fix: persist onboarding qscore baseline

This commit is contained in:
-Puter
2026-06-05 19:51:24 +05:30
parent 8e4fdc6adf
commit 213987a9e0
4 changed files with 161 additions and 3 deletions

View 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;
}

View File

@@ -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;

View File

@@ -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)

View File

@@ -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"));