Compare commits
11 Commits
feat/home-
...
fix/matchm
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ea8f4c3df3 | ||
|
|
6d2eb6e22a | ||
|
|
35e29316df | ||
|
|
5a519f6b52 | ||
|
|
794fe29d8d | ||
|
|
0bade06255 | ||
| ce5be53b96 | |||
|
|
fcbb93ca6a | ||
|
|
461b893fdc | ||
|
|
712a4e57f3 | ||
| d857b60111 |
@@ -14,11 +14,14 @@
|
||||
"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",
|
||||
|
||||
73
scripts/artifact-transport.test.ts
Normal file
73
scripts/artifact-transport.test.ts
Normal 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");
|
||||
264
scripts/career-report.test.ts
Normal file
264
scripts/career-report.test.ts
Normal 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");
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
})));
|
||||
|
||||
|
||||
32
scripts/deadline-hierarchy.test.ts
Normal file
32
scripts/deadline-hierarchy.test.ts
Normal 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");
|
||||
@@ -1,6 +1,7 @@
|
||||
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 {
|
||||
@@ -218,4 +219,21 @@ assert.equal(malformedA2a.opportunity.status, "ready", "valid qscore category is
|
||||
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");
|
||||
|
||||
39
scripts/interview-leaderboard.test.ts
Normal file
39
scripts/interview-leaderboard.test.ts
Normal 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");
|
||||
@@ -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 /);
|
||||
}
|
||||
|
||||
39
scripts/roleplay-leaderboard.test.ts
Normal file
39
scripts/roleplay-leaderboard.test.ts
Normal 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");
|
||||
@@ -389,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 };
|
||||
}
|
||||
|
||||
@@ -788,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);
|
||||
@@ -915,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>();
|
||||
@@ -969,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");
|
||||
@@ -980,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) => {
|
||||
@@ -997,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>();
|
||||
@@ -1045,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())));
|
||||
@@ -1069,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");
|
||||
@@ -1080,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"))));
|
||||
@@ -1145,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);
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -78,3 +78,41 @@ 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.
|
||||
|
||||
@@ -13,7 +13,16 @@ import {
|
||||
readQscoreFromService,
|
||||
readQscoreHistoryFromService,
|
||||
} from "../../services/qscore-proxy.js";
|
||||
import { buildHomeInsightSnapshots } from "./home-insight-snapshot.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);
|
||||
@@ -148,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)));
|
||||
|
||||
397
src/v1/analytics/career-report.ts
Normal file
397
src/v1/analytics/career-report.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -68,6 +68,21 @@ 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;
|
||||
@@ -247,6 +262,28 @@ function socialStateData(read: ProducerRead): ProducerRead {
|
||||
: { 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)) {
|
||||
@@ -258,6 +295,12 @@ function a2aData(read: ProducerRead, action: string): ProducerRead {
|
||||
: { 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];
|
||||
@@ -293,8 +336,9 @@ export async function buildHomeInsightSnapshots(input: {
|
||||
qscore: QscoreReadResult<QscoreProxyResult>;
|
||||
qscoreHistory: QscoreReadResult<QscoreHistoryPoint[]>;
|
||||
recentEvents: InsightEvent[];
|
||||
onProducerStatuses?: (statuses: HomeInsightProducerStatuses) => void;
|
||||
}, dependencies: ProducerDependencies = defaultDependencies): Promise<HomeInsightSnapshots> {
|
||||
const [interviewRaw, roleplayRaw, resumeRead, socialRaw, matchmakingRaw, courseRaw, assessmentRead] = await Promise.all([
|
||||
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)),
|
||||
@@ -305,7 +349,20 @@ export async function buildHomeInsightSnapshots(input: {
|
||||
]);
|
||||
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"
|
||||
@@ -376,7 +433,6 @@ export async function buildHomeInsightSnapshots(input: {
|
||||
}
|
||||
|
||||
const opportunityFallback = qscoreCategorySnapshot(input.qscore, "matching_activity", "Opportunities matched");
|
||||
const matchmakingRead = a2aData(matchmakingRaw, "scout_stats");
|
||||
let opportunity = unavailableWhenNoUsableFallback(matchmakingRead, opportunityFallback, "matchmaking.scout-stats", "Opportunities matched", "matches");
|
||||
if (matchmakingRead.status === "ready") {
|
||||
const matches = finiteCount(matchmakingRead.data.matchesFound);
|
||||
@@ -469,7 +525,6 @@ export async function buildHomeInsightSnapshots(input: {
|
||||
}
|
||||
|
||||
const learningFallback = qscoreCategorySnapshot(input.qscore, "learning_credentials", "Learning progress");
|
||||
const courseRead = a2aData(courseRaw, "course_state_loaded");
|
||||
let learning = unavailableWhenNoUsableFallback(courseRead, learningFallback, "courses.course-state", "Learning progress", "videos");
|
||||
if (courseRead.status === "ready") {
|
||||
const hasProfile = courseRead.data.has_interest_profile === true;
|
||||
|
||||
Reference in New Issue
Block a user