Compare commits

..

21 Commits

Author SHA1 Message Date
Sai-karthik
ea8f4c3df3 surface matchmaking agent failures 2026-07-17 07:50:25 +00:00
Sai-karthik
6d2eb6e22a fix matchmaking gateway failures 2026-07-17 07:40:07 +00:00
-Puter
35e29316df fix artifact mime parsing build 2026-07-16 13:25:39 +05:30
-Puter
5a519f6b52 fix artifact download response contract 2026-07-16 13:19:06 +05:30
-Puter
794fe29d8d add interview and roleplay parity adapters 2026-07-16 11:52:00 +05:30
-Puter
0bade06255 fix service gateway deadlines and roleplay controls 2026-07-16 10:00:45 +05:30
ce5be53b96 Merge pull request 'feat: expose live Career Report data contract' (#20) from feat/career-report-data into staging 2026-07-15 20:07:07 +00:00
Sai-karthik
fcbb93ca6a fix: distinguish missing career display contract 2026-07-15 19:40:50 +00:00
Sai-karthik
461b893fdc fix: harden career report producer contracts 2026-07-15 19:37:44 +00:00
Sai-karthik
712a4e57f3 feat: expose live career report data 2026-07-15 19:27:02 +00:00
d857b60111 Merge pull request 'feat: integrate live Home Insight snapshots' (#19) from feat/home-insight-live-aggregate into staging 2026-07-15 17:57:08 +00:00
Sai-karthik
171495f1e1 fix: preserve insight producer outages 2026-07-15 17:30:47 +00:00
Sai-karthik
7caf05f4f5 feat: expose producer-owned audience reach 2026-07-15 17:23:56 +00:00
Sai-karthik
15eb7e033c feat: aggregate live home insight snapshots 2026-07-15 17:22:02 +00:00
-Puter
63c9c18494 fix(roleplay): forward curator orchestration 2026-07-15 16:30:39 +05:30
-Puter
d4332f9f8b fix(curator): preserve active sprint on reads 2026-07-15 16:07:46 +05:30
-Puter
e8c6665ca0 fix(curator): gate profile and day progression 2026-07-15 15:36:15 +05:30
-Puter
d6f583f238 fix(roleplay): hydrate qscore with canonical identity 2026-07-14 22:41:32 +05:30
-Puter
d2353273d5 fix(interview): dedupe streak sessions by session id 2026-07-14 21:20:57 +05:30
-Puter
13ca12588b fix(websocket): project session ownership before tickets 2026-07-14 20:47:44 +05:30
6d2379ce61 Merge pull request 'Publish raw GrowEvents to RQ Score without backend evaluation' (#18) from refactor/qscore-single-owner-volatility into staging 2026-07-14 14:13:46 +00:00
24 changed files with 2595 additions and 70 deletions

View File

@@ -13,11 +13,15 @@
"test:curator-reconcile": "tsx scripts/curator-persisted-reconcile.test.ts",
"test:qscore-raw-events": "tsx scripts/qscore-raw-event-contract.test.ts",
"test:rq-score-contract": "tsx scripts/rq-score-contract.test.ts",
"test:home-insight-snapshot": "tsx scripts/home-insight-snapshot.test.ts",
"test:career-report": "tsx scripts/career-report.test.ts",
"test:onboarding-read": "tsx scripts/onboarding-rev10-read.test.ts",
"test:interview-user-identity": "tsx scripts/interview-user-identity.test.ts",
"test:ws-ticket": "tsx scripts/ws-ticket.test.ts",
"test:roleplay-drafts": "tsx scripts/roleplay-drafts.test.ts",
"test:artifact-transport": "tsx scripts/artifact-transport.test.ts",
"test:resume-gateway": "tsx scripts/resume-gateway.test.ts",
"test:roleplay-leaderboard": "tsx scripts/roleplay-leaderboard.test.ts",
"test:interview-leaderboard": "tsx scripts/interview-leaderboard.test.ts",
"start": "node dist/index.js",
"typecheck": "tsc -p tsconfig.json --noEmit",
"workflows:smoke": "tsx src/workflows/smoke-test.ts",

View File

@@ -0,0 +1,73 @@
import assert from "node:assert/strict";
import { createServer } from "node:http";
import type { AddressInfo } from "node:net";
import { serviceRoutes } from "../src/routes/services.js";
const originalFetch = globalThis.fetch;
const requests: Request[] = [];
const artifactTypes: Record<string, { mime: string; bytes: number[] }> = {
session_dual_wav: { mime: "audio/wav", bytes: [0x52, 0x49, 0x46, 0x46] },
session_mixed_mp3: { mime: "audio/mpeg", bytes: [0x49, 0x44, 0x33, 0x04] },
session_video: { mime: "video/webm", bytes: [0x1a, 0x45, 0xdf, 0xa3] },
session_video_mp4: { mime: "video/mp4", bytes: [0x00, 0x00, 0x00, 0x18] },
};
const storageServer = createServer((request, response) => {
const artifactType = request.url?.replace(/^\/stored\//, "") ?? "";
const artifact = artifactTypes[artifactType];
if (!artifact) { response.writeHead(404); response.end(); return; }
response.writeHead(200, { "content-type": artifact.mime, "content-length": artifact.bytes.length });
response.end(Buffer.from(artifact.bytes));
});
await new Promise<void>((resolve) => storageServer.listen(0, "127.0.0.1", resolve));
const storagePort = (storageServer.address() as AddressInfo).port;
const storageBaseUrl = `http://127.0.0.1:${storagePort}`;
globalThis.fetch = (async (input, init) => {
const request = new Request(input, init);
requests.push(request);
const path = new URL(request.url).pathname;
const metadataMatch = path.match(/\/api\/v1\/artifacts\/sess-1\/([^/]+)$/);
if (metadataMatch && artifactTypes[metadataMatch[1]]) {
const artifact = artifactTypes[metadataMatch[1]];
return new Response(JSON.stringify({ url: `${storageBaseUrl}/stored/${metadataMatch[1]}`, mime_type: artifact.mime }), { headers: { "content-type": "application/json" } });
}
if (path.startsWith("/stored/")) return originalFetch(input, init);
if (path.endsWith("/api/v1/sessions/sess-1/video/upload-url")) {
return new Response(JSON.stringify({ url: "http://s3.test/put", mime_type: "video/webm" }), { headers: { "content-type": "application/json" } });
}
if (path === "/put") return new Response(null, { status: 200 });
throw new Error(`unexpected upstream ${request.method} ${request.url}`);
}) as typeof fetch;
const app = serviceRoutes({ skipAuth: true });
try {
const artifacts = [
["session_dual_wav", "audio/wav", [0x52, 0x49, 0x46, 0x46], "wav"],
["session_mixed_mp3", "audio/mpeg", [0x49, 0x44, 0x33, 0x04], "mp3"],
["session_video", "video/webm", [0x1a, 0x45, 0xdf, 0xa3], "webm"],
["session_video_mp4", "video/mp4", [0x00, 0x00, 0x00, 0x18], "mp4"],
] as const;
for (const product of ["interview", "roleplay"] as const) {
for (const [artifactType, mime, expectedBytes, extension] of artifacts) {
const download = await app.request(`http://backend.test/${product}/artifacts/sess-1/${artifactType}/download`);
assert.equal(download.status, 200);
const downloadedBytes = new Uint8Array(await download.arrayBuffer());
assert.deepEqual(Array.from(downloadedBytes), expectedBytes);
assert.equal(download.headers.get("content-type"), mime);
assert.match(download.headers.get("content-disposition") ?? "", new RegExp(`^attachment; filename="${artifactType}\\.${extension}"$`));
assert.ok(downloadedBytes.byteLength > 0);
}
const upload = await app.request(`http://backend.test/${product}/sessions/sess-1/video/upload`, { method: "PUT", headers: { "content-type": "video/webm" }, body: new Uint8Array([9, 8, 7]) });
assert.equal(upload.status, 204);
const put = requests.at(-1)!;
assert.equal(put.method, "PUT");
assert.equal(put.headers.get("content-type"), "video/webm");
assert.deepEqual(Array.from(new Uint8Array(await put.arrayBuffer())), [9, 8, 7]);
}
} finally {
globalThis.fetch = originalFetch;
storageServer.close();
}
console.log("artifact transport gateway tests passed");

View File

@@ -0,0 +1,264 @@
import assert from "node:assert/strict";
import {
buildCareerReportProjection,
parseCareerReportDisplay,
type CareerReportActivity,
} from "../src/v1/analytics/career-report.js";
import type { HomeInsightSnapshots, InsightSnapshot } from "../src/v1/analytics/home-insight-snapshot.js";
import type { QscoreProxyResult } from "../src/services/qscore-proxy.js";
const categorySpecs = [
["learning_credentials", "Learning & Credentials"],
["resume_presence", "Resume Presence"],
["linkedin_presence", "LinkedIn Presence"],
["matching_activity", "Matching Activity"],
["interview_performance", "Interview Performance"],
["pathway_progress", "Pathway Progress"],
["roleplay_performance", "Roleplay Performance"],
["engagement_consistency", "Engagement & Consistency"],
["goals_readiness", "Goals & Readiness"],
];
function displayFixture() {
let signalIndex = 0;
const categories = categorySpecs.map(([id, label], categoryIndex) => {
const count = categoryIndex === categorySpecs.length - 1 ? 3 : 8;
const signals = Array.from({ length: count }, () => {
const index = signalIndex++;
return { id: `signal.${index}`, label: `Signal ${index}`, present: index < 4, score: index < 4 ? 50 : 0 };
});
return {
id,
label,
input_ids: [`input_${categoryIndex}`],
weight_percent: 10,
score: signals.some((signal) => signal.present) ? 50 : 0,
active: signals.some((signal) => signal.present),
present_rules: signals.filter((signal) => signal.present).length,
total_rules: signals.length,
signals,
};
});
return {
version: "rq-score-display-v1",
registered_signal_count: 67,
active_signal_count: 4,
categories,
};
}
function metric(overrides: Partial<InsightSnapshot> = {}): InsightSnapshot {
return {
status: "empty",
source: "fixture",
value: 0,
unit: "count",
label: "Fixture",
updatedAt: null,
reason: "no_evidence",
detail: null,
...overrides,
};
}
const snapshots: HomeInsightSnapshots = {
rqScore: metric({ status: "ready", source: "qscore.current", value: 61, reason: null }),
interview: metric({ source: "interview.page-state" }),
resume: metric({ status: "ready", source: "resume.state", value: 72, reason: null }),
opportunity: metric({ source: "matchmaking.scout-stats" }),
pathway: metric({ status: "unsupported", source: "qscore.breakdown.display", value: null, reason: "pathway_signal_contract_missing" }),
socialBrand: metric({ source: "social.state" }),
roleplay: metric({ source: "roleplay.page-state" }),
learning: metric({ source: "courses.course-state" }),
skillGap: metric({ source: "assessment.list" }),
};
const qscore: QscoreProxyResult = {
org_id: "growqr",
user_id: "00000000-0000-4000-8000-000000000000",
rq_score: 61,
iq_score: null,
eq_score: null,
sq_score: null,
profession: "",
formula_version: "v2",
formula_id: "fixture",
calculated_at: "2026-07-16T00:00:00Z",
quotients: {},
breakdown: { display: displayFixture() },
ledger_seq_from: null,
ledger_seq_to: null,
};
const activity: CareerReportActivity = {
windowDays: 14,
totalEvents: 0,
completedEvents: 0,
openedEvents: 0,
services: [],
opportunityEvents: 0,
};
const producerStatuses = {
interview: { status: "ready" as const, reason: null },
roleplay: { status: "ready" as const, reason: null },
resume: { status: "ready" as const, reason: null },
linkedin: { status: "ready" as const, reason: null },
matchmaking: { status: "ready" as const, reason: null },
courses: { status: "ready" as const, reason: null },
assessment: { status: "ready" as const, reason: null },
};
const report = buildCareerReportProjection({
generatedAt: "2026-07-16T01:00:00Z",
profile: {
status: "ready",
data: {
first_name: "Ada",
last_name: "Lovelace",
avatar_url: "https://images.example/ada.png",
updated_at: "2026-07-15T22:00:00Z",
preferences: {
onboarding: {
progress: { updated_at: "2026-07-15T23:00:00Z" },
responses: {
target_role: "Platform Engineer",
target_field: "Technology",
experience_level: "3-5 years",
current_situation: "employed",
desired_outcomes: ["Move into platform engineering"],
career_barriers: [],
},
},
},
},
},
qscore: { status: "ready", data: qscore },
qscoreHistory: { status: "ready", data: [{ date: "2026-07-15", rq_score: 61 }] },
snapshots,
producerStatuses,
activity,
});
assert.equal(report.contractVersion, "career-report-v1");
assert.deepEqual(report.subject.displayName, {
status: "ready",
source: "user-service.profile",
value: "Ada Lovelace",
updatedAt: "2026-07-15T22:00:00Z",
reason: null,
});
assert.equal(report.subject.targetRole.value, "Platform Engineer");
assert.equal(report.subject.barriers.status, "empty");
assert.deepEqual(report.subject.barriers.value, []);
assert.equal(report.rqScore.score.value, 61);
assert.equal(report.rqScore.display.status, "ready");
assert.equal(report.rqScore.display.value?.registeredSignalCount, 67);
assert.equal(report.rqScore.display.value?.categories.length, 9);
assert.deepEqual(report.activity.value, activity);
assert.equal(report.sources.find((source) => source.id === "grow_events")?.status, "ready");
const malformed = structuredClone(displayFixture());
malformed.registered_signal_count = 66;
assert.equal(parseCareerReportDisplay(malformed), null, "mislabelled canonical display must be rejected");
const reportWithMalformedDisplay = buildCareerReportProjection({
profile: { status: "ready", data: {} },
qscore: { status: "ready", data: { ...qscore, breakdown: { display: malformed } } },
qscoreHistory: { status: "ready", data: [] },
snapshots,
producerStatuses,
activity,
});
assert.equal(reportWithMalformedDisplay.rqScore.display.status, "unavailable");
assert.equal(reportWithMalformedDisplay.rqScore.display.value, null);
assert.equal(reportWithMalformedDisplay.rqScore.display.reason, "display_contract_invalid");
const reportWithoutDisplay = buildCareerReportProjection({
profile: { status: "ready", data: {} },
qscore: { status: "ready", data: { ...qscore, breakdown: {} } },
qscoreHistory: { status: "ready", data: [] },
snapshots,
producerStatuses,
activity,
});
assert.equal(reportWithoutDisplay.rqScore.display.status, "unsupported");
assert.equal(reportWithoutDisplay.rqScore.display.value, null);
assert.equal(reportWithoutDisplay.rqScore.display.reason, "display_contract_missing");
const wrongCategoryId = structuredClone(displayFixture());
wrongCategoryId.categories[0].id = "not_a_canonical_category";
assert.equal(parseCareerReportDisplay(wrongCategoryId), null, "wrong category ids must be rejected");
const wrongCategoryOrder = structuredClone(displayFixture());
[wrongCategoryOrder.categories[0], wrongCategoryOrder.categories[1]] = [wrongCategoryOrder.categories[1], wrongCategoryOrder.categories[0]];
assert.equal(parseCareerReportDisplay(wrongCategoryOrder), null, "canonical categories out of order must be rejected");
const outOfRangeCategoryScore = structuredClone(displayFixture());
outOfRangeCategoryScore.categories[0].score = 101;
assert.equal(parseCareerReportDisplay(outOfRangeCategoryScore), null, "category scores above 100 must be rejected");
const outOfRangeSignalScore = structuredClone(displayFixture());
outOfRangeSignalScore.categories[0].signals[0].score = -1;
assert.equal(parseCareerReportDisplay(outOfRangeSignalScore), null, "signal scores below zero must be rejected");
const outOfRangeWeight = structuredClone(displayFixture());
outOfRangeWeight.categories[0].weight_percent = 101;
assert.equal(parseCareerReportDisplay(outOfRangeWeight), null, "category weights above 100 must be rejected");
const duplicatedSignal = structuredClone(displayFixture());
duplicatedSignal.categories[1].signals[0].id = duplicatedSignal.categories[0].signals[0].id;
assert.equal(parseCareerReportDisplay(duplicatedSignal), null, "signals assigned more than once must be rejected");
const categoryCountMismatch = structuredClone(displayFixture());
categoryCountMismatch.categories[0].present_rules += 1;
assert.equal(parseCareerReportDisplay(categoryCountMismatch), null, "category count mismatches must be rejected");
const malformedOnboarding = buildCareerReportProjection({
profile: {
status: "ready",
data: {
preferences: {
onboarding: {
responses: {
target_role: 42,
desired_outcomes: ["Real goal", 7],
},
},
},
},
},
qscore: { status: "ready", data: qscore },
qscoreHistory: { status: "ready", data: [] },
snapshots,
producerStatuses,
activity,
});
assert.equal(malformedOnboarding.subject.targetRole.status, "unavailable");
assert.equal(malformedOnboarding.subject.targetRole.value, null);
assert.equal(malformedOnboarding.subject.targetRole.reason, "producer_response_invalid");
assert.equal(malformedOnboarding.subject.goals.status, "unavailable");
assert.equal(malformedOnboarding.subject.goals.value, null);
assert.equal(malformedOnboarding.subject.barriers.status, "empty", "missing optional arrays remain confirmed empty");
assert.deepEqual(malformedOnboarding.subject.barriers.value, []);
const unavailable = buildCareerReportProjection({
profile: { status: "unavailable", reason: "profile_unavailable" },
qscore: { status: "unavailable", reason: "request_failed" },
qscoreHistory: { status: "unavailable", reason: "request_failed" },
snapshots: Object.fromEntries(
Object.keys(snapshots).map((key) => [key, metric({ status: "unavailable", value: null, reason: "producer_unavailable" })]),
) as HomeInsightSnapshots,
producerStatuses: Object.fromEntries(
Object.keys(producerStatuses).map((key) => [key, { status: "unavailable", reason: "producer_unavailable" }]),
) as typeof producerStatuses,
activity,
});
assert.equal(unavailable.subject.displayName.status, "unavailable");
assert.equal(unavailable.subject.displayName.value, null);
assert.equal(unavailable.rqScore.score.status, "unavailable");
assert.equal(unavailable.rqScore.score.value, null);
assert.equal(unavailable.rqScore.history.value, null);
assert.equal(unavailable.rqScore.display.value, null);
console.log("career report contract: ok");

View File

@@ -1,11 +1,18 @@
import assert from "node:assert/strict";
import { serviceRoutes } from "../src/routes/services.js";
import { config } from "../src/config.js";
const originalFetch = globalThis.fetch;
const calls: Array<{ url: string; body: Record<string, unknown> }> = [];
const calls: Array<{ url: string; body: Record<string, unknown>; headers: Headers }> = [];
globalThis.fetch = (async (input, init) => {
const body = JSON.parse(String(init?.body ?? "{}")) as Record<string, unknown>;
calls.push({ url: String(input), body });
calls.push({ url: String(input), body, headers: new Headers(init?.headers) });
if (body.action === "force_forbidden") {
return new Response(JSON.stringify({ detail: "Invalid A2A auth token" }), { status: 403, headers: { "content-type": "application/json" } });
}
if (body.action === "agent_error") {
return new Response(JSON.stringify({ task_id: "a2a-task", status: "completed", messages: [{ type: "agent_error", action: "search_failed", data: { code: "job_boards_unavailable" } }] }), { status: 200, headers: { "content-type": "application/json" } });
}
return new Response(JSON.stringify({ task_id: "a2a-task", status: "completed", messages: [] }), { status: 200, headers: { "content-type": "application/json" } });
}) as typeof fetch;
const app = serviceRoutes({ skipAuth: true });
@@ -24,6 +31,15 @@ try {
const matchmakingResponse = await app.request("http://backend.test/matchmaking/a2a", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ action: "mark_applied", params: { opportunity_id: "job-1" } }) });
assert.equal(matchmakingResponse.status, 200);
assert.ok(calls[0]?.url.endsWith("/a2a/tasks"));
assert.equal(calls[0]?.headers.get("authorization"), config.a2aAllowedKey ? `Bearer ${config.a2aAllowedKey}` : null);
const forbiddenResponse = await app.request("http://backend.test/matchmaking/a2a", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ action: "force_forbidden" }) });
assert.equal(forbiddenResponse.status, 502);
assert.match(await forbiddenResponse.text(), /matchmaking service authentication failed/);
const agentErrorResponse = await app.request("http://backend.test/matchmaking/a2a", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ action: "agent_error" }) });
assert.equal(agentErrorResponse.status, 502);
assert.match(await agentErrorResponse.text(), /could not complete the search/);
} finally {
globalThis.fetch = originalFetch;
}

View File

@@ -6,6 +6,7 @@ import {
decideCuratorSprintReconciliation,
resolveCuratorSprintAccess,
shouldReconcileCuratorSprint,
shouldRebaseCuratorSprintForOnboardingContext,
} from "../src/v1/curator/curator-store.js";
import { templateSetFor, trialDaysFor } from "../src/v1/curator/task-registry.js";
@@ -51,6 +52,26 @@ const startDate = "2026-07-13";
const studentFingerprint = curatorSprintPlanFingerprint({ startDate, variantId: "student_recent_grad", durationDays: 2, access: "trial" });
const experiencedFingerprint = curatorSprintPlanFingerprint({ startDate, variantId: "experienced_professional", durationDays: 2, access: "trial" });
const experiencedPlan = curatorSprintTaskPlan({ startDate, variantId: "experienced_professional", durationDays: 2, access: "trial" });
assert.equal(
shouldRebaseCuratorSprintForOnboardingContext({
existingStartDate: "2026-07-14",
focusDate: "2026-07-15",
onboardingContextProvided: true,
existingCompatible: true,
}),
false,
"a normal sprint read with onboarding context must preserve the active sprint across days",
);
assert.equal(
shouldRebaseCuratorSprintForOnboardingContext({
existingStartDate: "2026-07-14",
focusDate: "2026-07-15",
onboardingContextProvided: true,
existingCompatible: false,
}),
true,
"changed onboarding compatibility may replace the existing sprint without resetting its calendar identity",
);
assert.notEqual(studentFingerprint, experiencedFingerprint, "ICP changes must change the compatibility fingerprint");
assert.equal(shouldReconcileCuratorSprint(
{ access: "trial", durationDays: 2, variantId: "student_recent_grad", planFingerprint: studentFingerprint },

View File

@@ -97,6 +97,7 @@ if (!databaseAvailable) {
category: "mission" as const,
occurredAt: new Date(Date.now() + index),
dedupeKey: `${userId}:curator.task.completed:${task.id}`,
correlation: { task_id: task.id },
payload: { taskId: task.id },
})));

View File

@@ -0,0 +1,20 @@
import assert from "node:assert/strict";
import { resolveProfileTaskPolicy } from "../src/v1/curator/streak-utils.js";
assert.deepEqual(
resolveProfileTaskPolicy({ resumeAvailable: true, linkedinAvailable: false }),
{ complete: false, serviceId: "social-branding-service" },
"a resume upload must not complete or reroute the LinkedIn profile task",
);
assert.deepEqual(
resolveProfileTaskPolicy({ resumeAvailable: false, linkedinAvailable: true }),
{ complete: true, serviceId: "social-branding-service" },
"an existing LinkedIn connection completes the task on the next sprint read",
);
assert.deepEqual(
resolveProfileTaskPolicy({ resumeAvailable: true, linkedinAvailable: true }),
{ complete: true, serviceId: "social-branding-service" },
"LinkedIn remains the completion signal when both profile assets exist",
);
console.log("curator profile-task policy regression: ok");

View File

@@ -5,8 +5,21 @@ import {
resolveAgedTrialReuse,
} from "../src/v1/curator/streak-utils.js";
assert.equal(curatorLogicalDate(new Date("2026-07-14T18:29:59.999Z")), "2026-07-14");
assert.equal(curatorLogicalDate(new Date("2026-07-14T18:30:00.000Z")), "2026-07-15");
assert.equal(
curatorLogicalDate(new Date("2026-07-15T03:29:59.999Z")),
"2026-07-14",
"the previous Curator day remains active until 09:00 Asia/Kolkata",
);
assert.equal(
curatorLogicalDate(new Date("2026-07-15T03:30:00.000Z")),
"2026-07-15",
"the next Curator day starts at 09:00 Asia/Kolkata",
);
assert.equal(
curatorLogicalDate(new Date("2026-07-15T02:30:00.000Z"), 8),
"2026-07-15",
"deployments may explicitly configure a different business-morning hour",
);
const reuse = resolveAgedTrialReuse({
existing: { startDate: "2026-07-14", version: "icp-v10-static", durationDays: 2, access: "trial" },
desired: { durationDays: 2, access: "trial" },

View File

@@ -0,0 +1,32 @@
import assert from "node:assert/strict";
import { INTERACTIVE_SERVICE_TIMEOUT_MS, serviceJson, roleplayService } from "../src/services/product-service-clients.js";
assert.equal(INTERACTIVE_SERVICE_TIMEOUT_MS, 240_000, "interactive service deadline must stay inside the 300s proxy budget");
const originalFetch = globalThis.fetch;
const originalTimeout = AbortSignal.timeout;
const requests: Request[] = [];
let capturedTimeout = 0;
AbortSignal.timeout = ((ms: number) => {
capturedTimeout = ms;
return new AbortController().signal;
}) as typeof AbortSignal.timeout;
globalThis.fetch = (async (input, init) => {
const request = new Request(input, init);
requests.push(request);
return new Response(JSON.stringify({ ok: true }), { status: 200, headers: { "content-type": "application/json" } });
}) as typeof fetch;
try {
await roleplayService.plan("session-1", "user-1");
assert.equal(new URL(requests[0]!.url).pathname, "/api/v1/roleplays/sessions/session-1/plan");
assert.equal(capturedTimeout, INTERACTIVE_SERVICE_TIMEOUT_MS, "interactive plan calls must use the inner deadline");
requests.length = 0;
capturedTimeout = 0;
await serviceJson("http://service.test", "/slow", { timeoutMs: 17 });
assert.equal(capturedTimeout, 17);
} finally {
AbortSignal.timeout = originalTimeout;
globalThis.fetch = originalFetch;
}
console.log("deadline hierarchy tests passed");

View File

@@ -0,0 +1,239 @@
import assert from "node:assert/strict";
import { buildHomeInsightSnapshots } from "../src/v1/analytics/home-insight-snapshot.js";
import type { HomeInsightProducerStatuses } from "../src/v1/analytics/home-insight-snapshot.js";
import type { QscoreProxyResult, QscoreReadResult } from "../src/services/qscore-proxy.js";
function qscoreFixture(): QscoreProxyResult {
const category = (id: string, score: number, active = true, totalRules = 2) => ({
id,
label: id,
score,
active,
present_rules: active ? 1 : 0,
total_rules: totalRules,
weight_percent: 10,
signals: [],
});
return {
org_id: "growqr",
user_id: "00000000-0000-4000-8000-000000000000",
rq_score: 0,
iq_score: null,
eq_score: null,
sq_score: null,
profession: "",
formula_version: "v2",
formula_id: "fixture",
calculated_at: "2026-07-15T00:00:00Z",
quotients: {},
breakdown: {
display: {
version: "rq-score-display-v1",
registered_signal_count: 67,
active_signal_count: 7,
categories: [
category("learning_credentials", 45),
category("resume_presence", 55),
category("linkedin_presence", 65),
category("matching_activity", 75),
category("interview_performance", 85),
category("pathway_progress", 0, false, 0),
category("roleplay_performance", 70),
category("engagement_consistency", 40),
category("goals_readiness", 35),
],
},
},
ledger_seq_from: null,
ledger_seq_to: null,
};
}
function inactiveCategoryQscoreFixture(): QscoreProxyResult {
const fixture = qscoreFixture();
const display = fixture.breakdown.display as { categories: Array<Record<string, unknown>> };
for (const category of display.categories) {
if (category.id === "pathway_progress") continue;
category.score = 0;
category.active = false;
category.present_rules = 0;
}
return fixture;
}
const readyQscore: QscoreReadResult<QscoreProxyResult> = { status: "ready", data: qscoreFixture() };
const history = { status: "ready" as const, data: [{ rq_score: 0, date: "2026-07-15" }] };
const producers = {
interviewPageState: async () => ({ recent_sessions: [{ overall_score: 0, completed_at: "2026-07-15T01:00:00Z" }] }),
roleplayPageState: async () => ({ recent_sessions: [{ overall_score: 0, completed_at: "2026-07-15T02:00:00Z" }] }),
resumeState: async () => ({ resume_count: 1, resume_completeness: 0, resume_status: "draft" }),
socialState: async () => ({
linkedin_connected: true,
profile_analysis_score: 0,
followers_count: 0,
connections_count: 0,
skills_count: 0,
experience_count: 0,
improvements_count: 0,
section_scores: {},
}),
matchmakingStats: async () => ({
status: "completed",
messages: [{ action: "scout_stats", data: { matchesFound: 0, funnel: [], searches: 0, tailoredResumes: 0 } }],
}),
courseSummary: async () => ({
status: "completed",
messages: [{
action: "course_state_loaded",
data: { has_interest_profile: true, watch_count: 0, watch_stats: { watched: 0, total_s: 0, anchors: [] } },
}],
}),
assessments: async () => ({ items: [], total: 0 }),
};
const genuineZeros = await buildHomeInsightSnapshots({
userId: "user_fixture",
qscore: readyQscore,
qscoreHistory: history,
recentEvents: [{ payload: { result: { weakness_tags: ["SQL", "sql", "System design"] } } }],
}, producers);
assert.deepEqual(Object.keys(genuineZeros), [
"rqScore",
"interview",
"resume",
"opportunity",
"pathway",
"socialBrand",
"roleplay",
"learning",
"skillGap",
]);
for (const value of Object.values(genuineZeros)) {
assert.deepEqual(Object.keys(value), ["status", "source", "value", "unit", "label", "updatedAt", "reason", "detail"]);
}
assert.deepEqual(
{
rq: [genuineZeros.rqScore.status, genuineZeros.rqScore.value],
interview: [genuineZeros.interview.status, genuineZeros.interview.value],
resume: [genuineZeros.resume.status, genuineZeros.resume.value],
opportunity: [genuineZeros.opportunity.status, genuineZeros.opportunity.value],
social: [genuineZeros.socialBrand.status, genuineZeros.socialBrand.value],
roleplay: [genuineZeros.roleplay.status, genuineZeros.roleplay.value],
learning: [genuineZeros.learning.status, genuineZeros.learning.value],
skillGap: [genuineZeros.skillGap.status, genuineZeros.skillGap.value],
pathway: [genuineZeros.pathway.status, genuineZeros.pathway.value],
},
{
rq: ["ready", 0],
interview: ["ready", 0],
resume: ["ready", 0],
opportunity: ["empty", 0],
social: ["ready", 0],
roleplay: ["ready", 0],
learning: ["empty", 0],
skillGap: ["ready", 2],
pathway: ["unsupported", null],
},
"genuine zero, confirmed empty, and unsupported must remain distinct",
);
assert.deepEqual(genuineZeros.socialBrand.detail?.audienceReach, {
value: 0,
unit: "followers",
label: "Audience reach",
updatedAt: null,
}, "Audience Reach must be an explicit producer-owned display object and preserve genuine zero");
const failed = async () => { throw new Error("unreachable"); };
const unavailable = await buildHomeInsightSnapshots({
userId: "user_fixture",
qscore: { status: "unavailable", reason: "request_failed" },
qscoreHistory: { status: "unavailable", reason: "request_failed" },
recentEvents: [],
}, {
interviewPageState: failed,
roleplayPageState: failed,
resumeState: failed,
socialState: failed,
matchmakingStats: failed,
courseSummary: failed,
assessments: failed,
});
for (const [key, value] of Object.entries(unavailable)) {
assert.equal(value.status, "unavailable", `${key} outage must be unavailable`);
assert.equal(value.value, null, `${key} outage must not become zero`);
}
const inactiveQscore: QscoreReadResult<QscoreProxyResult> = {
status: "ready",
data: inactiveCategoryQscoreFixture(),
};
const primaryOutageWithNoQscoreEvidence = await buildHomeInsightSnapshots({
userId: "user_fixture",
qscore: inactiveQscore,
qscoreHistory: history,
recentEvents: [],
}, {
interviewPageState: failed,
roleplayPageState: failed,
resumeState: failed,
socialState: failed,
matchmakingStats: failed,
courseSummary: failed,
assessments: failed,
});
for (const key of ["interview", "resume", "opportunity", "socialBrand", "roleplay", "learning", "skillGap"] as const) {
assert.equal(primaryOutageWithNoQscoreEvidence[key].status, "unavailable", `${key} outage must not be masked by inactive qscore evidence`);
assert.equal(primaryOutageWithNoQscoreEvidence[key].value, null, `${key} outage with inactive qscore must not become zero`);
}
const malformedCourseA2a = await buildHomeInsightSnapshots({
userId: "user_fixture",
qscore: inactiveQscore,
qscoreHistory: history,
recentEvents: [],
}, {
...producers,
courseSummary: async () => ({ status: "completed", messages: [] }),
});
assert.equal(malformedCourseA2a.learning.status, "unavailable");
assert.equal(malformedCourseA2a.learning.value, null);
assert.equal(malformedCourseA2a.learning.reason, "producer_response_invalid");
assert.equal(malformedCourseA2a.learning.source, "courses.course-state");
const malformedA2a = await buildHomeInsightSnapshots({
userId: "user_fixture",
qscore: readyQscore,
qscoreHistory: history,
recentEvents: [],
}, {
...producers,
matchmakingStats: async () => ({ status: "completed", messages: [] }),
});
assert.equal(malformedA2a.opportunity.status, "ready", "valid qscore category is the honest fallback");
assert.equal(malformedA2a.opportunity.source, "qscore.breakdown.display");
assert.equal(malformedA2a.opportunity.value, 75);
let malformedDirectStatuses: HomeInsightProducerStatuses | null = null;
await buildHomeInsightSnapshots({
userId: "user_fixture",
qscore: readyQscore,
qscoreHistory: history,
recentEvents: [],
onProducerStatuses: (statuses) => { malformedDirectStatuses = statuses; },
}, {
...producers,
resumeState: async () => ({}),
assessments: async () => ({}),
});
assert.equal(malformedDirectStatuses?.resume.status, "unavailable", "malformed resume objects must not mark the direct source ready");
assert.equal(malformedDirectStatuses?.resume.reason, "producer_response_invalid");
assert.equal(malformedDirectStatuses?.assessment.status, "unavailable", "malformed assessment objects must not mark the direct source ready");
assert.equal(malformedDirectStatuses?.assessment.reason, "producer_response_invalid");
console.log("home insight snapshot contract: ok");

View File

@@ -0,0 +1,39 @@
import assert from "node:assert/strict";
import { serviceRoutes } from "../src/routes/services.js";
const originalFetch = globalThis.fetch;
const requests: Request[] = [];
globalThis.fetch = (async (input, init) => {
const request = new Request(input, init);
requests.push(request);
return new Response(JSON.stringify({ entries: [] }), {
status: 200,
headers: { "content-type": "application/json" },
});
}) as typeof fetch;
const app = serviceRoutes({ skipAuth: true });
try {
const leaderboard = await app.request("http://backend.test/interview/leaderboard?org_id=org-X&limit=100");
assert.equal(leaderboard.status, 200);
assert.deepEqual(await leaderboard.json(), { entries: [] });
const upstream = new URL(requests[0]!.url);
assert.equal(upstream.pathname, "/api/v1/leaderboard");
assert.equal(upstream.searchParams.get("limit"), "100");
assert.equal(upstream.searchParams.get("org_id"), "org-X");
assert.match(requests[0]!.headers.get("authorization") ?? "", /^Bearer /);
const clamped = await app.request("http://backend.test/interview/leaderboard?limit=101");
assert.equal(clamped.status, 200);
assert.equal(new URL(requests[1]!.url).searchParams.get("limit"), "100");
const defaulted = await app.request("http://backend.test/interview/leaderboard?limit=not-a-number");
assert.equal(defaulted.status, 200);
assert.equal(new URL(requests[2]!.url).searchParams.get("limit"), "10");
} finally {
globalThis.fetch = originalFetch;
}
console.log("interview leaderboard gateway: all assertions passed");

View File

@@ -12,9 +12,13 @@ globalThis.fetch = (async (input, init) => {
? { drafts: [{ session_id: "draft-1" }] }
: url.pathname.endsWith("/plan")
? { session_id: "draft-1", status: "draft" }
: url.pathname.endsWith("/configure/approve")
? { session_id: "draft-1", status: "configured", approved: true }
: { deleted: true, session_id: "draft-1" };
: url.pathname.endsWith("/video-usage")
? { used: 1, limit: 3, remaining: 2 }
: url.pathname.endsWith("/vip-check")
? { valid: true }
: url.pathname.endsWith("/configure/approve")
? { session_id: "draft-1", status: "configured", approved: true }
: { deleted: true, session_id: "draft-1" };
return new Response(JSON.stringify(body), {
status: 200,
headers: { "content-type": "application/json" },
@@ -42,13 +46,16 @@ try {
assert.equal(deleted.status, 200);
assert.deepEqual(await deleted.json(), { deleted: true, session_id: "draft-1" });
assert.equal(requests.length, 4);
assert.match(requests[0]!.url, /\/api\/v1\/roleplays\/drafts\?user_id=user_test&limit=25$/);
assert.match(requests[1]!.url, /\/api\/v1\/roleplays\/sessions\/draft-1\/plan$/);
assert.match(requests[2]!.url, /\/api\/v1\/roleplays\/configure\/approve$/);
assert.equal(requests[3]!.method, "DELETE");
assert.match(requests[3]!.url, /\/api\/v1\/roleplays\/drafts\/draft-1$/);
for (const request of requests) {
const usage = await app.request("http://backend.test/roleplay/video-usage");
assert.equal(usage.status, 200);
assert.deepEqual(await usage.json(), { used: 1, limit: 3, remaining: 2 });
const vip = await app.request("http://backend.test/roleplay/vip-check?code=grow26");
assert.equal(vip.status, 200);
assert.deepEqual(await vip.json(), { valid: true });
assert.match(requests.find((request) => request.url.includes("/api/v1/roleplays/vip-check"))?.url ?? "", /code=grow26&user_id=user_test$/);
assert.match(requests.find((request) => request.url.includes("/api/v1/roleplays/video-usage"))?.url ?? "", /\/api\/v1\/roleplays\/video-usage\?user_id=user_test$/);
assert.ok(requests.length >= 5);
for (const request of requests.filter((request) => request.url.includes("/api/v1/"))) {
assert.equal(request.headers.get("x-growqr-user"), "user_test");
assert.match(request.headers.get("authorization") ?? "", /^Bearer /);
}

View File

@@ -0,0 +1,39 @@
import assert from "node:assert/strict";
import { serviceRoutes } from "../src/routes/services.js";
const originalFetch = globalThis.fetch;
const requests: Request[] = [];
globalThis.fetch = (async (input, init) => {
const request = new Request(input, init);
requests.push(request);
return new Response(JSON.stringify({ entries: [] }), {
status: 200,
headers: { "content-type": "application/json" },
});
}) as typeof fetch;
const app = serviceRoutes({ skipAuth: true });
try {
const leaderboard = await app.request("http://backend.test/roleplay/leaderboard?org_id=org-X&limit=100");
assert.equal(leaderboard.status, 200);
assert.deepEqual(await leaderboard.json(), { entries: [] });
const upstream = new URL(requests[0]!.url);
assert.equal(upstream.pathname, "/api/v1/roleplays/leaderboard");
assert.equal(upstream.searchParams.get("limit"), "100");
assert.equal(upstream.searchParams.get("org_id"), "org-X");
assert.match(requests[0]!.headers.get("authorization") ?? "", /^Bearer /);
const clamped = await app.request("http://backend.test/roleplay/leaderboard?limit=101");
assert.equal(clamped.status, 200);
assert.equal(new URL(requests[1]!.url).searchParams.get("limit"), "100");
const defaulted = await app.request("http://backend.test/roleplay/leaderboard?limit=not-a-number");
assert.equal(defaulted.status, 200);
assert.equal(new URL(requests[2]!.url).searchParams.get("limit"), "10");
} finally {
globalThis.fetch = originalFetch;
}
console.log("roleplay leaderboard gateway: all assertions passed");

View File

@@ -0,0 +1,101 @@
import assert from "node:assert/strict";
import { buildPersonalizedRoleplayConfigurePayload } from "../src/routes/services.js";
import { toQscoreUserId } from "../src/services/qscore-proxy.js";
const clerkUserId = "user_3GTcs6PBFcUrXUP6bH6U8dcwXBq";
const originalFetch = globalThis.fetch;
const requests: Request[] = [];
globalThis.fetch = (async (input, init) => {
const request = new Request(input, init);
requests.push(request);
return new Response(
JSON.stringify({
org_id: "growqr",
user_id: toQscoreUserId(clerkUserId),
rq_score: 61,
iq_score: 60,
eq_score: 62,
sq_score: 61,
profession: "General",
formula_version: "v1",
formula_id: "default",
calculated_at: "2026-07-14T00:00:00Z",
quotients: {},
breakdown: {},
ledger_seq_from: null,
ledger_seq_to: null,
}),
{ status: 200, headers: { "content-type": "application/json" } },
);
}) as typeof fetch;
try {
const payload = await buildPersonalizedRoleplayConfigurePayload(
new Request("http://backend.test/roleplay/preview"),
{
scenario_type: "salary-negotiation",
metadata: { difficulty: "medium" },
},
clerkUserId,
{
resolveUserContext: async () => ({ clerk_id: clerkUserId }),
},
);
assert.equal(requests.length, 1);
const qscoreUrl = new URL(requests[0]!.url);
assert.equal(qscoreUrl.pathname, `/v1/qscore/${toQscoreUserId(clerkUserId)}`);
assert.equal(qscoreUrl.searchParams.get("org_id"), "growqr");
assert.equal((payload.qscore as Record<string, unknown>).rq_score, 61);
assert.equal(payload.user_id, clerkUserId);
requests.length = 0;
const suppliedPayload = await buildPersonalizedRoleplayConfigurePayload(
new Request("http://backend.test/roleplay/preview"),
{
qscore: { rq_score: 74, source: "caller" },
metadata: {},
},
clerkUserId,
{
resolveUserContext: async () => ({
clerk_id: clerkUserId,
qscore: { rq_score: 68, source: "context" },
}),
},
);
assert.equal(requests.length, 0);
assert.deepEqual(suppliedPayload.qscore, { rq_score: 74, source: "caller" });
const curatorTaskId = "curator-sprint:icp-v10-static:2026-07-14:day-2:practice:roleplay";
const correlatedPayload = await buildPersonalizedRoleplayConfigurePayload(
new Request("http://backend.test/roleplay/preview"),
{
qscore: { rq_score: 74 },
metadata: {},
curatorTaskId,
taskId: curatorTaskId,
mission: {
source: "curator-v1",
missionId: "curator-sprint",
missionInstanceId: "curator-sprint:icp-v10-static:2026-07-14",
stageId: "wk-1:day-2:practice:roleplay",
},
},
clerkUserId,
{ resolveUserContext: async () => ({ clerk_id: clerkUserId }) },
);
assert.deepEqual(correlatedPayload.mission, {
source: "curator-v1",
missionId: "curator-sprint",
missionInstanceId: "curator-sprint:icp-v10-static:2026-07-14",
stageId: "wk-1:day-2:practice:roleplay",
});
assert.equal(correlatedPayload.curatorTaskId, curatorTaskId);
assert.equal(correlatedPayload.taskId, curatorTaskId);
console.log("roleplay QScore identity: all assertions passed");
} finally {
globalThis.fetch = originalFetch;
}

View File

@@ -0,0 +1,122 @@
import assert from "node:assert/strict";
import type { GrowEventRow } from "../src/db/schema.js";
import { recordGatewayEvent } from "../src/routes/services.js";
for (const service of [
{
source: "interview-service",
type: "interview.session.configured",
serviceId: "interview",
},
{
source: "roleplay-service",
type: "roleplay.scenario.configured",
serviceId: "roleplay",
},
] as const) {
const event = {
id: `event-${service.serviceId}`,
userId: "user-1",
orgId: null,
source: service.source,
type: service.type,
category: "service",
occurredAt: new Date("2026-07-14T00:00:00.000Z"),
receivedAt: new Date("2026-07-14T00:00:00.000Z"),
mission: null,
subject: { serviceId: service.serviceId, externalId: "session-1" },
correlation: { sessionId: "session-1" },
payload: {},
raw: null,
dedupeKey: `dedupe-${service.serviceId}`,
processingStatus: "pending",
processingError: null,
processedAt: null,
} as GrowEventRow;
const order: string[] = [];
const returned = await recordGatewayEvent(
{
userId: "user-1",
source: service.source,
type: service.type,
payload: { session_id: "session-1" },
},
{
recordLegacyEvent: async () => {
order.push("legacy");
},
recordGrowEvent: async () => {
order.push("record");
return event;
},
applyServiceSessionProjection: async (projectedEvent) => {
assert.strictEqual(projectedEvent, event);
order.push("project");
return null;
},
routeGrowEventToUserActor: async (routedEvent) => {
assert.strictEqual(routedEvent, event);
order.push("route");
},
},
);
assert.strictEqual(returned, event);
assert.deepEqual(order, ["legacy", "record", "project", "route"], `${service.serviceId} must project ownership before actor routing`);
}
const configureDedupeKeys: string[] = [];
for (const sessionId of ["streak-session-1", "streak-session-2"]) {
await recordGatewayEvent(
{
userId: "user-1",
source: "interview-service",
type: "interview.session.configured",
payload: {
request: { curatorTaskId: "curator-task-1", taskId: "curator-task-1" },
result: { session_id: sessionId },
},
correlation: {
sessionId,
curatorTaskId: "curator-task-1",
taskId: "curator-task-1",
},
},
{
recordLegacyEvent: async () => undefined,
recordGrowEvent: async (input) => {
configureDedupeKeys.push(input.dedupeKey ?? "");
return {
id: `event-${sessionId}`,
userId: "user-1",
orgId: null,
source: "interview-service",
type: "interview.session.configured",
category: "service",
occurredAt: new Date("2026-07-14T00:00:00.000Z"),
receivedAt: new Date("2026-07-14T00:00:00.000Z"),
mission: null,
subject: { serviceId: "interview", externalId: sessionId },
correlation: { sessionId, curatorTaskId: "curator-task-1" },
payload: {},
raw: null,
dedupeKey: input.dedupeKey ?? null,
processingStatus: "pending",
processingError: null,
processedAt: null,
} as GrowEventRow;
},
applyServiceSessionProjection: async () => null,
routeGrowEventToUserActor: async () => undefined,
},
);
}
assert.notEqual(
configureDedupeKeys[0],
configureDedupeKeys[1],
"reconfiguring the same curator streak task must record ownership for each generated interview session",
);
console.log("interview and roleplay service session projection ordering tests passed");

View File

@@ -10,6 +10,7 @@ import { signWsTicket } from "../services/ws-tickets.js";
import { db } from "../db/client.js";
import { events, growEvents, missionServiceSessions } from "../db/schema.js";
import { recordGrowEvent } from "../events/record-grow-event.js";
import { applyServiceSessionProjection } from "../events/projectors/service-session-projector.js";
import { routeGrowEventToUserActor } from "../events/route-to-user-actor.js";
import { getRequestUserPreferences, getRequestUserProfile } from "../services/user-context.js";
import { log } from "../log.js";
@@ -224,20 +225,21 @@ function gatewayDedupeKey(input: {
const subject = input.subject ?? {};
const externalId = externalIdFromPayload(input.payload, input.correlation);
const anchor =
dedupeSegment(input.correlation?.sessionId) ??
dedupeSegment(result.session_id ?? result.sessionId) ??
dedupeSegment(request.request_id ?? request.requestId) ??
dedupeSegment(input.correlation?.requestId) ??
dedupeSegment(input.correlation?.taskId) ??
dedupeSegment(input.correlation?.curatorTaskId) ??
dedupeSegment(input.correlation?.sessionId) ??
dedupeSegment(input.correlation?.resumeId) ??
dedupeSegment(input.correlation?.externalId) ??
dedupeSegment(subject.externalId) ??
dedupeSegment(externalId) ??
dedupeSegment(result.task_id ?? result.taskId ?? result.session_id ?? result.sessionId ?? result.id);
dedupeSegment(result.task_id ?? result.taskId ?? result.id);
return `${input.source}:${input.type}:${input.userId}:${anchor ?? stableDedupeFingerprint(input)}`;
}
async function recordGatewayEvent(input: {
export type GatewayEventInput = {
userId: string;
source: string;
type: string;
@@ -246,15 +248,34 @@ async function recordGatewayEvent(input: {
mission?: Record<string, unknown>;
subject?: Record<string, unknown>;
dedupeKey?: string;
}) {
await db.insert(events).values({
userId: input.userId,
actorId: "service-gateway",
type: input.type,
payload: input.payload,
}).catch((err) => log.warn({ err, type: input.type }, "failed to record legacy service event"));
};
const event = await recordGrowEvent({
export type GatewayEventDependencies = {
recordLegacyEvent: (input: GatewayEventInput) => Promise<void>;
recordGrowEvent: typeof recordGrowEvent;
applyServiceSessionProjection: typeof applyServiceSessionProjection;
routeGrowEventToUserActor: typeof routeGrowEventToUserActor;
};
export async function recordGatewayEvent(
input: GatewayEventInput,
dependencies: GatewayEventDependencies = {
recordLegacyEvent: async (gatewayInput) => {
await db.insert(events).values({
userId: gatewayInput.userId,
actorId: "service-gateway",
type: gatewayInput.type,
payload: gatewayInput.payload,
}).catch((err) => log.warn({ err, type: gatewayInput.type }, "failed to record legacy service event"));
},
recordGrowEvent,
applyServiceSessionProjection,
routeGrowEventToUserActor,
},
) {
await dependencies.recordLegacyEvent(input);
const event = await dependencies.recordGrowEvent({
source: input.source,
type: input.type,
category: "service",
@@ -269,7 +290,8 @@ async function recordGatewayEvent(input: {
correlation: input.correlation,
payload: input.payload,
});
await routeGrowEventToUserActor(event);
await dependencies.applyServiceSessionProjection(event);
await dependencies.routeGrowEventToUserActor(event);
return event;
}
@@ -367,17 +389,48 @@ async function callMatchmakingA2a(body: Record<string, unknown>, userId: string)
action: action === "get_feed" ? "get_scout_feed" : action,
user_id: getString(body.user_id) ?? userId,
};
const res = await fetch(target, {
method: "POST",
headers: {
"content-type": "application/json",
...(config.a2aAllowedKey ? { authorization: `Bearer ${config.a2aAllowedKey}` } : {}),
},
body: JSON.stringify(payload),
});
const startedAt = Date.now();
let res: Response;
try {
res = await fetch(target, {
method: "POST",
headers: {
"content-type": "application/json",
...(config.a2aAllowedKey ? { authorization: `Bearer ${config.a2aAllowedKey}` } : {}),
},
body: JSON.stringify(payload),
});
} catch (err) {
log.warn({ err, service: "matchmaking", action, userId, durationMs: Date.now() - startedAt }, "matchmaking gateway request failed");
throw new HTTPException(502, { message: "matchmaking service is unavailable" });
}
const text = await res.text();
const result = text ? JSON.parse(text) as Record<string, unknown> : {};
if (!res.ok) throw new HTTPException(res.status as never, { message: text || "matchmaking request failed" });
let result: Record<string, unknown> = {};
if (text) {
try {
const parsed = JSON.parse(text);
result = isRecord(parsed) ? parsed : { value: parsed };
} catch {
result = { detail: text };
}
}
if (!res.ok) {
const upstreamAuthFailure = res.status === 401 || res.status === 403;
log.warn({
service: "matchmaking",
action,
userId,
upstreamStatus: res.status,
durationMs: Date.now() - startedAt,
}, "matchmaking gateway upstream rejected request");
// A downstream service credential failure is infrastructure, not the
// browser user's auth. Returning 502 prevents an unnecessary Clerk token
// refresh/retry that would simply repeat the same rejected request.
throw new HTTPException((upstreamAuthFailure ? 502 : res.status) as never, {
message: upstreamAuthFailure ? "matchmaking service authentication failed" : text || "matchmaking request failed",
});
}
log.info({ service: "matchmaking", action, userId, upstreamStatus: res.status, durationMs: Date.now() - startedAt }, "matchmaking gateway request completed");
return { action: String(payload.action || action), result };
}
@@ -646,9 +699,17 @@ async function buildPersonalizedConfigurePayload(req: Request, body: JsonObject,
};
}
async function buildPersonalizedRoleplayConfigurePayload(req: Request, body: JsonObject, userId: string): Promise<JsonObject> {
const { mission: _mission, ...rest } = body;
const userContext = await resolveGrowUserContext(req, userId).catch((err) => {
export async function buildPersonalizedRoleplayConfigurePayload(
req: Request,
body: JsonObject,
userId: string,
dependencies: {
resolveUserContext?: typeof resolveGrowUserContext;
loadQscore?: typeof getQscoreFromService;
} = {},
): Promise<JsonObject> {
const { mission, ...rest } = body;
const userContext = await (dependencies.resolveUserContext ?? resolveGrowUserContext)(req, userId).catch((err) => {
log.warn({ err, userId }, "failed to resolve Grow user context for roleplay configure");
return {} as Record<string, unknown>;
});
@@ -670,14 +731,20 @@ async function buildPersonalizedRoleplayConfigurePayload(req: Request, body: Jso
metadata.context_notes = getString(incomingMetadata.context_notes) ?? candidateProfile;
}
const orgId = String(rest.org_id ?? "growqr");
const suppliedQscore = isRecord(rest.qscore)
? rest.qscore
: isRecord(userContext.qscore)
? userContext.qscore
: undefined;
const qscore = suppliedQscore ?? await (dependencies.loadQscore ?? getQscoreFromService)(userId, orgId);
return {
...rest,
user_id: String(rest.user_id ?? userId),
org_id: String(rest.org_id ?? "growqr"),
metadata,
...((rest.qscore as JsonObject | undefined) ?? (isRecord(userContext.qscore) ? userContext.qscore : undefined)
? { qscore: (rest.qscore as JsonObject | undefined) ?? userContext.qscore }
: {}),
org_id: orgId,
...(qscore ? { qscore } : {}),
...(isRecord(mission) ? { mission } : {}),
user_context: userContext,
};
}
@@ -752,6 +819,62 @@ async function proxySocialRequest(req: Request, rest: string, userId: string) {
});
}
function artifactExtension(mimeType: string | undefined): string {
const normalized = mimeType?.split(";", 1)[0]?.trim().toLowerCase();
const extensions: Record<string, string> = {
"audio/mpeg": "mp3",
"audio/mp3": "mp3",
"audio/wav": "wav",
"audio/x-wav": "wav",
"video/webm": "webm",
"video/mp4": "mp4",
"application/json": "json",
};
return extensions[normalized ?? ""] ?? "bin";
}
async function streamServiceArtifact(input: { service: "interview" | "roleplay"; sessionId: string; artifactType: string; userId: string; request: Request }) {
const metadata = input.service === "interview"
? await interviewService.artifact(input.sessionId, input.artifactType, input.userId)
: await roleplayService.artifact(input.sessionId, input.artifactType, input.userId);
const url = isRecord(metadata) ? getString(metadata.url) : undefined;
if (!url) throw new HTTPException(502, { message: "artifact URL missing" });
const headers = new Headers({ accept: input.request.headers.get("accept") ?? "*/*" });
const range = input.request.headers.get("range");
if (range) headers.set("range", range);
const upstream = await fetch(url, { headers });
if (!upstream.ok && upstream.status !== 206) {
throw new HTTPException(upstream.status as never, { message: `artifact download failed: ${upstream.status}` });
}
const responseHeaders = new Headers();
for (const name of ["content-type", "content-length", "content-range", "accept-ranges", "etag", "last-modified"]) {
const value = upstream.headers.get(name);
if (value) responseHeaders.set(name, value);
}
const mimeType = responseHeaders.get("content-type") ?? (isRecord(metadata) ? getString(metadata.mime_type) : undefined);
if (mimeType && !responseHeaders.has("content-type")) responseHeaders.set("content-type", mimeType);
const filename = `${input.artifactType}.${artifactExtension(mimeType)}`;
responseHeaders.set("content-disposition", `attachment; filename="${filename}"`);
responseHeaders.set("cache-control", "private, max-age=300");
return new Response(upstream.body, { status: upstream.status, headers: responseHeaders });
}
async function proxyVideoUpload(input: { service: "interview" | "roleplay"; sessionId: string; userId: string; request: Request }) {
const payload = input.service === "interview"
? await interviewService.createVideoUploadUrl(input.sessionId, input.userId)
: await roleplayService.createVideoUploadUrl(input.sessionId, input.userId);
const url = isRecord(payload) ? getString(payload.url) : undefined;
if (!url) throw new HTTPException(502, { message: "video upload URL missing" });
const headers = new Headers();
const contentType = input.request.headers.get("content-type");
if (contentType) headers.set("content-type", contentType);
const contentLength = input.request.headers.get("content-length");
if (contentLength) headers.set("content-length", contentLength);
const upstream = await fetch(url, { method: "PUT", headers, body: input.request.body, duplex: "half" } as RequestInit & { duplex: "half" });
if (!upstream.ok) throw new HTTPException(upstream.status as never, { message: `video upload failed: ${upstream.status}` });
return new Response(null, { status: 204 });
}
export function serviceRoutes(options: { skipAuth?: boolean } = {}) {
const app = new Hono<AuthContext>();
if (!options.skipAuth) app.use("*", requireUser);
@@ -879,6 +1002,11 @@ export function serviceRoutes(options: { skipAuth?: boolean } = {}) {
linkedin_available: hasLinkedInContext(userContext),
});
});
app.get("/interview/sessions", async (c) => {
const requested = Number(c.req.query("limit") ?? 100);
const limit = Math.min(100, Math.max(1, Number.isFinite(requested) ? Math.trunc(requested) : 100));
return c.json(await interviewService.listSessions(c.get("userId"), limit).catch(serviceErrorResponse));
});
app.post("/interview/configure", async (c) => {
const userId = c.get("userId");
const body = await c.req.json<JsonObject>();
@@ -933,8 +1061,13 @@ export function serviceRoutes(options: { skipAuth?: boolean } = {}) {
}).catch((err) => log.warn({ err }, "failed to record interview review event"));
return c.json(result);
});
app.get("/interview/leaderboard", async (c) => c.json(await interviewService.leaderboard()));
app.get("/interview/leaderboard", async (c) => {
const requestedLimit = Number(c.req.query("limit"));
const limit = Number.isInteger(requestedLimit) ? Math.min(100, Math.max(1, requestedLimit)) : 10;
return c.json(await interviewService.leaderboard(c.req.query("org_id"), limit));
});
app.get("/interview/artifacts/:sessionId/:artifactType", async (c) => c.json(await interviewService.artifact(c.req.param("sessionId"), c.req.param("artifactType"), c.get("userId"))));
app.get("/interview/artifacts/:sessionId/:artifactType/download", async (c) => streamServiceArtifact({ service: "interview", sessionId: c.req.param("sessionId"), artifactType: c.req.param("artifactType"), userId: c.get("userId"), request: c.req.raw }));
app.post("/interview/sessions/:sessionId/ticket", async (c) => {
if (!config.sessionTicketSecret) throw new HTTPException(503, { message: "session ticket signing is not configured" });
const sessionId = c.req.param("sessionId");
@@ -944,6 +1077,7 @@ export function serviceRoutes(options: { skipAuth?: boolean } = {}) {
return c.json({ ticket: issued.ticket, expiresAt: issued.expiresAt });
});
app.post("/interview/sessions/:sessionId/video/upload-url", async (c) => c.json(await interviewService.createVideoUploadUrl(c.req.param("sessionId"), c.get("userId"))));
app.put("/interview/sessions/:sessionId/video/upload", async (c) => proxyVideoUpload({ service: "interview", sessionId: c.req.param("sessionId"), userId: c.get("userId"), request: c.req.raw }));
app.post("/interview/sessions/:sessionId/video/uploaded", async (c) => c.json(await interviewService.markVideoUploaded(c.req.param("sessionId"), c.get("userId"))));
app.get("/roleplay/page-state", async (c) => {
@@ -961,6 +1095,11 @@ export function serviceRoutes(options: { skipAuth?: boolean } = {}) {
linkedin_available: hasLinkedInContext(userContext),
});
});
app.get("/roleplay/sessions", async (c) => {
const requested = Number(c.req.query("limit") ?? 200);
const limit = Math.min(200, Math.max(1, Number.isFinite(requested) ? Math.trunc(requested) : 200));
return c.json(await roleplayService.listSessions(c.get("userId"), limit).catch(serviceErrorResponse));
});
app.post("/roleplay/configure", async (c) => {
const userId = c.get("userId");
const body = await c.req.json<JsonObject>();
@@ -1009,6 +1148,10 @@ export function serviceRoutes(options: { skipAuth?: boolean } = {}) {
const limit = Math.min(200, Math.max(1, Number(c.req.query("limit") ?? 50) || 50));
return c.json(await roleplayService.drafts(c.get("userId"), limit).catch(serviceErrorResponse));
});
app.get("/roleplay/video-usage", async (c) =>
c.json(await roleplayService.videoUsage(c.get("userId")).catch(serviceErrorResponse)));
app.get("/roleplay/vip-check", async (c) =>
c.json(await roleplayService.vipCheck(c.req.query("code") ?? "", c.get("userId")).catch(serviceErrorResponse)));
app.delete("/roleplay/drafts/:sessionId", async (c) =>
c.json(await roleplayService.deleteDraft(c.req.param("sessionId"), c.get("userId")).catch(serviceErrorResponse)));
app.post("/roleplay/assignments", async (c) => c.json(await roleplayService.createAssignments(await c.req.json())));
@@ -1033,8 +1176,13 @@ export function serviceRoutes(options: { skipAuth?: boolean } = {}) {
}).catch((err) => log.warn({ err }, "failed to record roleplay review event"));
return c.json(result);
});
app.get("/roleplay/leaderboard", async (c) => c.json(await roleplayService.leaderboard()));
app.get("/roleplay/leaderboard", async (c) => {
const requestedLimit = Number(c.req.query("limit"));
const limit = Number.isInteger(requestedLimit) ? Math.min(100, Math.max(1, requestedLimit)) : 10;
return c.json(await roleplayService.leaderboard(c.req.query("org_id"), limit));
});
app.get("/roleplay/artifacts/:sessionId/:artifactType", async (c) => c.json(await roleplayService.artifact(c.req.param("sessionId"), c.req.param("artifactType"), c.get("userId"))));
app.get("/roleplay/artifacts/:sessionId/:artifactType/download", async (c) => streamServiceArtifact({ service: "roleplay", sessionId: c.req.param("sessionId"), artifactType: c.req.param("artifactType"), userId: c.get("userId"), request: c.req.raw }));
app.post("/roleplay/sessions/:sessionId/ticket", async (c) => {
if (!config.sessionTicketSecret) throw new HTTPException(503, { message: "session ticket signing is not configured" });
const sessionId = c.req.param("sessionId");
@@ -1044,6 +1192,7 @@ export function serviceRoutes(options: { skipAuth?: boolean } = {}) {
return c.json({ ticket: issued.ticket, expiresAt: issued.expiresAt });
});
app.post("/roleplay/sessions/:sessionId/video/upload-url", async (c) => c.json(await roleplayService.createVideoUploadUrl(c.req.param("sessionId"), c.get("userId"))));
app.put("/roleplay/sessions/:sessionId/video/upload", async (c) => proxyVideoUpload({ service: "roleplay", sessionId: c.req.param("sessionId"), userId: c.get("userId"), request: c.req.raw }));
app.post("/roleplay/sessions/:sessionId/video/uploaded", async (c) => c.json(await roleplayService.markVideoUploaded(c.req.param("sessionId"), c.get("userId"))));
app.get("/resume/state/:clerkId", async (c) => c.json(await resumeService.state(c.req.param("clerkId"))));
@@ -1109,6 +1258,10 @@ export function serviceRoutes(options: { skipAuth?: boolean } = {}) {
const userId = c.get("userId");
const body = await c.req.json<JsonObject>().catch((): JsonObject => ({}));
const { result } = await callMatchmakingA2a({ ...body, user_id: userId }, userId);
if (resultHasAgentError(result)) {
log.warn({ service: "matchmaking", action: body.action, userId }, "matchmaking agent reported task failure");
throw new HTTPException(502, { message: "matchmaking service could not complete the search" });
}
// matchmaking-service emits canonical GrowEvents itself. The gateway is a
// transport proxy only, avoiding a second event for the same action.
return c.json(result);

View File

@@ -21,8 +21,8 @@ export class ProductServiceError extends Error {
}
}
const DEFAULT_SERVICE_TIMEOUT_MS = Number(process.env.PRODUCT_SERVICE_TIMEOUT_MS ?? 3500);
const INTERACTIVE_SERVICE_TIMEOUT_MS = Number(process.env.PRODUCT_INTERACTIVE_SERVICE_TIMEOUT_MS ?? 120000);
export const DEFAULT_SERVICE_TIMEOUT_MS = Number(process.env.PRODUCT_SERVICE_TIMEOUT_MS ?? 3500);
export const INTERACTIVE_SERVICE_TIMEOUT_MS = Number(process.env.PRODUCT_INTERACTIVE_SERVICE_TIMEOUT_MS ?? 240000);
function userHeader(userId?: string): Record<string, string> | undefined {
return userId ? { "x-growqr-user": userId } : undefined;
@@ -60,6 +60,10 @@ export const interviewService = {
serviceJson(config.interviewServiceUrl, `/api/v1/interviews/page-state?${new URLSearchParams({ user_id: userId })}`, {
headers: userHeader(userId),
}),
listSessions: (userId: string, limit = 100) =>
serviceJson(config.interviewServiceUrl, `/api/v1/sessions?${new URLSearchParams({ user_id: userId, limit: String(limit) })}`, {
headers: userHeader(userId),
}),
configure: (payload: JsonObject, userId?: string) =>
serviceJson(config.interviewServiceUrl, "/api/v1/configure", { body: payload, headers: userHeader(userId), timeoutMs: INTERACTIVE_SERVICE_TIMEOUT_MS }),
preview: (payload: JsonObject, userId?: string) =>
@@ -80,7 +84,11 @@ export const interviewService = {
serviceJson(config.interviewServiceUrl, `/api/v1/review/${encodeURIComponent(sessionId)}`, {
headers: userHeader(userId),
}),
leaderboard: () => serviceJson(config.interviewServiceUrl, "/api/v1/leaderboard"),
leaderboard: (orgId?: string, limit = 10) =>
serviceJson(config.interviewServiceUrl, `/api/v1/leaderboard?${new URLSearchParams({
...(orgId ? { org_id: orgId } : {}),
limit: String(limit),
})}`),
artifact: (sessionId: string, artifactType: string, userId?: string) =>
serviceJson(config.interviewServiceUrl, `/api/v1/artifacts/${encodeURIComponent(sessionId)}/${encodeURIComponent(artifactType)}`, {
headers: userHeader(userId),
@@ -109,6 +117,10 @@ export const roleplayService = {
serviceJson(config.roleplayServiceUrl, `/api/v1/roleplays/page-state?${new URLSearchParams({ user_id: userId })}`, {
headers: userHeader(userId),
}),
listSessions: (userId: string, limit = 200) =>
serviceJson(config.roleplayServiceUrl, `/api/v1/roleplays/sessions?${new URLSearchParams({ user_id: userId, limit: String(limit) })}`, {
headers: userHeader(userId),
}),
configure: (payload: JsonObject, userId?: string) => serviceJson(config.roleplayServiceUrl, "/api/v1/roleplays/configure", { body: payload, headers: userHeader(userId), timeoutMs: INTERACTIVE_SERVICE_TIMEOUT_MS }),
preview: (payload: JsonObject, userId?: string) => serviceJson(config.roleplayServiceUrl, "/api/v1/roleplays/configure/preview", { body: payload, headers: userHeader(userId), timeoutMs: INTERACTIVE_SERVICE_TIMEOUT_MS }),
editQuestions: (payload: { session_id: string; questions: Array<JsonObject | string> }, userId?: string) =>
@@ -118,6 +130,7 @@ export const roleplayService = {
plan: (sessionId: string, userId?: string) =>
serviceJson(config.roleplayServiceUrl, `/api/v1/roleplays/sessions/${encodeURIComponent(sessionId)}/plan`, {
headers: userHeader(userId),
timeoutMs: INTERACTIVE_SERVICE_TIMEOUT_MS,
}),
drafts: (userId: string, limit = 50) =>
serviceJson(config.roleplayServiceUrl, `/api/v1/roleplays/drafts?${new URLSearchParams({ user_id: userId, limit: String(limit) })}`, {
@@ -140,7 +153,19 @@ export const roleplayService = {
serviceJson(config.roleplayServiceUrl, `/api/v1/roleplays/review/${encodeURIComponent(sessionId)}`, {
headers: userHeader(userId),
}),
leaderboard: () => serviceJson(config.roleplayServiceUrl, "/api/v1/roleplays/leaderboard"),
leaderboard: (orgId?: string, limit = 10) =>
serviceJson(config.roleplayServiceUrl, `/api/v1/roleplays/leaderboard?${new URLSearchParams({
...(orgId ? { org_id: orgId } : {}),
limit: String(limit),
})}`),
videoUsage: (userId: string) =>
serviceJson(config.roleplayServiceUrl, `/api/v1/roleplays/video-usage?${new URLSearchParams({ user_id: userId })}`, {
headers: userHeader(userId),
}),
vipCheck: (code: string, userId: string) =>
serviceJson(config.roleplayServiceUrl, `/api/v1/roleplays/vip-check?${new URLSearchParams({ code, user_id: userId })}`, {
headers: userHeader(userId),
}),
artifact: (sessionId: string, artifactType: string, userId?: string) =>
serviceJson(config.roleplayServiceUrl, `/api/v1/artifacts/${encodeURIComponent(sessionId)}/${encodeURIComponent(artifactType)}`, {
headers: userHeader(userId),
@@ -167,6 +192,44 @@ export const courseService = {
health: () => serviceJson(config.coursesServiceUrl, "/health"),
task: (payload: { action?: string; params?: JsonObject; user_id: string; user_context?: JsonObject; session_start?: boolean; task_id?: string }) =>
serviceJson(config.coursesServiceUrl, "/a2a/tasks", { body: payload, timeoutMs: INTERACTIVE_SERVICE_TIMEOUT_MS }),
summaryState: (userId: string) =>
serviceJson(config.coursesServiceUrl, "/a2a/tasks", {
body: { user_id: userId, session_start: true, params: { page: "summary" } },
timeoutMs: DEFAULT_SERVICE_TIMEOUT_MS,
}),
};
export const assessmentService = {
health: () => serviceJson(config.assessmentServiceUrl, "/api/v1/health"),
listForUser: (userId: string, limit = 50) =>
serviceJson(config.assessmentServiceUrl, `/api/v1/assessments?${new URLSearchParams({
user_id: userId,
limit: String(limit),
})}`, {
headers: userHeader(userId),
}),
};
export const matchmakingService = {
health: () => serviceJson(config.matchmakingServiceUrl, "/api/v1/health"),
task: (payload: { action: string; params?: JsonObject; user_id: string; user_context?: JsonObject }) =>
serviceJson(config.matchmakingServiceUrl, "/a2a/tasks", {
body: payload,
timeoutMs: INTERACTIVE_SERVICE_TIMEOUT_MS,
}),
scoutStats: (userId: string, qscoreUserId: string) =>
serviceJson(config.matchmakingServiceUrl, "/a2a/tasks", {
body: { action: "get_scout_stats", user_id: userId, params: { user_uuid: qscoreUserId } },
timeoutMs: DEFAULT_SERVICE_TIMEOUT_MS,
}),
};
export const socialBrandingService = {
health: () => serviceJson(config.socialBrandingServiceUrl, "/healthz"),
state: (clerkId: string) =>
serviceJson(config.socialBrandingServiceUrl, `/api/state/${encodeURIComponent(clerkId)}`, {
headers: userHeader(clerkId),
}),
};
export const resumeService = {

View File

@@ -54,6 +54,16 @@ export type QscoreServiceResult = {
*/
export type QscoreProxyResult = QscoreServiceResult;
export type QscoreReadResult<T> =
| { status: "ready"; data: T }
| { status: "empty"; reason: "not_found" }
| { status: "unavailable"; reason: "request_failed" | "invalid_response" | `http_${number}` };
export type QscoreHistoryPoint = {
rq_score: number;
date: string;
};
export type QscoreLatestSignal = {
signal_id: string;
source: string | null;
@@ -117,6 +127,16 @@ export async function getQscoreFromService(
orgId: string = DEFAULT_QSCORE_ORG_ID,
timeoutMs: number = QSCORE_PROXY_TIMEOUT_MS,
): Promise<QscoreProxyResult | null> {
const result = await readQscoreFromService(userId, orgId, timeoutMs);
return result.status === "ready" ? result.data : null;
}
/** Status-bearing score read for callers that must not collapse absence and outage. */
export async function readQscoreFromService(
userId: string,
orgId: string = DEFAULT_QSCORE_ORG_ID,
timeoutMs: number = QSCORE_PROXY_TIMEOUT_MS,
): Promise<QscoreReadResult<QscoreProxyResult>> {
const qscoreUserId = toQscoreUserId(userId);
const path = `/v1/qscore/${encodeURIComponent(qscoreUserId)}?org_id=${encodeURIComponent(orgId)}`;
const url = `${config.qscoreServiceUrl.replace(/\/$/, "")}${path}`;
@@ -130,21 +150,64 @@ export async function getQscoreFromService(
},
signal: AbortSignal.timeout(timeoutMs),
});
// 404 = no run computed yet for this user → treat as null (graceful).
if (res.status === 404) return null;
// A missing run is distinct from an unavailable service for status-aware callers.
if (res.status === 404) return { status: "empty", reason: "not_found" };
if (!res.ok) {
const text = await res.text().catch(() => "");
log.warn(
{ status: res.status, body: text, userId, orgId },
{ status: res.status, userId, orgId },
"qscore_service returned non-success status",
);
return null;
return { status: "unavailable", reason: `http_${res.status}` };
}
const body = await res.json();
return parseResult(body);
try {
return { status: "ready", data: parseResult(body) };
} catch (err) {
log.warn({ err, userId, orgId }, "qscore_service returned invalid response");
return { status: "unavailable", reason: "invalid_response" };
}
} catch (err) {
log.warn({ err, userId, orgId }, "qscore_service proxy call failed");
return null;
return { status: "unavailable", reason: "request_failed" };
}
}
/** Fetch daily RQ Score history while preserving empty vs unavailable. */
export async function readQscoreHistoryFromService(
userId: string,
orgId: string = DEFAULT_QSCORE_ORG_ID,
points = 10,
timeoutMs: number = QSCORE_PROXY_TIMEOUT_MS,
): Promise<QscoreReadResult<QscoreHistoryPoint[]>> {
const qscoreUserId = toQscoreUserId(userId);
const query = new URLSearchParams({ org_id: orgId, points: String(points) });
const url = `${config.qscoreServiceUrl.replace(/\/$/, "")}/v1/qscore/${encodeURIComponent(qscoreUserId)}/history?${query}`;
try {
const res = await fetch(url, {
headers: {
"content-type": "application/json",
...(config.a2aAllowedKey ? { authorization: `Bearer ${config.a2aAllowedKey}` } : {}),
},
signal: AbortSignal.timeout(timeoutMs),
});
if (res.status === 404) return { status: "empty", reason: "not_found" };
if (!res.ok) {
log.warn({ status: res.status, userId, orgId }, "qscore_service history returned non-success status");
return { status: "unavailable", reason: `http_${res.status}` };
}
const body: unknown = await res.json();
if (!isRecord(body) || !Array.isArray(body.points)) {
return { status: "unavailable", reason: "invalid_response" };
}
const history = body.points.flatMap((value): QscoreHistoryPoint[] => {
if (!isRecord(value) || typeof value.rq_score !== "number" || !Number.isFinite(value.rq_score) || typeof value.date !== "string") return [];
return [{ rq_score: value.rq_score, date: value.date }];
});
if (history.length !== body.points.length) return { status: "unavailable", reason: "invalid_response" };
return { status: "ready", data: history };
} catch (err) {
log.warn({ err, userId, orgId }, "qscore_service history proxy call failed");
return { status: "unavailable", reason: "request_failed" };
}
}

View File

@@ -8,3 +8,111 @@ The added responsibility here is the nightly improvement loop:
2. Generate validated improvement signal objects with the Vercel AI SDK.
3. Apply those signals to the V1 Curator as events.
4. The Curator uses them on the next day when shaping tasks and nudges.
## Home insight snapshot contract
`GET /v1/analytics/insight-snapshot` keeps its legacy `roleFit`,
`readinessTrend`, `activity`, and `opportunities` fields and adds the
`home-insight-snapshot-v1` contract:
```json
{
"contractVersion": "home-insight-snapshot-v1",
"generatedAt": "ISO-8601 timestamp",
"snapshots": {
"rqScore": {},
"interview": {},
"resume": {},
"opportunity": {},
"pathway": {},
"socialBrand": {},
"roleplay": {},
"learning": {},
"skillGap": {}
}
}
```
Every snapshot has the same fields:
- `status`: `ready`, `empty`, `unavailable`, or `unsupported`.
- `source`: the backend-owned producer identifier.
- `value`, `unit`, and `label`: display-ready values from real producer data.
- `updatedAt`: producer timestamp when one exists.
- `reason`: a stable machine-readable reason for non-ready states.
- `detail`: real supporting evidence; consumers must not recalculate scores from it.
The status contract is strict. `ready` requires a real numeric value. `empty`
requires a successful producer read that confirms absence or a genuine zero
count. `unavailable` means the producer failed, timed out, or returned an
invalid response. `unsupported` means no viable producer contract exists.
Failures and nulls are never converted to zero.
The authenticated Clerk user id is passed unchanged to product services.
Qscore is the only exception: `toQscoreUserId` applies the shared deterministic
UUID mapping before current/history reads or matchmaking's optional qscore
cross-read.
### Producer ownership
- RQ Score: qscore current plus daily history.
- Interview and Roleplay: their page-state APIs, with canonical qscore display
categories as a read-only fallback.
- Resume: resume state, with canonical qscore display fallback.
- Opportunity: matchmaking `get_scout_stats` A2A data, with qscore matching
activity fallback.
- Pathway: canonical qscore display only. Formula versions with zero registered
pathway rules return `unsupported`, not zero progress.
- Social Brand: social state/profile-analysis score. Audience Reach is the
separate producer-owned `detail.audienceReach` display object
(`value`, `unit: "followers"`, `label`, and `updatedAt`) only when Social
confirms a connected profile and numeric follower count. It is never blended
into the brand score or inferred by the consumer.
- Learning: course A2A summary state and persisted substantial-watch count.
- Skill Gap: explicit producer-owned weakness/gap tags from GrowEvents. An
assessment result without an explicit gap metric is evidence detail, not a
fabricated gap count.
All product-service calls are concurrent, bounded, and failure-isolated. A
single unavailable producer does not fail the full snapshot response. A direct
producer failure may fall back only to an active, `ready` canonical qscore
category. An inactive category with `no_category_evidence` never masks an
outage as an empty numeric zero.
## Career report data contract
`GET /v1/analytics/career-report` is an authenticated, read-only aggregation
for the dashboard-owned fixed PDF template. It returns
`contractVersion: "career-report-v1"` with these additive top-level fields:
- `subject`: canonical user-service profile and onboarding values.
- `rqScore`: the current score, producer history, and a strictly validated
`rq-score-display-v1` projection.
- `snapshots`: the existing nine Home Insight producer projections.
- `activity`: real GrowEvents counts for the previous 14 days.
- `sources`: status and timestamp metadata for every queried producer.
Every field is status-bearing. `ready` carries a real producer value; `empty`
means a successful read confirmed no value or an empty array/count;
`unavailable` means the producer could not be read; and `unsupported` means the
expected producer contract is absent. Errors and missing integrations never
become numeric zeroes.
Wrongly typed onboarding values and malformed canonical RQ display payloads
are `unavailable`, never `empty` or `ready`. RQ display validation enforces the
exact nine categories in producer order, all 67 uniquely assigned signals,
and bounded scores, weights, and counts. Activity totals and service buckets
are aggregated across the complete 14-day database window; the bounded recent
event sample is used only as supporting snapshot evidence.
The endpoint deliberately does not calculate labels, rankings, strongest or
weakest categories, recommendations, timelines, peer/market benchmarks,
profile scores, percentages, or narrative prose. It creates no database rows,
jobs, or stored reports. PDF layout and fixed explanatory labels remain owned
by the dashboard.
Profile identity is read strictly from user-service `/api/v1/users/me` using
the authenticated request. The backend-mirror profile fallback is not used,
so internal user ids or synthetic service-local email addresses cannot leak
into the report. No email, token, link, secret, or internal credential is
included in the response.

View File

@@ -7,7 +7,22 @@ import { growEvents } from "../../db/schema.js";
import { recordGrowEvent } from "../../events/record-grow-event.js";
import { routeGrowEventToUserActor } from "../../events/route-to-user-actor.js";
import { v1AnalyticsActor } from "./analytics-actor.js";
import { DEFAULT_QSCORE_ORG_ID, getQscoreFromService, getQscoreLatestSignalsFromService } from "../../services/qscore-proxy.js";
import {
DEFAULT_QSCORE_ORG_ID,
getQscoreLatestSignalsFromService,
readQscoreFromService,
readQscoreHistoryFromService,
} from "../../services/qscore-proxy.js";
import { fetchUserProfile } from "../../services/user-profile.js";
import {
buildHomeInsightSnapshots,
type HomeInsightProducerStatuses,
} from "./home-insight-snapshot.js";
import {
buildCareerReportProjection,
type CareerReportActivity,
type CareerReportProfileRead,
} from "./career-report.js";
function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === "object" && !Array.isArray(value);
@@ -45,10 +60,12 @@ export function v1AnalyticsRoutes() {
app.get("/insight-snapshot", async (c) => {
const userId = c.get("userId");
const [qscoreResult, qscoreEvidence] = await Promise.all([
getQscoreFromService(userId, DEFAULT_QSCORE_ORG_ID),
const [qscoreRead, qscoreHistory, qscoreEvidence] = await Promise.all([
readQscoreFromService(userId, DEFAULT_QSCORE_ORG_ID),
readQscoreHistoryFromService(userId, DEFAULT_QSCORE_ORG_ID),
getQscoreLatestSignalsFromService(userId, DEFAULT_QSCORE_ORG_ID),
]);
const qscoreResult = qscoreRead.status === "ready" ? qscoreRead.data : null;
const breakdown = qscoreResult?.breakdown ?? {};
const latestSignals = (qscoreEvidence ?? []).map((signal) => ({
signalId: signal.signal_id,
@@ -81,8 +98,18 @@ export function v1AnalyticsRoutes() {
const readinessSummary = null;
const lastUpdatedAt = qscoreResult?.calculated_at ?? null;
const signalCount = latestSignals.length;
const snapshots = await buildHomeInsightSnapshots({
userId,
qscore: qscoreRead,
qscoreHistory,
recentEvents,
});
const generatedAt = new Date().toISOString();
const response = {
contractVersion: "home-insight-snapshot-v1",
generatedAt,
snapshots,
roleFit: {
score,
label: score === null ? "baseline_needed" : score >= 75 ? "strong" : score >= 55 ? "building" : "needs_focus",
@@ -130,6 +157,74 @@ export function v1AnalyticsRoutes() {
return c.json(response);
});
app.get("/career-report", async (c) => {
const userId = c.get("userId");
const profilePromise: Promise<CareerReportProfileRead> = fetchUserProfile(c.req.raw)
.then((data) => isRecord(data)
? { status: "ready" as const, data }
: { status: "unavailable" as const, reason: "profile_response_invalid" as const })
.catch(() => ({ status: "unavailable" as const, reason: "profile_unavailable" as const }));
const [qscoreRead, qscoreHistory, profile, recentEvents, counts, sourceCounts] = await Promise.all([
readQscoreFromService(userId, DEFAULT_QSCORE_ORG_ID),
readQscoreHistoryFromService(userId, DEFAULT_QSCORE_ORG_ID),
profilePromise,
db
.select()
.from(growEvents)
.where(and(eq(growEvents.userId, userId), gte(growEvents.occurredAt, daysAgo(14))))
.orderBy(desc(growEvents.occurredAt))
.limit(100),
db
.select({
total: sql<number>`count(*)::int`,
completed: sql<number>`count(*) filter (where ${growEvents.type} ilike '%completed%' or ${growEvents.type} ilike '%review_completed%')::int`,
opened: sql<number>`count(*) filter (where ${growEvents.type} = 'task.opened' or ${growEvents.type} ilike '%started%')::int`,
})
.from(growEvents)
.where(and(eq(growEvents.userId, userId), gte(growEvents.occurredAt, daysAgo(14)))),
db
.select({
source: growEvents.source,
count: sql<number>`count(*)::int`,
})
.from(growEvents)
.where(and(eq(growEvents.userId, userId), gte(growEvents.occurredAt, daysAgo(14))))
.groupBy(growEvents.source),
]);
let producerStatuses: HomeInsightProducerStatuses | null = null;
const snapshots = await buildHomeInsightSnapshots({
userId,
qscore: qscoreRead,
qscoreHistory,
recentEvents,
onProducerStatuses: (statuses) => { producerStatuses = statuses; },
});
if (!producerStatuses) throw new Error("career report producer status collection failed");
const serviceCounts = new Map<string, number>();
for (const row of sourceCounts) {
const bucket = sourceBucket(row.source);
serviceCounts.set(bucket, (serviceCounts.get(bucket) ?? 0) + row.count);
}
const aggregate = counts[0];
const activity: CareerReportActivity = {
windowDays: 14,
totalEvents: aggregate?.total ?? 0,
completedEvents: aggregate?.completed ?? 0,
openedEvents: aggregate?.opened ?? 0,
services: Array.from(serviceCounts.entries()).map(([service, count]) => ({ service, count })),
opportunityEvents: serviceCounts.get("opportunities") ?? 0,
};
return c.json(buildCareerReportProjection({
profile,
qscore: qscoreRead,
qscoreHistory,
snapshots,
producerStatuses,
activity,
}));
});
app.get("/activity-history", async (c) => {
const userId = c.get("userId");
const limit = Math.min(200, Math.max(1, Number(c.req.query("limit") ?? 80)));

View File

@@ -0,0 +1,397 @@
import type {
HomeInsightProducerStatuses,
HomeInsightSnapshots,
InsightSnapshotStatus,
} from "./home-insight-snapshot.js";
import type {
QscoreHistoryPoint,
QscoreProxyResult,
QscoreReadResult,
} from "../../services/qscore-proxy.js";
export type CareerReportStatus = InsightSnapshotStatus;
export type CareerReportField<T> = {
status: CareerReportStatus;
source: string;
value: T | null;
updatedAt: string | null;
reason: string | null;
};
export type CareerReportSignal = {
id: string;
label: string;
present: boolean;
score: number;
};
export type CareerReportCategory = {
id: string;
label: string;
inputIds: string[];
weightPercent: number;
score: number;
active: boolean;
presentRules: number;
totalRules: number;
signals: CareerReportSignal[];
};
export type CareerReportDisplay = {
version: "rq-score-display-v1";
registeredSignalCount: number;
activeSignalCount: number;
categories: CareerReportCategory[];
};
export type CareerReportActivity = {
windowDays: 14;
totalEvents: number;
completedEvents: number;
openedEvents: number;
services: Array<{ service: string; count: number }>;
opportunityEvents: number;
};
export type CareerReportSourceId =
| "profile"
| "onboarding"
| "qscore"
| "resume"
| "linkedin"
| "interview"
| "roleplay"
| "matchmaking"
| "courses"
| "assessment"
| "grow_events";
export type CareerReportSource = {
id: CareerReportSourceId;
label: string;
status: CareerReportStatus;
updatedAt: string | null;
reason: string | null;
};
export type CareerReportProfileRead =
| { status: "ready"; data: Record<string, unknown> }
| { status: "unavailable"; reason: "profile_unavailable" | "profile_response_invalid" };
export type CareerReportResponse = {
contractVersion: "career-report-v1";
generatedAt: string;
subject: {
displayName: CareerReportField<string>;
avatarUrl: CareerReportField<string>;
targetRole: CareerReportField<string>;
targetField: CareerReportField<string>;
experienceLevel: CareerReportField<string>;
currentSituation: CareerReportField<string>;
goals: CareerReportField<string[]>;
barriers: CareerReportField<string[]>;
};
rqScore: {
score: CareerReportField<number>;
history: CareerReportField<Array<{ date: string; score: number }>>;
display: CareerReportField<CareerReportDisplay>;
};
snapshots: HomeInsightSnapshots;
activity: CareerReportField<CareerReportActivity>;
sources: CareerReportSource[];
};
const CANONICAL_CATEGORY_SPECS = [
["learning_credentials", "Learning & Credentials"],
["resume_presence", "Resume Presence"],
["linkedin_presence", "LinkedIn Presence"],
["matching_activity", "Matching Activity"],
["interview_performance", "Interview Performance"],
["pathway_progress", "Pathway Progress"],
["roleplay_performance", "Roleplay Performance"],
["engagement_consistency", "Engagement & Consistency"],
["goals_readiness", "Goals & Readiness"],
] as const;
function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === "object" && !Array.isArray(value);
}
function finiteNumber(value: unknown): number | null {
return typeof value === "number" && Number.isFinite(value) ? value : null;
}
function nonNegativeInteger(value: unknown): number | null {
const number = finiteNumber(value);
return number !== null && Number.isInteger(number) && number >= 0 ? number : null;
}
function boundedNumber(value: unknown, minimum: number, maximum: number): number | null {
const number = finiteNumber(value);
return number !== null && number >= minimum && number <= maximum ? number : null;
}
function stringValue(value: unknown): string | null {
return typeof value === "string" && value.trim() ? value.trim() : null;
}
function stringArray(value: unknown): string[] | null {
if (!Array.isArray(value)) return null;
const items = value.flatMap((item) => {
const parsed = stringValue(item);
return parsed ? [parsed] : [];
});
return items.length === value.length ? items : null;
}
function readyField<T>(source: string, value: T, updatedAt: string | null = null): CareerReportField<T> {
return { status: "ready", source, value, updatedAt, reason: null };
}
function emptyField<T>(source: string, value: T | null, reason: string): CareerReportField<T> {
return { status: "empty", source, value, updatedAt: null, reason };
}
function unavailableField<T>(source: string, reason: string): CareerReportField<T> {
return { status: "unavailable", source, value: null, updatedAt: null, reason };
}
function unsupportedField<T>(source: string, reason: string, updatedAt: string | null = null): CareerReportField<T> {
return { status: "unsupported", source, value: null, updatedAt, reason };
}
function scalarField(source: string, value: unknown, updatedAt: string | null = null): CareerReportField<string> {
if (value === undefined || value === null || (typeof value === "string" && !value.trim())) {
return { ...emptyField(source, null, "field_not_present"), updatedAt };
}
const parsed = stringValue(value);
return parsed
? readyField(source, parsed, updatedAt)
: { ...unavailableField<string>(source, "producer_response_invalid"), updatedAt };
}
function arrayField(source: string, value: unknown, updatedAt: string | null = null): CareerReportField<string[]> {
if (value === undefined || value === null) {
return { ...emptyField(source, [], "field_not_present"), updatedAt };
}
const parsed = stringArray(value);
if (parsed === null) return { ...unavailableField<string[]>(source, "producer_response_invalid"), updatedAt };
return parsed.length ? readyField(source, parsed, updatedAt) : { ...emptyField(source, [], "no_values"), updatedAt };
}
function displayName(profile: Record<string, unknown>): string | null {
const parts = [stringValue(profile.first_name ?? profile.firstName), stringValue(profile.last_name ?? profile.lastName)].filter(
(value): value is string => !!value,
);
return parts.length ? parts.join(" ") : null;
}
function avatarUrl(profile: Record<string, unknown>): string | null {
return stringValue(
profile.avatar_url ??
profile.avatarUrl ??
profile.image_url ??
profile.imageUrl ??
profile.profile_image_url ??
profile.profileImageUrl,
);
}
function parseSignal(value: unknown): CareerReportSignal | null {
if (!isRecord(value)) return null;
const id = stringValue(value.id);
const label = stringValue(value.label);
const score = boundedNumber(value.score, 0, 100);
if (!id || !label || score === null || typeof value.present !== "boolean") return null;
return { id, label, present: value.present, score };
}
function parseCategory(value: unknown): CareerReportCategory | null {
if (!isRecord(value)) return null;
const id = stringValue(value.id);
const label = stringValue(value.label);
const inputIds = stringArray(value.input_ids);
const weightPercent = boundedNumber(value.weight_percent, 0, 100);
const score = boundedNumber(value.score, 0, 100);
const presentRules = nonNegativeInteger(value.present_rules);
const totalRules = nonNegativeInteger(value.total_rules);
if (
!id ||
!label ||
inputIds === null ||
weightPercent === null ||
score === null ||
presentRules === null ||
totalRules === null ||
typeof value.active !== "boolean" ||
!Array.isArray(value.signals)
) return null;
const signals = value.signals.map(parseSignal);
if (signals.some((signal) => signal === null)) return null;
const parsedSignals = signals as CareerReportSignal[];
if (
totalRules > 67 ||
presentRules > totalRules ||
parsedSignals.length !== totalRules ||
parsedSignals.filter((signal) => signal.present).length !== presentRules ||
value.active !== (presentRules > 0) ||
new Set(parsedSignals.map((signal) => signal.id)).size !== parsedSignals.length
) return null;
return { id, label, inputIds, weightPercent, score, active: value.active, presentRules, totalRules, signals: parsedSignals };
}
/** Strictly validates the producer-owned canonical display contract without recalculating it. */
export function parseCareerReportDisplay(value: unknown): CareerReportDisplay | null {
if (!isRecord(value) || value.version !== "rq-score-display-v1" || !Array.isArray(value.categories)) return null;
const registeredSignalCount = nonNegativeInteger(value.registered_signal_count);
const activeSignalCount = nonNegativeInteger(value.active_signal_count);
const categories = value.categories.map(parseCategory);
if (registeredSignalCount === null || activeSignalCount === null || categories.some((category) => category === null)) return null;
const parsedCategories = categories as CareerReportCategory[];
const signals = parsedCategories.flatMap((category) => category.signals);
if (
parsedCategories.length !== CANONICAL_CATEGORY_SPECS.length ||
registeredSignalCount !== 67 ||
activeSignalCount > registeredSignalCount ||
signals.length !== registeredSignalCount ||
signals.filter((signal) => signal.present).length !== activeSignalCount ||
new Set(parsedCategories.map((category) => category.id)).size !== parsedCategories.length ||
new Set(signals.map((signal) => signal.id)).size !== signals.length
) return null;
for (const [index, [expectedId, expectedLabel]] of CANONICAL_CATEGORY_SPECS.entries()) {
const category = parsedCategories[index];
if (!category || category.id !== expectedId || category.label !== expectedLabel) return null;
}
const inputIds = parsedCategories.flatMap((category) => category.inputIds);
if (new Set(inputIds).size !== inputIds.length) return null;
return { version: "rq-score-display-v1", registeredSignalCount, activeSignalCount, categories: parsedCategories };
}
function sourceFromProducer(
id: CareerReportSourceId,
label: string,
producer: HomeInsightProducerStatuses[keyof HomeInsightProducerStatuses],
updatedAt: string | null,
): CareerReportSource {
return { id, label, status: producer.status, updatedAt, reason: producer.reason };
}
function sourceStatus(fields: Array<CareerReportField<unknown>>): CareerReportStatus {
if (fields.some((field) => field.status === "ready")) return "ready";
if (fields.some((field) => field.status === "unavailable")) return "unavailable";
if (fields.some((field) => field.status === "unsupported")) return "unsupported";
return "empty";
}
function sourceReason(fields: Array<CareerReportField<unknown>>): string | null {
const status = sourceStatus(fields);
return status === "ready" ? null : fields.find((field) => field.status === status)?.reason ?? null;
}
export function buildCareerReportProjection(input: {
generatedAt?: string;
profile: CareerReportProfileRead;
qscore: QscoreReadResult<QscoreProxyResult>;
qscoreHistory: QscoreReadResult<QscoreHistoryPoint[]>;
snapshots: HomeInsightSnapshots;
producerStatuses: HomeInsightProducerStatuses;
activity: CareerReportActivity;
}): CareerReportResponse {
const generatedAt = input.generatedAt ?? new Date().toISOString();
const profileSource = "user-service.profile";
const onboardingSource = "user-service.preferences.onboarding";
let displayNameField: CareerReportField<string>;
let avatarUrlField: CareerReportField<string>;
let targetRole: CareerReportField<string>;
let targetField: CareerReportField<string>;
let experienceLevel: CareerReportField<string>;
let currentSituation: CareerReportField<string>;
let goals: CareerReportField<string[]>;
let barriers: CareerReportField<string[]>;
if (input.profile.status === "unavailable") {
displayNameField = unavailableField(profileSource, input.profile.reason);
avatarUrlField = unavailableField(profileSource, input.profile.reason);
targetRole = unavailableField(onboardingSource, input.profile.reason);
targetField = unavailableField(onboardingSource, input.profile.reason);
experienceLevel = unavailableField(onboardingSource, input.profile.reason);
currentSituation = unavailableField(onboardingSource, input.profile.reason);
goals = unavailableField(onboardingSource, input.profile.reason);
barriers = unavailableField(onboardingSource, input.profile.reason);
} else {
const profileUpdatedAt = stringValue(input.profile.data.updated_at ?? input.profile.data.updatedAt);
displayNameField = scalarField(profileSource, displayName(input.profile.data), profileUpdatedAt);
avatarUrlField = scalarField(profileSource, avatarUrl(input.profile.data), profileUpdatedAt);
const preferences = isRecord(input.profile.data.preferences) ? input.profile.data.preferences : {};
const onboarding = isRecord(preferences.onboarding) ? preferences.onboarding : {};
const responses = isRecord(onboarding.responses) ? onboarding.responses : {};
const progress = isRecord(onboarding.progress) ? onboarding.progress : {};
const onboardingUpdatedAt = stringValue(progress.updated_at ?? onboarding.completed_at);
targetRole = scalarField(onboardingSource, responses.target_role, onboardingUpdatedAt);
targetField = scalarField(onboardingSource, responses.target_field, onboardingUpdatedAt);
experienceLevel = scalarField(onboardingSource, responses.experience_level, onboardingUpdatedAt);
currentSituation = scalarField(onboardingSource, responses.current_situation, onboardingUpdatedAt);
goals = arrayField(onboardingSource, responses.desired_outcomes, onboardingUpdatedAt);
barriers = arrayField(onboardingSource, responses.career_barriers, onboardingUpdatedAt);
}
let qscoreScore: CareerReportField<number>;
let qscoreDisplay: CareerReportField<CareerReportDisplay>;
if (input.qscore.status === "ready") {
qscoreScore = readyField("qscore.current", input.qscore.data.rq_score, input.qscore.data.calculated_at || null);
const rawDisplay = input.qscore.data.breakdown.display;
if (rawDisplay === undefined || rawDisplay === null) {
qscoreDisplay = unsupportedField("qscore.breakdown.display", "display_contract_missing", input.qscore.data.calculated_at || null);
} else {
const parsedDisplay = parseCareerReportDisplay(rawDisplay);
qscoreDisplay = parsedDisplay
? readyField("qscore.breakdown.display", parsedDisplay, input.qscore.data.calculated_at || null)
: { ...unavailableField<CareerReportDisplay>("qscore.breakdown.display", "display_contract_invalid"), updatedAt: input.qscore.data.calculated_at || null };
}
} else if (input.qscore.status === "empty") {
qscoreScore = emptyField<number>("qscore.current", null, "no_qscore_run");
qscoreDisplay = emptyField<CareerReportDisplay>("qscore.breakdown.display", null, "no_qscore_run");
} else {
qscoreScore = unavailableField("qscore.current", input.qscore.reason);
qscoreDisplay = unavailableField("qscore.breakdown.display", input.qscore.reason);
}
let history: CareerReportField<Array<{ date: string; score: number }>>;
if (input.qscoreHistory.status === "ready") {
const points = input.qscoreHistory.data.map((point) => ({ date: point.date, score: point.rq_score }));
history = points.length ? readyField("qscore.history", points) : emptyField("qscore.history", [], "no_history_points");
} else if (input.qscoreHistory.status === "empty") {
history = emptyField("qscore.history", [], "no_qscore_history");
} else {
history = unavailableField("qscore.history", input.qscoreHistory.reason);
}
const profileFields: Array<CareerReportField<unknown>> = [displayNameField, avatarUrlField];
const onboardingFields: Array<CareerReportField<unknown>> = [targetRole, targetField, experienceLevel, currentSituation, goals, barriers];
const activity = readyField("grow_events", input.activity, generatedAt);
const sources: CareerReportSource[] = [
{ id: "profile", label: "Profile", status: sourceStatus(profileFields), updatedAt: displayNameField.updatedAt ?? avatarUrlField.updatedAt, reason: sourceReason(profileFields) },
{ id: "onboarding", label: "Onboarding", status: sourceStatus(onboardingFields), updatedAt: targetRole.updatedAt, reason: sourceReason(onboardingFields) },
{ id: "qscore", label: "RQ Score", status: input.qscore.status === "ready" ? "ready" : input.qscore.status, updatedAt: qscoreScore.updatedAt, reason: qscoreScore.reason },
sourceFromProducer("resume", "Resume", input.producerStatuses.resume, input.snapshots.resume.source === "resume.state" ? input.snapshots.resume.updatedAt : null),
sourceFromProducer("linkedin", "LinkedIn", input.producerStatuses.linkedin, input.snapshots.socialBrand.source === "social.state" ? input.snapshots.socialBrand.updatedAt : null),
sourceFromProducer("interview", "Interview", input.producerStatuses.interview, input.snapshots.interview.source === "interview.page-state" ? input.snapshots.interview.updatedAt : null),
sourceFromProducer("roleplay", "Roleplay", input.producerStatuses.roleplay, input.snapshots.roleplay.source === "roleplay.page-state" ? input.snapshots.roleplay.updatedAt : null),
sourceFromProducer("matchmaking", "Matchmaking", input.producerStatuses.matchmaking, input.snapshots.opportunity.source === "matchmaking.scout-stats" ? input.snapshots.opportunity.updatedAt : null),
sourceFromProducer("courses", "Courses", input.producerStatuses.courses, input.snapshots.learning.source === "courses.course-state" ? input.snapshots.learning.updatedAt : null),
sourceFromProducer("assessment", "Assessment", input.producerStatuses.assessment, input.snapshots.skillGap.source === "assessment.list" ? input.snapshots.skillGap.updatedAt : null),
{ id: "grow_events", label: "Grow events", status: "ready", updatedAt: generatedAt, reason: null },
];
return {
contractVersion: "career-report-v1",
generatedAt,
subject: { displayName: displayNameField, avatarUrl: avatarUrlField, targetRole, targetField, experienceLevel, currentSituation, goals, barriers },
rqScore: { score: qscoreScore, history, display: qscoreDisplay },
snapshots: input.snapshots,
activity,
sources,
};
}

View File

@@ -0,0 +1,602 @@
import {
assessmentService,
courseService,
interviewService,
matchmakingService,
resumeService,
roleplayService,
socialBrandingService,
} from "../../services/product-service-clients.js";
import {
toQscoreUserId,
type QscoreHistoryPoint,
type QscoreProxyResult,
type QscoreReadResult,
} from "../../services/qscore-proxy.js";
export type InsightSnapshotStatus = "ready" | "empty" | "unavailable" | "unsupported";
export type InsightSnapshot = {
status: InsightSnapshotStatus;
source: string;
value: number | null;
unit: string | null;
label: string | null;
updatedAt: string | null;
reason: string | null;
detail: Record<string, unknown> | null;
};
export type HomeInsightSnapshots = {
rqScore: InsightSnapshot;
interview: InsightSnapshot;
resume: InsightSnapshot;
opportunity: InsightSnapshot;
pathway: InsightSnapshot;
socialBrand: InsightSnapshot;
roleplay: InsightSnapshot;
learning: InsightSnapshot;
skillGap: InsightSnapshot;
};
export type InsightEvent = {
payload: unknown;
occurredAt?: Date | string | null;
};
type ProducerDependencies = {
interviewPageState: (userId: string) => Promise<unknown>;
roleplayPageState: (userId: string) => Promise<unknown>;
resumeState: (userId: string) => Promise<unknown>;
socialState: (userId: string) => Promise<unknown>;
matchmakingStats: (userId: string, qscoreUserId: string) => Promise<unknown>;
courseSummary: (userId: string) => Promise<unknown>;
assessments: (userId: string) => Promise<unknown>;
};
const defaultDependencies: ProducerDependencies = {
interviewPageState: interviewService.pageState,
roleplayPageState: roleplayService.pageState,
resumeState: resumeService.state,
socialState: socialBrandingService.state,
matchmakingStats: matchmakingService.scoutStats,
courseSummary: courseService.summaryState,
assessments: assessmentService.listForUser,
};
type ProducerRead =
| { status: "ready"; data: Record<string, unknown> }
| { status: "unavailable"; reason: "producer_unavailable" | "producer_response_invalid" };
export type HomeInsightProducerStatus = {
status: "ready" | "unavailable";
reason: "producer_unavailable" | "producer_response_invalid" | null;
};
export type HomeInsightProducerStatuses = {
interview: HomeInsightProducerStatus;
roleplay: HomeInsightProducerStatus;
resume: HomeInsightProducerStatus;
linkedin: HomeInsightProducerStatus;
matchmaking: HomeInsightProducerStatus;
courses: HomeInsightProducerStatus;
assessment: HomeInsightProducerStatus;
};
type CategoryEvidence = {
id: string;
label: string;
score: number;
active: boolean;
presentRules: number;
totalRules: number;
weightPercent: number | null;
signals: unknown[];
};
function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === "object" && !Array.isArray(value);
}
function finiteNumber(value: unknown): number | null {
return typeof value === "number" && Number.isFinite(value) ? value : null;
}
function finiteCount(value: unknown): number | null {
const number = finiteNumber(value);
return number !== null && number >= 0 ? number : null;
}
function text(value: unknown): string | null {
return typeof value === "string" && value.trim() ? value : null;
}
function snapshot(
status: InsightSnapshotStatus,
source: string,
label: string,
unit: string,
options: {
value?: number | null;
updatedAt?: string | null;
reason?: string | null;
detail?: Record<string, unknown> | null;
} = {},
): InsightSnapshot {
return {
status,
source,
value: options.value ?? null,
unit,
label,
updatedAt: options.updatedAt ?? null,
reason: options.reason ?? null,
detail: options.detail ?? null,
};
}
async function readProducer(call: () => Promise<unknown>): Promise<ProducerRead> {
try {
const value = await call();
return isRecord(value)
? { status: "ready", data: value }
: { status: "unavailable", reason: "producer_response_invalid" };
} catch {
return { status: "unavailable", reason: "producer_unavailable" };
}
}
function qscoreDisplay(result: QscoreProxyResult): Record<string, unknown> | null {
const display = result.breakdown.display;
return isRecord(display) && display.version === "rq-score-display-v1" ? display : null;
}
function categoryEvidence(result: QscoreProxyResult, id: string): CategoryEvidence | null {
const display = qscoreDisplay(result);
const categories = display?.categories;
if (!Array.isArray(categories)) return null;
const raw = categories.find((value) => isRecord(value) && value.id === id);
if (!isRecord(raw)) return null;
const score = finiteNumber(raw.score);
const presentRules = finiteCount(raw.present_rules);
const totalRules = finiteCount(raw.total_rules);
if (score === null || presentRules === null || totalRules === null || typeof raw.active !== "boolean") return null;
return {
id,
label: text(raw.label) ?? id,
score,
active: raw.active,
presentRules,
totalRules,
weightPercent: finiteNumber(raw.weight_percent),
signals: Array.isArray(raw.signals) ? raw.signals : [],
};
}
function categoryDetail(category: CategoryEvidence | null): Record<string, unknown> | null {
if (!category) return null;
return {
category: {
id: category.id,
score: category.score,
active: category.active,
presentRules: category.presentRules,
totalRules: category.totalRules,
weightPercent: category.weightPercent,
signals: category.signals,
},
};
}
function qscoreCategorySnapshot(
qscore: QscoreReadResult<QscoreProxyResult>,
categoryId: string,
label: string,
): InsightSnapshot {
if (qscore.status === "unavailable") {
return snapshot("unavailable", "qscore.current", label, "score", { reason: "qscore_unavailable" });
}
if (qscore.status === "empty") {
return snapshot("empty", "qscore.current", label, "score", { reason: "no_qscore_run" });
}
const category = categoryEvidence(qscore.data, categoryId);
if (!category) {
return snapshot("unsupported", "qscore.breakdown.display", label, "score", {
reason: "display_contract_missing",
updatedAt: qscore.data.calculated_at || null,
});
}
const detail = categoryDetail(category);
if (category.totalRules === 0) {
return snapshot("unsupported", "qscore.breakdown.display", label, "score", {
reason: categoryId === "pathway_progress" ? "pathway_signal_contract_missing" : "category_unsupported",
updatedAt: qscore.data.calculated_at || null,
detail,
});
}
if (!category.active || category.presentRules === 0) {
return snapshot("empty", "qscore.breakdown.display", label, "score", {
value: 0,
reason: "no_category_evidence",
updatedAt: qscore.data.calculated_at || null,
detail,
});
}
return snapshot("ready", "qscore.breakdown.display", label, "score", {
value: category.score,
updatedAt: qscore.data.calculated_at || null,
detail,
});
}
function combineDetail(...parts: Array<Record<string, unknown> | null | undefined>): Record<string, unknown> | null {
const merged = Object.assign({}, ...parts.filter((part): part is Record<string, unknown> => !!part));
return Object.keys(merged).length ? merged : null;
}
function unavailableWhenNoUsableFallback(
read: ProducerRead,
fallback: InsightSnapshot,
source: string,
label: string,
unit: string,
): InsightSnapshot {
if (read.status === "ready" || fallback.status === "ready") return fallback;
return snapshot("unavailable", source, label, unit, {
reason: read.reason,
detail: fallback.detail,
});
}
function sessionPageData(read: ProducerRead): ProducerRead {
if (read.status === "unavailable") return read;
return Array.isArray(read.data.recent_sessions)
? read
: { status: "unavailable", reason: "producer_response_invalid" };
}
function socialStateData(read: ProducerRead): ProducerRead {
if (read.status === "unavailable") return read;
return typeof read.data.linkedin_connected === "boolean"
? read
: { status: "unavailable", reason: "producer_response_invalid" };
}
function resumeStateData(read: ProducerRead): ProducerRead {
if (read.status === "unavailable") return read;
const count = finiteCount(read.data.resume_count);
const completeness = read.data.resume_completeness;
const status = read.data.resume_status;
const completenessValid = completeness === undefined || completeness === null || (
finiteNumber(completeness) !== null && Number(completeness) >= 0 && Number(completeness) <= 100
);
const statusValid = status === undefined || status === null || text(status) !== null;
if (count === null || !completenessValid || !statusValid || (count > 0 && finiteNumber(completeness) === null)) {
return { status: "unavailable", reason: "producer_response_invalid" };
}
return read;
}
function assessmentStateData(read: ProducerRead): ProducerRead {
if (read.status === "unavailable") return read;
return Array.isArray(read.data.items) && finiteCount(read.data.total) !== null
? read
: { status: "unavailable", reason: "producer_response_invalid" };
}
function a2aData(read: ProducerRead, action: string): ProducerRead {
if (read.status === "unavailable") return read;
if (read.data.status !== "completed" || !Array.isArray(read.data.messages)) {
return { status: "unavailable", reason: "producer_response_invalid" };
}
const message = read.data.messages.find((value) => isRecord(value) && value.action === action);
return isRecord(message) && isRecord(message.data)
? { status: "ready", data: message.data }
: { status: "unavailable", reason: "producer_response_invalid" };
}
function producerStatus(read: ProducerRead): HomeInsightProducerStatus {
return read.status === "ready"
? { status: "ready", reason: null }
: { status: "unavailable", reason: read.reason };
}
function latestSessionScore(data: Record<string, unknown>): { score: number | null; count: number; updatedAt: string | null } {
const sessions = Array.isArray(data.recent_sessions) ? data.recent_sessions.filter(isRecord) : [];
const latest = sessions[0];
return {
score: latest ? finiteNumber(latest.overall_score) : null,
count: sessions.length,
updatedAt: latest ? text(latest.completed_at ?? latest.updated_at ?? latest.created_at) : null,
};
}
function gapTagsFromEvents(events: InsightEvent[]): string[] {
const keys = new Set(["weakness_tags", "weaknessTags", "skill_gaps", "skillGaps"]);
const tags = new Map<string, string>();
const visit = (value: unknown, depth: number) => {
if (depth > 4 || !isRecord(value)) return;
for (const [key, item] of Object.entries(value)) {
if (keys.has(key) && Array.isArray(item)) {
for (const tag of item) {
if (typeof tag !== "string" || !tag.trim()) continue;
tags.set(tag.trim().toLowerCase(), tag.trim());
}
} else if (isRecord(item)) {
visit(item, depth + 1);
}
}
};
for (const event of events) visit(event.payload, 0);
return [...tags.values()];
}
export async function buildHomeInsightSnapshots(input: {
userId: string;
qscore: QscoreReadResult<QscoreProxyResult>;
qscoreHistory: QscoreReadResult<QscoreHistoryPoint[]>;
recentEvents: InsightEvent[];
onProducerStatuses?: (statuses: HomeInsightProducerStatuses) => void;
}, dependencies: ProducerDependencies = defaultDependencies): Promise<HomeInsightSnapshots> {
const [interviewRaw, roleplayRaw, resumeRaw, socialRaw, matchmakingRaw, courseRaw, assessmentRaw] = await Promise.all([
readProducer(() => dependencies.interviewPageState(input.userId)),
readProducer(() => dependencies.roleplayPageState(input.userId)),
readProducer(() => dependencies.resumeState(input.userId)),
readProducer(() => dependencies.socialState(input.userId)),
readProducer(() => dependencies.matchmakingStats(input.userId, toQscoreUserId(input.userId))),
readProducer(() => dependencies.courseSummary(input.userId)),
readProducer(() => dependencies.assessments(input.userId)),
]);
const interviewRead = sessionPageData(interviewRaw);
const roleplayRead = sessionPageData(roleplayRaw);
const resumeRead = resumeStateData(resumeRaw);
const socialRead = socialStateData(socialRaw);
const assessmentRead = assessmentStateData(assessmentRaw);
const matchmakingRead = a2aData(matchmakingRaw, "scout_stats");
const courseRead = a2aData(courseRaw, "course_state_loaded");
input.onProducerStatuses?.({
interview: producerStatus(interviewRead),
roleplay: producerStatus(roleplayRead),
resume: producerStatus(resumeRead),
linkedin: producerStatus(socialRead),
matchmaking: producerStatus(matchmakingRead),
courses: producerStatus(courseRead),
assessment: producerStatus(assessmentRead),
});
const qscoreData = input.qscore.status === "ready" ? input.qscore.data : null;
const historyDetail = input.qscoreHistory.status === "ready"
? { history: input.qscoreHistory.data, historyStatus: "ready" }
: { history: null, historyStatus: input.qscoreHistory.status };
const rqScore = input.qscore.status === "ready"
? snapshot("ready", "qscore.current", "RQ Score", "score", {
value: input.qscore.data.rq_score,
updatedAt: input.qscore.data.calculated_at || null,
detail: {
contractVersion: qscoreDisplay(input.qscore.data)?.version ?? null,
formulaVersion: input.qscore.data.formula_version,
quotients: input.qscore.data.quotients,
...historyDetail,
},
})
: input.qscore.status === "empty"
? snapshot("empty", "qscore.current", "RQ Score", "score", { reason: "no_qscore_run", detail: historyDetail })
: snapshot("unavailable", "qscore.current", "RQ Score", "score", { reason: "qscore_unavailable", detail: historyDetail });
const interviewFallback = qscoreCategorySnapshot(input.qscore, "interview_performance", "Interview performance");
let interview = unavailableWhenNoUsableFallback(interviewRead, interviewFallback, "interview.page-state", "Interview performance", "score");
if (interviewRead.status === "ready") {
const direct = latestSessionScore(interviewRead.data);
const directDetail = { completedSessions: direct.count, latestScore: direct.score };
if (direct.count === 0) {
interview = snapshot("empty", "interview.page-state", "Interview performance", "score", {
reason: "no_interview_sessions",
detail: combineDetail(directDetail, interviewFallback.detail),
});
} else if (direct.score !== null) {
interview = snapshot("ready", "interview.page-state", "Interview performance", "score", {
value: direct.score,
updatedAt: direct.updatedAt,
detail: combineDetail(directDetail, interviewFallback.detail),
});
} else if (interviewFallback.status !== "ready") {
interview = snapshot("unavailable", "interview.page-state", "Interview performance", "score", {
reason: "producer_value_missing",
detail: combineDetail(directDetail, interviewFallback.detail),
});
}
}
const resumeFallback = qscoreCategorySnapshot(input.qscore, "resume_presence", "Resume completeness");
let resume = unavailableWhenNoUsableFallback(resumeRead, resumeFallback, "resume.state", "Resume completeness", "percent");
if (resumeRead.status === "ready") {
const resumeCount = finiteCount(resumeRead.data.resume_count);
const completeness = finiteNumber(resumeRead.data.resume_completeness);
const directDetail = { resumeCount, resumeStatus: text(resumeRead.data.resume_status) };
if (resumeCount === 0) {
resume = snapshot("empty", "resume.state", "Resume completeness", "percent", {
value: 0,
reason: "no_resumes",
detail: combineDetail(directDetail, resumeFallback.detail),
});
} else if (resumeCount !== null && resumeCount > 0 && completeness !== null) {
resume = snapshot("ready", "resume.state", "Resume completeness", "percent", {
value: completeness,
detail: combineDetail(directDetail, resumeFallback.detail),
});
} else if (resumeFallback.status !== "ready") {
resume = snapshot("unavailable", "resume.state", "Resume completeness", "percent", {
reason: "producer_response_invalid",
detail: resumeFallback.detail,
});
}
}
const opportunityFallback = qscoreCategorySnapshot(input.qscore, "matching_activity", "Opportunities matched");
let opportunity = unavailableWhenNoUsableFallback(matchmakingRead, opportunityFallback, "matchmaking.scout-stats", "Opportunities matched", "matches");
if (matchmakingRead.status === "ready") {
const matches = finiteCount(matchmakingRead.data.matchesFound);
const directDetail = {
matchesFound: matches,
topMatch: finiteNumber(matchmakingRead.data.topMatch),
funnel: Array.isArray(matchmakingRead.data.funnel) ? matchmakingRead.data.funnel : [],
searches: finiteCount(matchmakingRead.data.searches),
tailoredResumes: finiteCount(matchmakingRead.data.tailoredResumes),
};
if (matches === 0) {
opportunity = snapshot("empty", "matchmaking.scout-stats", "Opportunities matched", "matches", {
value: 0,
reason: "no_matches",
detail: combineDetail(directDetail, opportunityFallback.detail),
});
} else if (matches !== null) {
opportunity = snapshot("ready", "matchmaking.scout-stats", "Opportunities matched", "matches", {
value: matches,
detail: combineDetail(directDetail, opportunityFallback.detail),
});
}
}
const pathway = qscoreCategorySnapshot(input.qscore, "pathway_progress", "Pathway progress");
const socialFallback = qscoreCategorySnapshot(input.qscore, "linkedin_presence", "Social brand");
let socialBrand = unavailableWhenNoUsableFallback(socialRead, socialFallback, "social.state", "Social brand", "score");
if (socialRead.status === "ready") {
const connected = socialRead.data.linkedin_connected === true;
const brandScore = finiteNumber(socialRead.data.profile_analysis_score);
const sectionScores = isRecord(socialRead.data.section_scores) ? socialRead.data.section_scores : null;
const followers = finiteCount(socialRead.data.followers_count);
const lastSyncedAt = text(socialRead.data.last_synced_at);
const directDetail = {
connected,
followers,
audienceReach: connected && followers !== null
? { value: followers, unit: "followers", label: "Audience reach", updatedAt: lastSyncedAt }
: null,
connections: finiteCount(socialRead.data.connections_count),
skills: finiteCount(socialRead.data.skills_count),
experience: finiteCount(socialRead.data.experience_count),
improvements: finiteCount(socialRead.data.improvements_count),
grade: text(socialRead.data.score_grade),
sectionScores,
lastSyncedAt,
};
if (!connected) {
socialBrand = snapshot("empty", "social.state", "Social brand", "score", {
reason: "no_social_profile",
detail: combineDetail(directDetail, socialFallback.detail),
});
} else if (brandScore !== null) {
socialBrand = snapshot("ready", "social.state", "Social brand", "score", {
value: brandScore,
updatedAt: lastSyncedAt,
detail: combineDetail(directDetail, socialFallback.detail),
});
} else if (socialFallback.status !== "ready") {
socialBrand = snapshot("unavailable", "social.state", "Social brand", "score", {
reason: "producer_value_missing",
detail: combineDetail(directDetail, socialFallback.detail),
});
}
}
const roleplayFallback = qscoreCategorySnapshot(input.qscore, "roleplay_performance", "Roleplay performance");
let roleplay = unavailableWhenNoUsableFallback(roleplayRead, roleplayFallback, "roleplay.page-state", "Roleplay performance", "score");
if (roleplayRead.status === "ready") {
const direct = latestSessionScore(roleplayRead.data);
const directDetail = { completedSessions: direct.count, latestScore: direct.score };
if (direct.count === 0) {
roleplay = snapshot("empty", "roleplay.page-state", "Roleplay performance", "score", {
reason: "no_roleplay_sessions",
detail: combineDetail(directDetail, roleplayFallback.detail),
});
} else if (direct.score !== null) {
roleplay = snapshot("ready", "roleplay.page-state", "Roleplay performance", "score", {
value: direct.score,
updatedAt: direct.updatedAt,
detail: combineDetail(directDetail, roleplayFallback.detail),
});
} else if (roleplayFallback.status !== "ready") {
roleplay = snapshot("unavailable", "roleplay.page-state", "Roleplay performance", "score", {
reason: "producer_value_missing",
detail: combineDetail(directDetail, roleplayFallback.detail),
});
}
}
const learningFallback = qscoreCategorySnapshot(input.qscore, "learning_credentials", "Learning progress");
let learning = unavailableWhenNoUsableFallback(courseRead, learningFallback, "courses.course-state", "Learning progress", "videos");
if (courseRead.status === "ready") {
const hasProfile = courseRead.data.has_interest_profile === true;
const watchStats = isRecord(courseRead.data.watch_stats) ? courseRead.data.watch_stats : {};
const watched = finiteCount(watchStats.watched ?? courseRead.data.watch_count);
const directDetail = {
hasInterestProfile: hasProfile,
watchCount: finiteCount(courseRead.data.watch_count),
watched,
totalSeconds: finiteCount(watchStats.total_s),
anchors: Array.isArray(watchStats.anchors) ? watchStats.anchors : [],
};
if (!hasProfile || watched === 0) {
learning = snapshot("empty", "courses.course-state", "Learning progress", "videos", {
value: 0,
reason: "no_learning_activity",
detail: combineDetail(directDetail, learningFallback.detail),
});
} else if (watched !== null) {
learning = snapshot("ready", "courses.course-state", "Learning progress", "videos", {
value: watched,
detail: combineDetail(directDetail, learningFallback.detail),
});
}
}
const explicitGapTags = gapTagsFromEvents(input.recentEvents);
let skillGap: InsightSnapshot;
if (explicitGapTags.length > 0) {
skillGap = snapshot("ready", "grow_events.weakness-tags", "Skill gaps", "gaps", {
value: explicitGapTags.length,
detail: { gaps: explicitGapTags },
});
} else if (assessmentRead.status === "unavailable") {
skillGap = snapshot("unavailable", "assessment.list", "Skill gaps", "gaps", {
reason: assessmentRead.reason,
});
} else {
const items = Array.isArray(assessmentRead.data.items) ? assessmentRead.data.items.filter(isRecord) : null;
const total = finiteCount(assessmentRead.data.total);
if (!items || total === null) {
skillGap = snapshot("unavailable", "assessment.list", "Skill gaps", "gaps", {
reason: "producer_response_invalid",
});
} else if (total === 0) {
skillGap = snapshot("empty", "assessment.list", "Skill gaps", "gaps", {
reason: "no_skill_gap_evidence",
detail: { assessmentCount: 0 },
});
} else {
const latestCompleted = items.find((item) => item.status === "completed");
skillGap = snapshot("unsupported", "assessment.list", "Skill gaps", "gaps", {
reason: "skill_gap_contract_missing",
updatedAt: latestCompleted ? text(latestCompleted.completed_at ?? latestCompleted.updated_at) : null,
detail: {
assessmentCount: total,
latestCompletedScore: latestCompleted ? finiteNumber(latestCompleted.score) : null,
latestCompletedMaxScore: latestCompleted ? finiteNumber(latestCompleted.max_score) : null,
resultBreakdownItems: latestCompleted && isRecord(latestCompleted.result) && Array.isArray(latestCompleted.result.breakdown)
? latestCompleted.result.breakdown.length
: null,
},
});
}
}
// Keep the successful Qscore timestamp discoverable even when a direct producer wins.
if (qscoreData?.calculated_at) {
for (const item of [interview, resume, opportunity, pathway, socialBrand, roleplay, learning]) {
if (item.detail && item.detail.qscoreCalculatedAt === undefined) item.detail.qscoreCalculatedAt = qscoreData.calculated_at;
}
}
return { rqScore, interview, resume, opportunity, pathway, socialBrand, roleplay, learning, skillGap };
}

View File

@@ -34,7 +34,7 @@ import {
} from "./task-registry.js";
import { kpiDefinitionsForIcp } from "./kpi-registry.js";
import { outcomeDefinitionsForIcp } from "./outcome-report-registry.js";
import { activeCuratorDayIndex, addDaysIso, composeTrialDay2, computeStreakFromDays, curatorLogicalDate, resolveAgedTrialReuse, todayIso } from "./streak-utils.js";
import { activeCuratorDayIndex, addDaysIso, composeTrialDay2, computeStreakFromDays, curatorLogicalDate, resolveAgedTrialReuse, resolveProfileTaskPolicy, todayIso } from "./streak-utils.js";
// A streak day represents meaningful correlated activity, not necessarily a
// terminal artifact. Keep this intentionally broader than full task completion.
@@ -141,6 +141,20 @@ export function decideCuratorSprintReconciliation(
};
}
export function shouldRebaseCuratorSprintForOnboardingContext(input: {
existingStartDate?: string;
focusDate: string;
onboardingContextProvided: boolean;
existingCompatible: boolean;
}) {
return Boolean(
input.onboardingContextProvided
&& input.existingStartDate
&& input.existingStartDate !== input.focusDate
&& !input.existingCompatible,
);
}
function accessChoiceFromContext(context: Record<string, unknown> | undefined): "trial" | "full" | undefined {
const preferences = context && context.preferences && typeof context.preferences === "object" && !Array.isArray(context.preferences)
? context.preferences as Record<string, unknown>
@@ -751,7 +765,16 @@ async function loadSprintState(
const existingDuration = latestPayload?.durationDays === 7 ? 7 : latestPayload?.durationDays === 2 ? 2 : undefined;
const existingAccess = latestPayload?.access === "full" ? "full" : latestPayload?.access === "trial" ? "trial" : undefined;
const existingFingerprint = typeof latestPayload?.planFingerprint === "string" ? latestPayload.planFingerprint : undefined;
const rebaseExistingForCompletion = Boolean(onboardingContextOverride && existingStartDate && existingStartDate !== todayDate);
const existingCompatible = Boolean(
existingStartDate && existingVersion === CURATOR_PLAN_VERSION && existingVariant
&& existingDuration === desired.durationDays && existingAccess === desired.access,
);
const rebaseExistingForCompletion = shouldRebaseCuratorSprintForOnboardingContext({
existingStartDate,
focusDate: todayDate,
onboardingContextProvided: Boolean(onboardingContextOverride),
existingCompatible,
});
const reuseDecision = existingStartDate && existingVersion && existingDuration && existingAccess
? resolveAgedTrialReuse({
existing: { startDate: existingStartDate, version: existingVersion, durationDays: existingDuration, access: existingAccess },
@@ -1224,14 +1247,16 @@ function buildTask(
isPaidTask: seedTask.isPaidTask !== false,
isRecoveryPractice: Boolean(seedTask.isRecoveryPractice),
};
const profileComplete = isProfileTask && (userContext.profile.resumeAvailable || userContext.profile.linkedinAvailable);
const routeTask = isProfileTask && userContext.profile.linkedinAvailable && !userContext.profile.resumeAvailable
? { ...task, serviceId: "resume-service" as const }
const profilePolicy = isProfileTask
? resolveProfileTaskPolicy(userContext.profile)
: undefined;
const routeTask = profilePolicy
? { ...task, serviceId: profilePolicy.serviceId }
: task;
const taskWithRoute = {
...task,
route: buildCuratorTaskDeepLink(routeTask, targetRole),
status: profileComplete ? "completed" as const : classifyTaskStatus({ task, focusDate, completionRows, recentRows }),
status: profilePolicy?.complete ? "completed" as const : classifyTaskStatus({ task, focusDate, completionRows, recentRows }),
} satisfies CuratorTask;
return taskWithRoute;
}

View File

@@ -17,9 +17,37 @@ export function indiaTodayIso(date = new Date()): string {
}).format(date);
}
// Timestamps stay UTC; only the product-day boundary is interpreted for India.
export function curatorLogicalDate(date = new Date()): string {
return indiaTodayIso(date);
const CURATOR_BUSINESS_DAY_START_HOUR = 9;
// Timestamps stay UTC; the Curator product day rolls over at the next
// business morning in India rather than at calendar midnight.
export function curatorLogicalDate(date = new Date(), businessDayStartHour = CURATOR_BUSINESS_DAY_START_HOUR): string {
const indiaParts = new Intl.DateTimeFormat("en-CA", {
timeZone: "Asia/Kolkata",
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
hourCycle: "h23",
}).formatToParts(date);
const part = (type: Intl.DateTimeFormatPartTypes) => indiaParts.find((item) => item.type === type)?.value ?? "";
const indiaDate = `${part("year")}-${part("month")}-${part("day")}`;
return Number(part("hour")) < businessDayStartHour
? addDaysIso(indiaDate, -1)
: indiaDate;
}
export type ProfileTaskPolicyInput = {
resumeAvailable: boolean;
linkedinAvailable: boolean;
};
export function resolveProfileTaskPolicy(input: ProfileTaskPolicyInput) {
void input.resumeAvailable;
return {
complete: input.linkedinAvailable,
serviceId: "social-branding-service" as const,
};
}
export function addDaysIso(startDate: string, days: number): string {