fix: persist onboarding qscore baseline
This commit is contained in:
106
src/events/onboarding-qscore.ts
Normal file
106
src/events/onboarding-qscore.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { and, eq, sql } from "drizzle-orm";
|
||||
import { db } from "../db/client.js";
|
||||
import { growQscoreLatest, growQscoreProjectionState, growQscoreSignals } from "../db/schema.js";
|
||||
|
||||
export const ONBOARDING_BASELINE_SIGNAL_ID = "onboarding.completed_baseline";
|
||||
export const ONBOARDING_BASELINE_QSCORE = 35;
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
|
||||
}
|
||||
|
||||
function onboardingCompletedAt(preferences: Record<string, unknown> | undefined): Date | null {
|
||||
const onboarding = asRecord(preferences?.onboarding);
|
||||
const completedAt = onboarding.completed_at;
|
||||
if (typeof completedAt !== "string" || !completedAt.trim()) return null;
|
||||
const parsed = new Date(completedAt);
|
||||
return Number.isNaN(parsed.getTime()) ? new Date() : parsed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed the first real Q Score projection when onboarding is completed.
|
||||
*
|
||||
* The onboarding UI tells users their QX baseline starts at 35. Previously that
|
||||
* number was only cosmetic, while the header showed a separate home-feed
|
||||
* fallback and the Q Score page stayed empty. This makes the onboarding
|
||||
* baseline a persisted readiness signal, but only when the user has no Q Score
|
||||
* signals/projection yet so we do not overwrite mature accounts.
|
||||
*/
|
||||
export async function ensureOnboardingBaselineQscore(
|
||||
userId: string,
|
||||
preferences: Record<string, unknown> | undefined,
|
||||
): Promise<boolean> {
|
||||
const completedAt = onboardingCompletedAt(preferences);
|
||||
if (!completedAt) return false;
|
||||
|
||||
const [existingSignals] = await db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(growQscoreLatest)
|
||||
.where(and(eq(growQscoreLatest.userId, userId), eq(growQscoreLatest.present, true)));
|
||||
|
||||
const [existingProjection] = await db
|
||||
.select({ score: growQscoreProjectionState.score, signalCount: growQscoreProjectionState.signalCount })
|
||||
.from(growQscoreProjectionState)
|
||||
.where(eq(growQscoreProjectionState.userId, userId))
|
||||
.limit(1);
|
||||
|
||||
if (Number(existingSignals?.count ?? 0) > 0 || (existingProjection?.score ?? 0) > 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const raw = {
|
||||
reason: "completed onboarding baseline",
|
||||
completedAt: completedAt.toISOString(),
|
||||
};
|
||||
|
||||
const inserted = await db
|
||||
.insert(growQscoreLatest)
|
||||
.values({
|
||||
userId,
|
||||
signalId: ONBOARDING_BASELINE_SIGNAL_ID,
|
||||
score: ONBOARDING_BASELINE_QSCORE,
|
||||
present: true,
|
||||
source: "onboarding",
|
||||
raw,
|
||||
occurredAt: completedAt,
|
||||
updatedAt: now,
|
||||
})
|
||||
.onConflictDoNothing()
|
||||
.returning({ signalId: growQscoreLatest.signalId });
|
||||
|
||||
if (!inserted.length) return false;
|
||||
|
||||
await db.insert(growQscoreSignals).values({
|
||||
userId,
|
||||
signalId: ONBOARDING_BASELINE_SIGNAL_ID,
|
||||
score: ONBOARDING_BASELINE_QSCORE,
|
||||
present: true,
|
||||
source: "onboarding",
|
||||
raw,
|
||||
occurredAt: completedAt,
|
||||
});
|
||||
|
||||
await db
|
||||
.insert(growQscoreProjectionState)
|
||||
.values({
|
||||
userId,
|
||||
score: ONBOARDING_BASELINE_QSCORE,
|
||||
signalCount: 1,
|
||||
dimensions: { baseline: true, latestSignalIds: [ONBOARDING_BASELINE_SIGNAL_ID] },
|
||||
summary: "Baseline Q Score from completed onboarding.",
|
||||
updatedAt: now,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: growQscoreProjectionState.userId,
|
||||
set: {
|
||||
score: ONBOARDING_BASELINE_QSCORE,
|
||||
signalCount: 1,
|
||||
dimensions: { baseline: true, latestSignalIds: [ONBOARDING_BASELINE_SIGNAL_ID] },
|
||||
summary: "Baseline Q Score from completed onboarding.",
|
||||
updatedAt: now,
|
||||
},
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
type NewGrowHomeNotification,
|
||||
} from "../db/schema.js";
|
||||
import { interviewService, resumeService, roleplayService } from "../services/product-service-clients.js";
|
||||
import { ensureOnboardingBaselineQscore } from "../events/onboarding-qscore.js";
|
||||
import { refineHomeNotificationsWithAgent } from "./home-feed-agent.js";
|
||||
import {
|
||||
isAllowedNotificationHref,
|
||||
@@ -559,6 +560,7 @@ async function buildIdentity(ctx: HomeContext) {
|
||||
}
|
||||
|
||||
export async function getHomeFeed(userId: string, opts: { refresh?: boolean; userProfile?: Record<string, unknown>; preferences?: Record<string, unknown> } = {}): Promise<HomeFeedResponse> {
|
||||
await ensureOnboardingBaselineQscore(userId, opts.preferences);
|
||||
const ctx = await collectContext(userId, { userProfile: opts.userProfile, preferences: opts.preferences });
|
||||
const persisted = await readPersistedNotifications(userId);
|
||||
const newest = persisted[0]?.createdAt?.getTime() ?? 0;
|
||||
|
||||
@@ -8,6 +8,7 @@ import { db } from "../db/client.js";
|
||||
import { events, growQscoreLatest, growQscoreProjectionState } from "../db/schema.js";
|
||||
import { recordGrowEvent } from "../events/record-grow-event.js";
|
||||
import { routeGrowEventToUserActor } from "../events/route-to-user-actor.js";
|
||||
import { ensureOnboardingBaselineQscore } from "../events/onboarding-qscore.js";
|
||||
import { log } from "../log.js";
|
||||
|
||||
const LANDING_AGENTS = [
|
||||
@@ -118,6 +119,20 @@ async function proxyResumeRequest(req: Request, rest: string, userId: string) {
|
||||
});
|
||||
}
|
||||
|
||||
async function getUserServicePreferences(req: Request): Promise<Record<string, unknown> | undefined> {
|
||||
const target = new URL("/api/v1/users/me", config.userServiceUrl.replace(/\/$/, ""));
|
||||
const headers = new Headers(req.headers);
|
||||
headers.delete("host");
|
||||
headers.delete("cookie");
|
||||
const res = await fetch(target, { method: "GET", headers });
|
||||
if (!res.ok) return undefined;
|
||||
const userProfile = await res.json().catch(() => null) as Record<string, unknown> | null;
|
||||
const preferences = userProfile?.preferences;
|
||||
return preferences && typeof preferences === "object" && !Array.isArray(preferences)
|
||||
? preferences as Record<string, unknown>
|
||||
: undefined;
|
||||
}
|
||||
|
||||
async function proxySocialRequest(req: Request, rest: string, userId: string) {
|
||||
const incoming = new URL(req.url);
|
||||
const normalizedRest = rest.replace(/^\/+/, "");
|
||||
@@ -194,6 +209,12 @@ export function serviceRoutes() {
|
||||
|
||||
app.get("/qscore/current", async (c) => {
|
||||
const userId = c.get("userId");
|
||||
try {
|
||||
await ensureOnboardingBaselineQscore(userId, await getUserServicePreferences(c.req.raw));
|
||||
} catch (err) {
|
||||
log.warn({ err, userId }, "failed to seed onboarding Q Score baseline before current Q Score read");
|
||||
}
|
||||
|
||||
const [projection] = await db
|
||||
.select()
|
||||
.from(growQscoreProjectionState)
|
||||
|
||||
@@ -6,6 +6,7 @@ import { eq } from "drizzle-orm";
|
||||
import { provisionUserStack } from "../docker/manager.js";
|
||||
import { log } from "../log.js";
|
||||
import { config } from "../config.js";
|
||||
import { ensureOnboardingBaselineQscore } from "../events/onboarding-qscore.js";
|
||||
|
||||
function publicStack(stack: UserStack | null | undefined) {
|
||||
if (!stack) return stack;
|
||||
@@ -17,7 +18,7 @@ function userServiceTarget(path: string, search = "") {
|
||||
return new URL(`/api/v1/users${path}${search}`, config.userServiceUrl.replace(/\/$/, ""));
|
||||
}
|
||||
|
||||
async function proxyUserService(req: Request, path: string) {
|
||||
async function fetchUserService(req: Request, path: string) {
|
||||
const incoming = new URL(req.url);
|
||||
const target = userServiceTarget(path, incoming.search);
|
||||
const headers = new Headers(req.headers);
|
||||
@@ -26,7 +27,11 @@ async function proxyUserService(req: Request, path: string) {
|
||||
|
||||
const method = req.method.toUpperCase();
|
||||
const body = ["GET", "HEAD"].includes(method) ? undefined : await req.arrayBuffer();
|
||||
const res = await fetch(target, { method, headers, body });
|
||||
return fetch(target, { method, headers, body });
|
||||
}
|
||||
|
||||
async function proxyUserService(req: Request, path: string) {
|
||||
const res = await fetchUserService(req, path);
|
||||
return new Response(res.body, {
|
||||
status: res.status,
|
||||
statusText: res.statusText,
|
||||
@@ -89,7 +94,31 @@ export function userRoutes() {
|
||||
});
|
||||
|
||||
app.get("/me", async (c) => proxyUserService(c.req.raw, "/me"));
|
||||
app.patch("/me", async (c) => proxyUserService(c.req.raw, "/me"));
|
||||
app.patch("/me", async (c) => {
|
||||
const res = await fetchUserService(c.req.raw, "/me");
|
||||
const text = await res.text();
|
||||
|
||||
if (res.ok) {
|
||||
try {
|
||||
const userProfile = JSON.parse(text) as Record<string, unknown>;
|
||||
const preferences = userProfile.preferences;
|
||||
await ensureOnboardingBaselineQscore(
|
||||
c.get("userId"),
|
||||
preferences && typeof preferences === "object" && !Array.isArray(preferences)
|
||||
? (preferences as Record<string, unknown>)
|
||||
: undefined,
|
||||
);
|
||||
} catch (err) {
|
||||
log.warn({ err, userId: c.get("userId") }, "failed to seed onboarding Q Score baseline after user update");
|
||||
}
|
||||
}
|
||||
|
||||
return new Response(text, {
|
||||
status: res.status,
|
||||
statusText: res.statusText,
|
||||
headers: res.headers,
|
||||
});
|
||||
});
|
||||
app.get("/me/plan", async (c) => proxyUserService(c.req.raw, "/me/plan"));
|
||||
app.post("/me/photo", async (c) => proxyUserService(c.req.raw, "/me/photo"));
|
||||
app.delete("/me/photo", async (c) => proxyUserService(c.req.raw, "/me/photo"));
|
||||
|
||||
Reference in New Issue
Block a user