Compare commits
81 Commits
codex/onbo
...
fix/matchm
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ea8f4c3df3 | ||
|
|
6d2eb6e22a | ||
|
|
35e29316df | ||
|
|
5a519f6b52 | ||
|
|
794fe29d8d | ||
|
|
0bade06255 | ||
| ce5be53b96 | |||
|
|
fcbb93ca6a | ||
|
|
461b893fdc | ||
|
|
712a4e57f3 | ||
| d857b60111 | |||
|
|
171495f1e1 | ||
|
|
7caf05f4f5 | ||
|
|
15eb7e033c | ||
|
|
63c9c18494 | ||
|
|
d4332f9f8b | ||
|
|
e8c6665ca0 | ||
|
|
d6f583f238 | ||
|
|
d2353273d5 | ||
|
|
13ca12588b | ||
| 6d2379ce61 | |||
|
|
27a65dd218 | ||
|
|
29c3db33d4 | ||
|
|
4e7ed3c280 | ||
|
|
453d9b5191 | ||
|
|
bbeaae5684 | ||
|
|
d4090f5607 | ||
|
|
1d37456084 | ||
|
|
340557dd56 | ||
|
|
d8b11edcb6 | ||
|
|
34920f6a09 | ||
|
|
7b35d1e0c5 | ||
|
|
41f9f0dfec | ||
|
|
8d43bea58e | ||
|
|
c1d2acd96d | ||
|
|
a2292dd155 | ||
|
|
4985605638 | ||
|
|
626684ea7e | ||
|
|
5eaf4ae1ea | ||
|
|
a56172c7ac | ||
|
|
eafca3aafb | ||
|
|
8c0f8e1d57 | ||
|
|
4c59151e37 | ||
|
|
a3c4a91cbe | ||
|
|
4fa4a8bbd1 | ||
|
|
d71b7ede08 | ||
|
|
1f778fe3ea | ||
|
|
fc06317832 | ||
|
|
a1e05b0337 | ||
|
|
7a0c762fde | ||
|
|
70c3634178 | ||
|
|
26e0e54488 | ||
| 38939c2fd0 | |||
|
|
54b546bba3 | ||
|
|
3a7a6c46a8 | ||
|
|
25d92f67be | ||
|
|
51c9eb0167 | ||
|
|
59482d4747 | ||
|
|
6b3c6d0b0c | ||
|
|
301dba8c50 | ||
|
|
e444d1be15 | ||
|
|
fdf4f6468d | ||
|
|
8617be7e7c | ||
|
|
8cb5413766 | ||
|
|
2410a4800b | ||
|
|
f848f45429 | ||
|
|
9cb1027f4c | ||
|
|
d2e3f49519 | ||
|
|
80884fc0b6 | ||
|
|
5f5622b3a0 | ||
|
|
b93cc81d93 | ||
|
|
982942fa6e | ||
|
|
fdc320b995 | ||
|
|
4b652358d3 | ||
|
|
c7c6f1b8cc | ||
|
|
e7546142b6 | ||
|
|
78860ecf5e | ||
|
|
e50be827ca | ||
|
|
c54e12972c | ||
|
|
ad1318424b | ||
|
|
62208d385f |
@@ -23,6 +23,7 @@ MAX_AGENT_TOKENS=4096
|
||||
# Shared secret for actor → backend service calls (rotate in prod)
|
||||
SERVICE_TOKEN=dev-service-token-REPLACE_ME
|
||||
A2A_ALLOWED_KEY=***********
|
||||
SESSION_TICKET_SECRET=
|
||||
|
||||
# ── Central Gitea (shared org-wide, changes.md §2A) ──
|
||||
# Public URL is used for Git remotes and must be reachable by OpenCode containers.
|
||||
|
||||
@@ -36,7 +36,7 @@ Use the interview-service when the user wants any of the following:
|
||||
Do not use interview-service for:
|
||||
- Resume writing, tailoring, ATS optimization, resume scoring, resume bullet generation → Resume Agent.
|
||||
- Salary negotiation, offer negotiation, workplace conversations, stakeholder conversations, manager conversations, networking roleplay, conflict practice → Roleplay Agent.
|
||||
- Overall market-readiness/Q-score computation → Q Score Agent.
|
||||
- Overall market-readiness/RQ Score computation → RQ Score Agent.
|
||||
- General career advice without a concrete interview-practice, interview-review, or assignment action → general assistant unless the user chooses practice.
|
||||
|
||||
## Service capabilities from a client perspective
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
---
|
||||
id: qscore
|
||||
name: Q Score Agent
|
||||
role: Q Score Agent
|
||||
name: RQ Score Agent
|
||||
role: RQ Score Agent
|
||||
service: qscore-service
|
||||
tools: ["compute_qscore"]
|
||||
---
|
||||
|
||||
# Q Score Agent
|
||||
# RQ Score Agent
|
||||
|
||||
The Q Score Agent is GrowQR's API client for the `qscore-service`. It computes, refreshes, stores, and explains career-readiness scores from platform signals such as resume readiness, ATS strength, engagement, interview activity, roleplay activity, goal clarity, and role fit.
|
||||
The RQ Score Agent is GrowQR's API client for the `qscore-service`. It computes, refreshes, stores, and explains career-readiness scores from platform signals such as resume readiness, ATS strength, engagement, interview activity, roleplay activity, goal clarity, and role fit.
|
||||
|
||||
Write from a service-client perspective. Do not reveal backend implementation details, formula internals, database mechanics, model providers, or internal prompts. Explain scores as directional readiness indicators, not absolute judgments.
|
||||
|
||||
## Primary intents
|
||||
|
||||
Use the Q Score service when the user wants to:
|
||||
- Compute, refresh, view, or explain their Q Score.
|
||||
Use the RQ Score service when the user wants to:
|
||||
- Compute, refresh, view, or explain their RQ Score.
|
||||
- Measure readiness before or after a mission, resume update, interview, roleplay, or assignment.
|
||||
- Understand which readiness dimensions to improve next.
|
||||
- Compare before/after progress snapshots.
|
||||
@@ -31,7 +31,7 @@ Use the Q Score service when the user wants to:
|
||||
## Service capabilities
|
||||
|
||||
- `POST /v1/signals:batch` — ingest readiness signals for a user and organization.
|
||||
- `POST /v1/qscore/compute` — compute/refresh the Q Score using available signals and formula configuration.
|
||||
- `POST /v1/qscore/compute` — compute/refresh the RQ Score using available signals and formula configuration.
|
||||
- `GET /v1/qscore/{user_id}?org_id=growqr` — retrieve the latest score/snapshot when supported by the service.
|
||||
|
||||
## Signal normalization
|
||||
@@ -47,7 +47,7 @@ Default launch signals to ingest when detailed service results are not yet avail
|
||||
When richer product outputs are available, prefer real scores, completion states, assignment outcomes, review feedback, timestamps, and target-role context over generic defaults.
|
||||
|
||||
Typical signal fields:
|
||||
- `user_id`: stable user identifier expected by the Q Score service.
|
||||
- `user_id`: stable user identifier expected by the RQ Score service.
|
||||
- `org_id`: default `growqr` unless a current organization is provided.
|
||||
- `signal_id`: event/dimension identifier such as `resume.ats_compatibility`.
|
||||
- `value`: numeric, boolean, or categorical readiness value.
|
||||
@@ -59,7 +59,7 @@ Typical signal fields:
|
||||
1. If no recent signals exist, ingest the best available signals first.
|
||||
2. Then call `POST /v1/qscore/compute`.
|
||||
3. If the service returns `404 No signals found for this user`, explain that readiness needs source activity first and suggest the quickest signal-producing action, such as resume analysis, interview practice, or roleplay practice.
|
||||
4. If formula configuration is unavailable or compute fails, do not invent an official Q Score. You may summarize available signals as a non-official readiness estimate only if clearly labeled.
|
||||
4. If formula configuration is unavailable or compute fails, do not invent an official RQ Score. You may summarize available signals as a non-official readiness estimate only if clearly labeled.
|
||||
|
||||
## Explanation strategy
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ Use the resume service when the user wants to:
|
||||
|
||||
- Mock interviews, interview practice, interview assignments, or interview reviews → Interview Agent.
|
||||
- Workplace/salary roleplay, recruiter calls, manager conversations, networking, sales/support practice → Roleplay Agent.
|
||||
- Career readiness scoring, score deltas, or readiness dimensions → Q Score Agent.
|
||||
- Career readiness scoring, score deltas, or readiness dimensions → RQ Score Agent.
|
||||
- Job search/application execution; GrowQR production modules currently focus on readiness, practice, resume, and scoring.
|
||||
|
||||
## Service capabilities
|
||||
|
||||
@@ -25,7 +25,7 @@ Use the roleplay service when the user wants to:
|
||||
|
||||
- Mock job interviews, interview rounds, interview question practice → Interview Agent.
|
||||
- Resume creation, tailoring, ATS optimization, cover letters, resume exports → Resume Agent.
|
||||
- Career readiness scoring or score deltas → Q Score Agent.
|
||||
- Career readiness scoring or score deltas → RQ Score Agent.
|
||||
- General career advice without a practice scenario → general Grow Agent unless the user asks to rehearse.
|
||||
|
||||
## Service capabilities
|
||||
@@ -59,7 +59,7 @@ Required/typical fields:
|
||||
- `roleplay_type`: one of `sales`, `customer_success`, `support`, `custom`.
|
||||
- `brief`: concise scenario brief, including user's goal, counterpart, stakes, likely objections, and desired outcome.
|
||||
- `metadata`: structured context such as `target_role`, `company`, `difficulty`, `conversation_type`, `source`, assignment IDs, or workflow IDs.
|
||||
- `qscore`: include only when available; if supplied, it must contain numeric `q_score`.
|
||||
- `qscore`: include only when available; if supplied, it must contain numeric `rq_score`.
|
||||
|
||||
Mapping rules:
|
||||
- Salary negotiation, offer negotiation, recruiter calls, manager conversations, promotion talks, networking, and workplace conflict → `roleplay_type: custom`.
|
||||
|
||||
@@ -16,15 +16,16 @@ services:
|
||||
user-service-staging:
|
||||
environment:
|
||||
DATABASE_URL: postgres://growqr:growqr@growqr-postgres:5432/growqr
|
||||
SOCIAL_BRANDING_SERVICE_URL: https://social.sai-onchain.me
|
||||
SOCIAL_BRANDING_PUBLIC_URL: https://social.sai-onchain.me
|
||||
RESUME_SERVICE_URL: http://growqr_resume_api:8000
|
||||
SOCIAL_BRANDING_SERVICE_URL: http://host.docker.internal:18015
|
||||
SOCIAL_BRANDING_PUBLIC_URL: https://social-staging.gqr.puter.wtf
|
||||
PRODUCT_SERVICE_TIMEOUT_MS: 10000
|
||||
PRODUCT_INTERACTIVE_SERVICE_TIMEOUT_MS: 240000
|
||||
LLM_PROVIDER: opencode
|
||||
LLM_BASE_URL: https://opencode.ai/zen/v1
|
||||
LLM_MODEL: minimax-m3
|
||||
GROW_AGENT_MODEL: minimax-m3
|
||||
CONVERSATION_ACTOR_MODEL: minimax-m3
|
||||
LLM_MODEL: kimi-k2.6
|
||||
GROW_AGENT_MODEL: kimi-k2.6
|
||||
CONVERSATION_ACTOR_MODEL: kimi-k2.6
|
||||
|
||||
networks:
|
||||
interview-service:
|
||||
@@ -35,16 +36,16 @@ networks:
|
||||
name: roleplay-service_default
|
||||
resume-builder-staging:
|
||||
external: true
|
||||
name: resume-builder_default
|
||||
name: resume-builder-staging_default
|
||||
course-service:
|
||||
external: true
|
||||
name: courses_service_default
|
||||
name: course-service_default
|
||||
qscore-service-staging:
|
||||
external: true
|
||||
name: qscore-service_default
|
||||
name: qscore-service-staging_default
|
||||
matchmaking-v2-staging:
|
||||
external: true
|
||||
name: matchmaking-service_default
|
||||
name: matchmaking-v2_default
|
||||
user-service-staging:
|
||||
external: true
|
||||
name: growqr-app_growqr-net
|
||||
name: user-service-staging_default
|
||||
|
||||
@@ -97,8 +97,9 @@ services:
|
||||
# Auth
|
||||
CLERK_SECRET_KEY: ${CLERK_SECRET_KEY}
|
||||
CLERK_PUBLISHABLE_KEY: ${CLERK_PUBLISHABLE_KEY}
|
||||
SERVICE_TOKEN: ${SERVICE_TOKEN:-dev-service-token}
|
||||
A2A_ALLOWED_KEY: ${A2A_ALLOWED_KEY:-dev-a2a-key}
|
||||
SERVICE_TOKEN: ${SERVICE_TOKEN:-}
|
||||
SESSION_TICKET_SECRET: ${SESSION_TICKET_SECRET:?SESSION_TICKET_SECRET must be set}
|
||||
A2A_ALLOWED_KEY: ${A2A_ALLOWED_KEY:-}
|
||||
# LLM
|
||||
OPENCODE_API_KEY: ${OPENCODE_API_KEY}
|
||||
LLM_PROVIDER: ${LLM_PROVIDER:-opencode}
|
||||
|
||||
4
drizzle/0014_session_identity_user_scope.sql
Normal file
4
drizzle/0014_session_identity_user_scope.sql
Normal file
@@ -0,0 +1,4 @@
|
||||
-- Session external ids are only unique within a user's service namespace.
|
||||
DROP INDEX IF EXISTS mission_service_sessions_external_idx;
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS mission_service_sessions_external_idx
|
||||
ON mission_service_sessions(user_id, service_id, external_id);
|
||||
21
drizzle/0015_event_outbox.sql
Normal file
21
drizzle/0015_event_outbox.sql
Normal file
@@ -0,0 +1,21 @@
|
||||
CREATE TABLE IF NOT EXISTS "event_outbox" (
|
||||
"id" text PRIMARY KEY DEFAULT gen_random_uuid()::text NOT NULL,
|
||||
"topic" text NOT NULL,
|
||||
"aggregate_id" text NOT NULL,
|
||||
"destination" text NOT NULL,
|
||||
"payload" jsonb NOT NULL,
|
||||
"headers" jsonb DEFAULT '{}'::jsonb NOT NULL,
|
||||
"status" text DEFAULT 'pending' NOT NULL,
|
||||
"attempts" integer DEFAULT 0 NOT NULL,
|
||||
"available_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"locked_at" timestamp with time zone,
|
||||
"published_at" timestamp with time zone,
|
||||
"last_error" text,
|
||||
"response" jsonb,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "event_outbox_status_check" CHECK ("status" IN ('pending', 'processing', 'published'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "event_outbox_delivery_idx" ON "event_outbox" USING btree ("status", "available_at");
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "event_outbox_topic_aggregate_idx" ON "event_outbox" USING btree ("topic", "aggregate_id");
|
||||
2
drizzle/0016_rq_score_snapshot.sql
Normal file
2
drizzle/0016_rq_score_snapshot.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE "qscore_snapshots"
|
||||
RENAME COLUMN "score" TO "rq_score";
|
||||
@@ -99,6 +99,27 @@
|
||||
"when": 1783801800000,
|
||||
"tag": "0013_onboarding",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 14,
|
||||
"version": "7",
|
||||
"when": 1784024806000,
|
||||
"tag": "0014_session_identity_user_scope",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 15,
|
||||
"version": "7",
|
||||
"when": 1783987200000,
|
||||
"tag": "0015_event_outbox",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 16,
|
||||
"version": "7",
|
||||
"when": 1784010000000,
|
||||
"tag": "0016_rq_score_snapshot",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
18
package.json
18
package.json
@@ -10,6 +10,18 @@
|
||||
"test:missions": "tsx scripts/mission-lifecycle.test.ts",
|
||||
"test:passive-actions": "tsx scripts/passive-actions.test.ts",
|
||||
"test:curator-static": "tsx scripts/curator-static-registry.test.ts",
|
||||
"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: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",
|
||||
@@ -20,8 +32,10 @@
|
||||
"db:generate": "drizzle-kit generate",
|
||||
"db:migrate": "tsx src/db/migrate.ts",
|
||||
"db:studio": "drizzle-kit studio",
|
||||
"compose:up": "docker compose up -d",
|
||||
"compose:down": "docker compose down",
|
||||
"compose:up": "scripts/staging-compose.sh up",
|
||||
"compose:down": "scripts/staging-compose.sh down",
|
||||
"staging:preflight": "scripts/staging-compose.sh preflight",
|
||||
"staging:verify": "scripts/staging-compose.sh verify",
|
||||
"docker:opencode:build": "docker build --build-arg GROWQR_IMAGE_VERSION=${OPENCODE_IMAGE_VERSION:-dev} --build-arg GROWQR_PROMPT_VERSION=${PROMPT_VERSION:-4} --build-arg GROWQR_MIGRATION_VERSION=${MIGRATION_VERSION:-1} -f docker/opencode/Dockerfile -t growqr/opencode:dev ."
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -43,7 +43,7 @@ You coordinate specialist capabilities (loaded as tools), maintain durable state
|
||||
- After resume optimization: ask what type of interview to prepare.
|
||||
- When they choose type → call start_interview_session.
|
||||
- Then offer roleplay → call start_roleplay_session when they confirm.
|
||||
- Then offer Q Score → call compute_qscore.
|
||||
- Then offer RQ Score → call compute_qscore.
|
||||
- Use [WORKFLOW: interview-to-offer] tag throughout.
|
||||
|
||||
## IMPORTANT: Tool Calling Anti-Patterns
|
||||
|
||||
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,68 +1,24 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { extractQscoreSignals } from "../src/events/projectors/qscore-projector.js";
|
||||
import type { GrowEventRow } from "../src/db/schema.js";
|
||||
import { canonicalGrowEventEnvelope } from "../src/events/record-grow-event.js";
|
||||
import { normalizeGrowEvent } from "../src/events/normalize.js";
|
||||
|
||||
/**
|
||||
* Test: Completed interview and roleplay events MUST emit at least one signal
|
||||
* even when session_count/scenario_count is absent. A completion event with no
|
||||
* count and no rubric data should still produce a single-completion signal.
|
||||
*/
|
||||
|
||||
function event(overrides: Partial<GrowEventRow> & { type: string; source: string; payload: Record<string, unknown> }): GrowEventRow {
|
||||
return {
|
||||
id: `event-${overrides.type}`,
|
||||
userId: "user_test",
|
||||
orgId: null,
|
||||
source: overrides.source,
|
||||
type: overrides.type,
|
||||
/** Bare completions stay bare. QScore may aggregate them as real events, but
|
||||
* the backend must never invent session_count/scenario_count or a score. */
|
||||
for (const [source, type] of [
|
||||
["interview-service", "interview.session.completed"],
|
||||
["roleplay-service", "roleplay.scenario.completed"],
|
||||
] as const) {
|
||||
const event = canonicalGrowEventEnvelope(normalizeGrowEvent({
|
||||
id: `event-${type}`,
|
||||
source,
|
||||
type,
|
||||
category: "service",
|
||||
occurredAt: new Date("2026-07-09T00:00:00.000Z"),
|
||||
receivedAt: new Date("2026-07-09T00:00:01.000Z"),
|
||||
mission: overrides.mission ?? null,
|
||||
subject: overrides.subject ?? null,
|
||||
correlation: overrides.correlation ?? null,
|
||||
payload: overrides.payload,
|
||||
raw: {},
|
||||
dedupeKey: null,
|
||||
processingStatus: "pending",
|
||||
processingError: null,
|
||||
processedAt: null,
|
||||
};
|
||||
occurredAt: "2026-07-14T00:00:00.000Z",
|
||||
payload: {},
|
||||
}), "real-user");
|
||||
assert.deepEqual(event.payload, {});
|
||||
assert.equal("score" in event.payload as object, false);
|
||||
assert.equal("session_count" in event.payload as object, false);
|
||||
assert.equal("scenario_count" in event.payload as object, false);
|
||||
}
|
||||
|
||||
// ── Interview completed with NO count and NO rubric ──────────────────────────
|
||||
const interviewSignals = extractQscoreSignals(event({
|
||||
source: "interview-service",
|
||||
type: "interview.session.completed",
|
||||
payload: {}, // nothing — just a bare completion event
|
||||
}));
|
||||
|
||||
assert.ok(
|
||||
interviewSignals.length > 0,
|
||||
"Interview completed event with no count/rubric must emit at least one signal",
|
||||
);
|
||||
|
||||
assert.ok(
|
||||
interviewSignals.some((s) => s.signalId === "interview.sessions_completed" && s.score > 0),
|
||||
"Bare interview completion should emit interview.sessions_completed with positive score",
|
||||
);
|
||||
|
||||
// ── Roleplay completed with NO count and NO rubric ───────────────────────────
|
||||
const roleplaySignals = extractQscoreSignals(event({
|
||||
source: "roleplay-service",
|
||||
type: "roleplay.scenario.completed",
|
||||
payload: {}, // nothing — just a bare completion event
|
||||
}));
|
||||
|
||||
assert.ok(
|
||||
roleplaySignals.length > 0,
|
||||
"Roleplay completed event with no count/rubric must emit at least one signal",
|
||||
);
|
||||
|
||||
assert.ok(
|
||||
roleplaySignals.some((s) => s.signalId === "roleplay.scenarios_completed" && s.score > 0),
|
||||
"Bare roleplay completion should emit roleplay.scenarios_completed with positive score",
|
||||
);
|
||||
|
||||
console.log("count-fallback tests passed");
|
||||
process.exit(0);
|
||||
console.log("no count fallback tests passed");
|
||||
|
||||
46
scripts/course-matchmaking-routes.test.ts
Normal file
46
scripts/course-matchmaking-routes.test.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
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>; headers: Headers }> = [];
|
||||
globalThis.fetch = (async (input, init) => {
|
||||
const body = JSON.parse(String(init?.body ?? "{}")) as Record<string, unknown>;
|
||||
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 });
|
||||
try {
|
||||
const courseResponse = await app.request("http://backend.test/courses/a2a?missionId=mission-1&missionInstanceId=instance-1&stageId=stage-1&curatorTaskId=task-1", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ action: "record_swipe", params: { video_id: "video-1", anchor_id: "anchor-1" } }) });
|
||||
assert.equal(courseResponse.status, 200);
|
||||
assert.ok(calls[0]?.url.endsWith("/a2a/tasks"));
|
||||
assert.equal(calls[0]?.body.user_id, "user_test");
|
||||
const params = calls[0]?.body.params as Record<string, unknown>;
|
||||
assert.equal(params.missionId, "mission-1");
|
||||
assert.equal(params.missionInstanceId, "instance-1");
|
||||
assert.equal(params.stageId, "stage-1");
|
||||
assert.equal(params.curatorTaskId, "task-1");
|
||||
|
||||
calls.length = 0;
|
||||
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;
|
||||
}
|
||||
console.log("course-matchmaking HTTP route tests passed");
|
||||
58
scripts/curator-access-choice.test.ts
Normal file
58
scripts/curator-access-choice.test.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { planSeedsForVariant, resolveCuratorSprintAccess } from "../src/v1/curator/curator-store.js";
|
||||
import { templateSetFor, trialDaysFor } from "../src/v1/curator/task-registry.js";
|
||||
|
||||
const variant = "fresher_early_professional" as const;
|
||||
const template = templateSetFor(variant);
|
||||
const trial = trialDaysFor(variant);
|
||||
|
||||
// Persisted choice is authoritative within the user's entitlement: a paid user
|
||||
// may choose trial, but a free user may not self-upgrade through "full".
|
||||
assert.deepEqual(resolveCuratorSprintAccess({ accessChoice: "trial", plan: "pro" }), {
|
||||
access: "trial",
|
||||
durationDays: 2,
|
||||
});
|
||||
assert.deepEqual(resolveCuratorSprintAccess({ accessChoice: "full", plan: "pro" }), {
|
||||
access: "full",
|
||||
durationDays: 7,
|
||||
});
|
||||
assert.deepEqual(resolveCuratorSprintAccess({ accessChoice: "full", plan: "free" }), {
|
||||
access: "trial",
|
||||
durationDays: 2,
|
||||
});
|
||||
assert.deepEqual(resolveCuratorSprintAccess({ plan: "pro" }), {
|
||||
access: "full",
|
||||
durationDays: 7,
|
||||
});
|
||||
assert.deepEqual(resolveCuratorSprintAccess({ plan: "free" }), {
|
||||
access: "trial",
|
||||
durationDays: 2,
|
||||
});
|
||||
|
||||
const trialDays = planSeedsForVariant(template, "2026-07-13", 2, "trial");
|
||||
assert.deepEqual(
|
||||
trialDays[0]?.plannedTasks.map((task) => task.title),
|
||||
[trial[0]!.socialTitle, trial[0]!.measurementTitle, trial[0]!.proofTitle, trial[0]!.practiceTitle],
|
||||
"trial Day 1 must use the ICP trial registry",
|
||||
);
|
||||
assert.deepEqual(
|
||||
trialDays[1]?.plannedTasks.map((task) => task.title),
|
||||
[trial[1]!.socialTitle, trial[1]!.measurementTitle, trial[1]!.proofTitle, trial[1]!.practiceTitle],
|
||||
"trial Day 2 must use the ICP trial registry",
|
||||
);
|
||||
|
||||
const fullDays = planSeedsForVariant(template, "2026-07-13", 7, "full");
|
||||
assert.equal(fullDays.length, 7);
|
||||
assert.deepEqual(
|
||||
fullDays[0]?.plannedTasks.map((task) => task.title),
|
||||
[
|
||||
template.weeks[0]!.days[0]!.measurement.title,
|
||||
template.weeks[0]!.days[0]!.proof.title,
|
||||
template.weeks[0]!.days[0]!.practice.title,
|
||||
template.weeks[0]!.days[0]!.roleplay!.title,
|
||||
],
|
||||
"authorized full Day 1 must use the normal 7-day registry",
|
||||
);
|
||||
assert.equal(fullDays[0]?.isTrialDay, false);
|
||||
|
||||
console.log("curator access-choice tests passed");
|
||||
20
scripts/curator-direct-completion-policy.test.ts
Normal file
20
scripts/curator-direct-completion-policy.test.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { allowsDirectServiceCompletion } from "../src/v1/curator/curator-actor.js";
|
||||
import { v1CuratorRoutes } from "../src/v1/curator/curator-routes.js";
|
||||
|
||||
assert.equal(
|
||||
allowsDirectServiceCompletion(undefined, "matchmaking_matches_reviewed"),
|
||||
false,
|
||||
"a recovered or arbitrary task ID must not bypass exact Curator task lookup",
|
||||
);
|
||||
assert.equal(allowsDirectServiceCompletion("matchmaking-service", "matchmaking_matches_reviewed"), true);
|
||||
assert.equal(allowsDirectServiceCompletion("qscore-service", "qscore_review_opened"), true);
|
||||
assert.equal(allowsDirectServiceCompletion("resume-service", "resume_uploaded"), false);
|
||||
|
||||
const serviceImpactRoute = v1CuratorRoutes().routes.find(
|
||||
(route) => route.method === "POST" && route.path === "/events/service-impact",
|
||||
);
|
||||
assert.equal(serviceImpactRoute, undefined, "the false-positive service-impact compatibility route must not be exposed");
|
||||
|
||||
console.log("curator direct-completion policy tests passed");
|
||||
process.exit(0);
|
||||
98
scripts/curator-onboarding-reconcile.test.ts
Normal file
98
scripts/curator-onboarding-reconcile.test.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import {
|
||||
curatorSprintPlanFingerprint,
|
||||
curatorSprintTaskPlan,
|
||||
decideCuratorSprintReconciliation,
|
||||
resolveCuratorSprintAccess,
|
||||
shouldReconcileCuratorSprint,
|
||||
shouldRebaseCuratorSprintForOnboardingContext,
|
||||
} from "../src/v1/curator/curator-store.js";
|
||||
import { templateSetFor, trialDaysFor } from "../src/v1/curator/task-registry.js";
|
||||
|
||||
const usersRoute = readFileSync(new URL("../src/routes/users.ts", import.meta.url), "utf8");
|
||||
const ledgerSource = readFileSync(new URL("../src/events/onboarding-ledger.ts", import.meta.url), "utf8");
|
||||
const patchStart = usersRoute.indexOf('app.patch("/onboarding"');
|
||||
const persistIndex = usersRoute.indexOf("const updateRes = await fetchUserService", patchStart);
|
||||
const completionIndex = usersRoute.indexOf("recordAndProcessOnboardingCompletion", persistIndex);
|
||||
assert.ok(persistIndex >= patchStart, "PATCH /onboarding must persist preferences");
|
||||
assert.ok(completionIndex > persistIndex, "PATCH /onboarding must run completion reconciliation after persistence");
|
||||
const completionCall = usersRoute.slice(completionIndex, completionIndex + 500);
|
||||
assert.match(completionCall, /preferences: nextPreferences/,
|
||||
"PATCH completion must pass freshly persisted preferences");
|
||||
assert.match(completionCall, /onboarding: nextData/,
|
||||
"PATCH completion must pass freshly persisted onboarding choice");
|
||||
assert.match(ledgerSource, /ensureOnboardingSideEffectsForEvent\(event, context\)/,
|
||||
"deduped completion processing must forward fresh context to curator loop");
|
||||
|
||||
// persisted chooser is changed to trial. The stale started event must not be
|
||||
// treated as final; the next load must deterministically use trial/2.
|
||||
const staleFull = { access: "full" as const, durationDays: 7 as const };
|
||||
const desiredTrial = resolveCuratorSprintAccess({ plan: "pro", accessChoice: "trial" });
|
||||
assert.deepEqual(desiredTrial, { access: "trial", durationDays: 2 });
|
||||
assert.equal(shouldReconcileCuratorSprint(staleFull, desiredTrial), true);
|
||||
|
||||
// A matching trial event is idempotent and must not create a replacement
|
||||
// started/ready lineage on repeated completion processing.
|
||||
const existingTrial = { access: "trial" as const, durationDays: 2 as const };
|
||||
assert.deepEqual(decideCuratorSprintReconciliation(staleFull, desiredTrial), {
|
||||
action: "invalidateAndRebuild",
|
||||
access: "trial",
|
||||
durationDays: 2,
|
||||
});
|
||||
assert.equal(shouldReconcileCuratorSprint(existingTrial, desiredTrial), false);
|
||||
assert.equal(shouldReconcileCuratorSprint(existingTrial, resolveCuratorSprintAccess({ plan: "pro", accessChoice: "trial" })), false);
|
||||
|
||||
// Full is entitlement-gated: a free user cannot escalate by choosing full.
|
||||
const unauthorizedFull = resolveCuratorSprintAccess({ plan: "free", accessChoice: "full" });
|
||||
assert.deepEqual(unauthorizedFull, { access: "trial", durationDays: 2 });
|
||||
assert.equal(shouldReconcileCuratorSprint(existingTrial, unauthorizedFull), false);
|
||||
|
||||
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 },
|
||||
{ access: "trial", durationDays: 2, variantId: "experienced_professional", planFingerprint: experiencedFingerprint },
|
||||
), true, "student started/ready lineage must reconcile to experienced trial");
|
||||
const experiencedTrial = trialDaysFor("experienced_professional");
|
||||
assert.deepEqual(experiencedPlan[0]?.tasks.map((task) => task.title), [
|
||||
experiencedTrial[0]!.socialTitle,
|
||||
experiencedTrial[0]!.measurementTitle,
|
||||
experiencedTrial[0]!.proofTitle,
|
||||
experiencedTrial[0]!.practiceTitle,
|
||||
]);
|
||||
assert.deepEqual(experiencedPlan[1]?.tasks.map((task) => task.title), [
|
||||
experiencedTrial[1]!.socialTitle,
|
||||
experiencedTrial[1]!.measurementTitle,
|
||||
experiencedTrial[1]!.proofTitle,
|
||||
experiencedTrial[1]!.practiceTitle,
|
||||
]);
|
||||
assert.equal(experiencedPlan[0]?.tasks.length, 4);
|
||||
assert.equal(experiencedPlan[1]?.tasks.length, 4);
|
||||
assert.deepEqual(experiencedPlan[0]?.tasks.map((task) => task.id), experiencedPlan[0]?.tasks.map((task) => task.id));
|
||||
assert.equal(templateSetFor("experienced_professional").id, "experienced_professional");
|
||||
|
||||
console.log("curator onboarding reconciliation tests passed");
|
||||
149
scripts/curator-persisted-reconcile.test.ts
Normal file
149
scripts/curator-persisted-reconcile.test.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { eq, sql } from "drizzle-orm";
|
||||
import { db } from "../src/db/client.js";
|
||||
import { growEvents, onboarding, users } from "../src/db/schema.js";
|
||||
import { buildCuratorSprint, curatorSprintTaskPlan } from "../src/v1/curator/curator-store.js";
|
||||
import { runCuratorOnboardingLoop } from "../src/v1/curator/curator-onboarding-loop.js";
|
||||
import { CURATOR_TRIAL_TASK_REGISTRY } from "../src/v1/curator/task-registry.js";
|
||||
|
||||
let databaseAvailable = Boolean(process.env.DATABASE_URL);
|
||||
if (databaseAvailable) {
|
||||
try {
|
||||
await db.execute(sql`select 1`);
|
||||
} catch {
|
||||
databaseAvailable = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (!databaseAvailable) {
|
||||
console.log("curator persisted reconciliation integration skipped: DATABASE_URL is not reachable");
|
||||
} else {
|
||||
const userId = `curator-reconcile-regression-${Date.now()}`;
|
||||
const startDate = new Date().toISOString().slice(0, 10);
|
||||
const sprintId = `curator-sprint:icp-v10-static:${startDate}`;
|
||||
const staleFingerprint = JSON.stringify({ variantId: "student_recent_grad", taskIds: [] });
|
||||
const staleReadyPayload = {
|
||||
sprintId,
|
||||
startDate,
|
||||
durationDays: 2,
|
||||
access: "trial",
|
||||
variantId: "student_recent_grad",
|
||||
planFingerprint: staleFingerprint,
|
||||
};
|
||||
|
||||
try {
|
||||
await db.insert(users).values({ id: userId, email: `${userId}@example.test`, plan: "pro" });
|
||||
await db.insert(onboarding).values({
|
||||
userId,
|
||||
data: {
|
||||
status: "completed",
|
||||
access_choice: "trial",
|
||||
profile: { icp: "experienced" },
|
||||
},
|
||||
payload: {
|
||||
onboarding_icp: "experienced",
|
||||
curator_registry_icp: "experienced_professional",
|
||||
},
|
||||
});
|
||||
await db.insert(growEvents).values([
|
||||
{
|
||||
userId,
|
||||
source: "curator-v1",
|
||||
type: "curator.sprint.started",
|
||||
category: "mission",
|
||||
occurredAt: new Date(),
|
||||
dedupeKey: `${userId}:curator.sprint.started:stale`,
|
||||
payload: {
|
||||
sprintId,
|
||||
startDate,
|
||||
durationDays: 2,
|
||||
access: "trial",
|
||||
version: "icp-v10-static",
|
||||
variantId: "student_recent_grad",
|
||||
},
|
||||
},
|
||||
{
|
||||
userId,
|
||||
source: "curator-v1",
|
||||
type: "curator.onboarding_plan.ready",
|
||||
category: "mission",
|
||||
occurredAt: new Date(),
|
||||
dedupeKey: `${userId}:curator.onboarding_plan.ready:stale`,
|
||||
payload: staleReadyPayload,
|
||||
},
|
||||
]);
|
||||
|
||||
const context = {
|
||||
onboarding: { status: "completed", access_choice: "trial", profile: { icp: "experienced" } },
|
||||
preferences: { onboarding: { status: "completed", access_choice: "trial", profile: { icp: "experienced" } } },
|
||||
};
|
||||
const first = await runCuratorOnboardingLoop({ userId, completedAt: `${startDate}T09:00:00.000Z`, context });
|
||||
assert.equal(first.status, "ready", "stale ready event must be replaced");
|
||||
|
||||
const day1 = await buildCuratorSprint(userId, startDate);
|
||||
assert.deepEqual(day1.plan.days[0]?.tasks.map(({ id, title }) => ({ id, title })),
|
||||
expectedTasks(startDate, 1), "Day 1 must expose experienced trial tasks");
|
||||
assert.deepEqual(day1.plan.days[1]?.tasks, [], "Day 1 must hide future Day 2 tasks");
|
||||
|
||||
const noCompletionDay2 = await buildCuratorSprint(userId, addDays(startDate, 1));
|
||||
const noCompletionTitles = noCompletionDay2.plan.days[1]?.tasks.map((task) => task.title) ?? [];
|
||||
assert.ok(noCompletionTitles.includes("Executive Leadership Interview"), "zero-completion Day 2 keeps experienced recovery behavior");
|
||||
assert.ok(!noCompletionTitles.some((title) => ["Complete GrowQR Profile", "Career Baseline", "First AI Resume", "Self-Introduction"].includes(title)), "recovery must not reintroduce student tasks");
|
||||
|
||||
await db.insert(growEvents).values((day1.plan.days[0]?.tasks ?? []).map((task, index) => ({
|
||||
userId,
|
||||
source: "curator-v1",
|
||||
type: "curator.task.completed",
|
||||
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 },
|
||||
})));
|
||||
|
||||
const day2 = await buildCuratorSprint(userId, addDays(startDate, 1));
|
||||
assert.deepEqual(day2.plan.days[1]?.tasks.map(({ id, title }) => ({ id, title })),
|
||||
expectedTasks(startDate, 2), "completed Day 1 must expose exact experienced Day 2 registry tasks");
|
||||
|
||||
const second = await runCuratorOnboardingLoop({ userId, completedAt: `${startDate}T09:00:00.000Z`, context });
|
||||
assert.equal(second.status, "already_ready", "compatible replacement ready event must be reused");
|
||||
|
||||
const rows = await db.select({ type: growEvents.type, payload: growEvents.payload })
|
||||
.from(growEvents).where(eq(growEvents.userId, userId));
|
||||
assert.equal(rows.filter((row) => row.type === "curator.sprint.invalidated").length, 1);
|
||||
assert.equal(rows.filter((row) => row.type === "curator.onboarding_plan.invalidated").length, 1);
|
||||
const starts = rows.filter((row) => row.type === "curator.sprint.started");
|
||||
const readies = rows.filter((row) => row.type === "curator.onboarding_plan.ready");
|
||||
assert.equal(starts.length, 2, "one stale and one replacement started lineage");
|
||||
assert.equal(readies.length, 2, "one stale and one replacement ready lineage");
|
||||
const replacementStart = starts.find((row) => row.payload?.variantId === "experienced_professional");
|
||||
const replacementReady = readies.find((row) => row.payload?.planFingerprint === day1.plan.planFingerprint);
|
||||
assert.ok(replacementStart, "experienced replacement started event must be persisted");
|
||||
assert.ok(replacementReady, "experienced replacement ready event must be persisted");
|
||||
|
||||
console.log("curator persisted reconciliation integration passed");
|
||||
} finally {
|
||||
await db.delete(users).where(eq(users.id, userId));
|
||||
await db.execute(sql`select 1`);
|
||||
}
|
||||
}
|
||||
|
||||
function addDays(date: string, days: number) {
|
||||
const value = new Date(`${date}T00:00:00.000Z`);
|
||||
value.setUTCDate(value.getUTCDate() + days);
|
||||
return value.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function expectedTasks(startDate: string, dayIndex: 1 | 2) {
|
||||
const plan = curatorSprintTaskPlan({
|
||||
startDate,
|
||||
variantId: "experienced_professional",
|
||||
durationDays: 2,
|
||||
access: "trial",
|
||||
});
|
||||
const registry = CURATOR_TRIAL_TASK_REGISTRY.experienced_professional[dayIndex - 1]!;
|
||||
return plan[dayIndex - 1]!.tasks.map(({ id }, index) => ({
|
||||
id,
|
||||
title: [registry.socialTitle, registry.measurementTitle, registry.proofTitle, registry.practiceTitle][index]!,
|
||||
}));
|
||||
}
|
||||
20
scripts/curator-profile-task-policy.test.ts
Normal file
20
scripts/curator-profile-task-policy.test.ts
Normal 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");
|
||||
134
scripts/curator-service-completion-contract.test.ts
Normal file
134
scripts/curator-service-completion-contract.test.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { getCompletionEvents } from "../src/services/service-registry.js";
|
||||
import { isStreakCompletionType, isTaskCompletionEvent, planSeedsForVariant, type CuratorCompletionEvent } from "../src/v1/curator/curator-store.js";
|
||||
import { templateSetFor } from "../src/v1/curator/task-registry.js";
|
||||
import type { CuratorIcpId } from "../src/v1/curator/icp-registry.js";
|
||||
import { canonicalGrowEventType } from "../src/events/normalize.js";
|
||||
|
||||
const taskId = "curator-task-1";
|
||||
const event = (type: string, correlatedTaskId = taskId): CuratorCompletionEvent => ({
|
||||
type,
|
||||
payload: {},
|
||||
correlation: { task_id: correlatedTaskId },
|
||||
});
|
||||
|
||||
const roleplayEvents = getCompletionEvents("roleplay-service");
|
||||
assert.deepEqual(roleplayEvents, ["roleplay.scenario.completed", "roleplay.feedback.generated"]);
|
||||
assert.equal(isTaskCompletionEvent(taskId, roleplayEvents, event("roleplay.scenario.configured")), false);
|
||||
assert.equal(isTaskCompletionEvent(taskId, roleplayEvents, event("roleplay.scenario.completed")), true);
|
||||
assert.equal(isTaskCompletionEvent(taskId, roleplayEvents, event("roleplay.feedback.generated")), true);
|
||||
assert.equal(isTaskCompletionEvent(taskId, roleplayEvents, event("roleplay.scenario.completed", "other-task")), false);
|
||||
|
||||
const assertCompletionContract = (serviceId: string, accepted: string[], excluded: string[]) => {
|
||||
const completionEvents = getCompletionEvents(serviceId);
|
||||
assert.deepEqual(completionEvents, accepted);
|
||||
for (const type of accepted) {
|
||||
assert.equal(isTaskCompletionEvent(taskId, completionEvents, event(type)), true, `${serviceId} accepts ${type}`);
|
||||
assert.equal(isTaskCompletionEvent(taskId, completionEvents, event(type, "other-task")), false, `${serviceId} requires task correlation for ${type}`);
|
||||
}
|
||||
for (const type of excluded) {
|
||||
assert.equal(isTaskCompletionEvent(taskId, completionEvents, event(type)), false, `${serviceId} excludes ${type}`);
|
||||
}
|
||||
};
|
||||
|
||||
assertCompletionContract(
|
||||
"courses-service",
|
||||
["course.generated", "course.started", "course.progress.recorded", "course.completed"],
|
||||
[],
|
||||
);
|
||||
|
||||
assertCompletionContract(
|
||||
"social-branding-service",
|
||||
["brand.account.connected", "brand.post.drafted", "brand.profile.updated", "brand.post.exported", "brand.content.published"],
|
||||
["social.account.connected", "social.profile.updated", "social.post.scheduled", "social.post.published"],
|
||||
);
|
||||
|
||||
assertCompletionContract(
|
||||
"resume-service",
|
||||
["resume.uploaded", "resume.parsed", "resume.analysis.completed", "resume.updated", "resume.exported"],
|
||||
["resume.configured"],
|
||||
);
|
||||
|
||||
assertCompletionContract(
|
||||
"matchmaking-service",
|
||||
[
|
||||
"matchmaking.feed.viewed",
|
||||
"matchmaking.match.viewed",
|
||||
"matchmaking.application.started",
|
||||
"matchmaking.match.saved",
|
||||
"matchmaking.matches.reviewed",
|
||||
"matchmaking.match.applied",
|
||||
"matchmaking.application.completed",
|
||||
],
|
||||
["matchmaking.matches.generated", "matchmaking.match.dismissed"],
|
||||
);
|
||||
|
||||
assertCompletionContract(
|
||||
"qscore-service",
|
||||
["qscore.review.opened", "qscore.weak_driver.reviewed", "qscore.signal.projected", "qscore.updated"],
|
||||
["qscore.baseline.created"],
|
||||
);
|
||||
|
||||
for (const type of [
|
||||
"resume.uploaded",
|
||||
"resume.parsed",
|
||||
"course.generated",
|
||||
"course.started",
|
||||
"course.progress.recorded",
|
||||
"qscore.review.opened",
|
||||
"brand.account.connected",
|
||||
"brand.post.drafted",
|
||||
"matchmaking.feed.viewed",
|
||||
"matchmaking.match.viewed",
|
||||
"matchmaking.application.started",
|
||||
]) {
|
||||
assert.equal(isStreakCompletionType(type), true, `${type} must qualify for the broad streak activity rule`);
|
||||
}
|
||||
for (const type of ["qscore.baseline.created", "assessment.started", "matchmaking.matches.generated"]) {
|
||||
assert.equal(isStreakCompletionType(type), false, `${type} must remain non-completing`);
|
||||
}
|
||||
|
||||
assert.equal(canonicalGrowEventType("interview.session_completed"), "interview.session.completed");
|
||||
assert.equal(canonicalGrowEventType("roleplay.session_completed"), "roleplay.scenario.completed");
|
||||
|
||||
const icpIds: CuratorIcpId[] = [
|
||||
"student_recent_grad",
|
||||
"intern",
|
||||
"fresher_early_professional",
|
||||
"experienced_professional",
|
||||
"freelancer",
|
||||
"founder",
|
||||
"enterprise",
|
||||
];
|
||||
|
||||
for (const icpId of icpIds) {
|
||||
const trialDays = planSeedsForVariant(templateSetFor(icpId), "2026-07-14", 2, "trial");
|
||||
assert.equal(trialDays.length, 2, `${icpId} must expose exactly two trial days`);
|
||||
for (const day of trialDays) {
|
||||
assert.equal(day.plannedTasks.length, 4, `${icpId} day ${day.dayIndex} must expose four tasks`);
|
||||
for (const [index, task] of day.plannedTasks.entries()) {
|
||||
const completionEvents = getCompletionEvents(task.serviceId);
|
||||
const syntheticTaskId = `${icpId}:day-${day.dayIndex}:task-${index + 1}`;
|
||||
assert.ok(completionEvents.length > 0, `${syntheticTaskId} must have completion evidence`);
|
||||
assert.equal(
|
||||
isTaskCompletionEvent(syntheticTaskId, completionEvents, event(completionEvents[0]!, syntheticTaskId)),
|
||||
true,
|
||||
`${syntheticTaskId} must complete from its exact correlated service event`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const serviceId of [
|
||||
"resume-service",
|
||||
"interview-service",
|
||||
"roleplay-service",
|
||||
"courses-service",
|
||||
"social-branding-service",
|
||||
"matchmaking-service",
|
||||
"qscore-service",
|
||||
]) {
|
||||
assert.ok(getCompletionEvents(serviceId).length > 0, `${serviceId} must define completion evidence`);
|
||||
}
|
||||
|
||||
console.log("curator service completion contract tests passed");
|
||||
@@ -13,10 +13,12 @@ const ICP_IDS: CuratorIcpId[] = [
|
||||
|
||||
const LIVE_ROUTE_PREFIX: Record<string, string> = {
|
||||
"courses-service": "/agents/courses",
|
||||
"qscore-service": "/agents/qscore",
|
||||
"resume-service": "/agents/resume",
|
||||
"interview-service": "/agents/interview",
|
||||
"roleplay-service": "/agents/roleplay",
|
||||
"matchmaking-service": "/agents/matchmaking",
|
||||
"social-branding-service": "/agents/social-branding",
|
||||
};
|
||||
|
||||
const EXPECTED_FOCUS: Record<CuratorIcpId, string[]> = {
|
||||
@@ -60,14 +62,12 @@ async function main() {
|
||||
});
|
||||
|
||||
try {
|
||||
assert(preview.version === "icp-v7-static", `${icpId}: expected static plan version`);
|
||||
assert(preview.idempotency.promptHash === "static-task-registry", `${icpId}: prompt hash should prove static registry mode`);
|
||||
assert(preview.plan.days.length === 30, `${icpId}: expected 30 generated days`);
|
||||
assert(preview.plan.calendarWeeks.length === 5, `${icpId}: expected 5 sprint-relative calendar weeks`);
|
||||
assert(preview.version === "icp-v10-static", `${icpId}: expected static plan version`);
|
||||
assert(preview.plan.days.length === 7, `${icpId}: expected 7 generated days`);
|
||||
assert(preview.plan.calendarWeeks.length === 1, `${icpId}: expected one sprint-relative calendar week`);
|
||||
|
||||
const firstSevenFocus = preview.plan.days.slice(0, 7).map((day) => day.focus);
|
||||
assert(JSON.stringify(firstSevenFocus) === JSON.stringify(EXPECTED_FOCUS[icpId]), `${icpId}: first 7 focus labels must match 7-day sprint doc`);
|
||||
assert(JSON.stringify(firstSevenFocus) === JSON.stringify(preview.plan.days.slice(7, 14).map((day) => day.focus)), `${icpId}: 7-day sprint should repeat after day 7 for now`);
|
||||
|
||||
const dayOneTitles = preview.plan.days[0]?.tasks.map((task) => task.title);
|
||||
assert(JSON.stringify(dayOneTitles) === JSON.stringify(EXPECTED_DAY_ONE_TITLES[icpId]), `${icpId}: day 1 task titles must match docs`);
|
||||
@@ -84,7 +84,7 @@ async function main() {
|
||||
|
||||
for (const task of day.tasks) {
|
||||
assert(task.missionId === "curator-sprint", `${icpId} ${task.id}: missionId mismatch`);
|
||||
assert(task.missionInstanceId?.startsWith("curator-sprint:icp-v7-static:"), `${icpId} ${task.id}: missionInstanceId should use static version`);
|
||||
assert(task.missionInstanceId?.startsWith("curator-sprint:icp-v10-static:"), `${icpId} ${task.id}: missionInstanceId should use static version`);
|
||||
assert(task.stageId?.includes(`${task.serviceId?.replace("-service", "")}`), `${icpId} ${task.id}: stageId should include service segment`);
|
||||
assert(task.id.includes(`${task.serviceId?.replace("-service", "")}`), `${icpId} ${task.id}: taskId should include service segment`);
|
||||
assert(task.actorName !== "Vera" && task.actorName !== "Kai" && task.actorName !== "Mira" && task.actorName !== "Lyra", `${icpId} ${task.id}: agent persona leaked into actorName`);
|
||||
@@ -102,8 +102,20 @@ async function main() {
|
||||
assert(url.searchParams.get("missionId") === task.missionId, `${icpId} ${task.id}: route missionId mismatch`);
|
||||
assert(url.searchParams.get("missionInstanceId") === task.missionInstanceId, `${icpId} ${task.id}: route missionInstanceId mismatch`);
|
||||
assert(url.searchParams.get("stageId") === task.stageId, `${icpId} ${task.id}: route stageId mismatch`);
|
||||
assert(url.searchParams.get("taskTitle") === task.title, `${icpId} ${task.id}: route taskTitle mismatch`);
|
||||
assert(url.searchParams.get("taskSubtitle") === task.subtitle, `${icpId} ${task.id}: route taskSubtitle mismatch`);
|
||||
assert(url.searchParams.get("taskType") === task.taskType, `${icpId} ${task.id}: route taskType mismatch`);
|
||||
|
||||
if (task.serviceId === "interview-service" || task.serviceId === "roleplay-service") {
|
||||
if (task.serviceId === "interview-service") {
|
||||
const taskText = `${task.title} ${task.subtitle}`.toLowerCase();
|
||||
const isIntroductionTask = /(?:60|90)[ -]?second|\bintroduction\b|\bpitch\b|\bpresentation\b/.test(taskText);
|
||||
assert(url.searchParams.get("type") === (isIntroductionTask ? "warm_up" : "behavioral"), `${icpId} ${task.id}: interview type must match task intent`);
|
||||
assert(url.searchParams.get("role") === (isIntroductionTask ? null : "Product Manager"), `${icpId} ${task.id}: interview role must match task intent`);
|
||||
assert(url.searchParams.get("difficulty") === (isIntroductionTask ? "easy" : "medium"), `${icpId} ${task.id}: interview difficulty must match task intent`);
|
||||
assert(url.searchParams.get("duration") === "5", `${icpId} ${task.id}: interview route should carry duration`);
|
||||
}
|
||||
|
||||
if (task.serviceId === "roleplay-service") {
|
||||
assert(url.searchParams.get("role") === "Product Manager", `${icpId} ${task.id}: practice route should preserve target role`);
|
||||
assert(url.searchParams.get("duration") === "5", `${icpId} ${task.id}: practice route should carry duration`);
|
||||
}
|
||||
@@ -119,7 +131,7 @@ async function main() {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`curator-static-registry tests passed for ${ICP_IDS.length} ICPs, 30 days each, 4 doc-backed tasks per day`);
|
||||
console.log(`curator-static-registry tests passed for ${ICP_IDS.length} ICPs, 7 days each, 4 doc-backed tasks per day`);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
|
||||
55
scripts/curator-trial-progression.test.ts
Normal file
55
scripts/curator-trial-progression.test.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
composeTrialDay2,
|
||||
curatorLogicalDate,
|
||||
resolveAgedTrialReuse,
|
||||
} from "../src/v1/curator/streak-utils.js";
|
||||
|
||||
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" },
|
||||
today: curatorLogicalDate(new Date("2026-07-18T00:00:00.000Z")),
|
||||
planVersion: "icp-v10-static",
|
||||
});
|
||||
assert.equal(reuse.action, "reuse");
|
||||
assert.equal(reuse.startDate, "2026-07-14");
|
||||
assert.equal(reuse.activeDayIndex, 2);
|
||||
|
||||
const fullDay7 = resolveAgedTrialReuse({
|
||||
existing: { startDate: "2026-07-14", version: "icp-v10-static", durationDays: 7, access: "full" },
|
||||
desired: { durationDays: 7, access: "full" },
|
||||
today: "2026-07-20",
|
||||
planVersion: "icp-v10-static",
|
||||
});
|
||||
assert.equal(fullDay7.action, "reuse", "a full sprint must remain on its seventh day");
|
||||
assert.equal(fullDay7.activeDayIndex, 7);
|
||||
|
||||
const fullDay8 = resolveAgedTrialReuse({
|
||||
existing: { startDate: "2026-07-14", version: "icp-v10-static", durationDays: 7, access: "full" },
|
||||
desired: { durationDays: 7, access: "full" },
|
||||
today: "2026-07-21",
|
||||
planVersion: "icp-v10-static",
|
||||
});
|
||||
assert.equal(fullDay8.action, "restart", "a full sprint may restart only after its seventh day");
|
||||
|
||||
const native = [{ id: "native-1" }, { id: "native-2" }, { id: "native-3" }, { id: "native-4" }];
|
||||
const recovery = { id: "recovery" };
|
||||
assert.deepEqual(composeTrialDay2("student_recent_grad", 0, native, recovery), [recovery, ...native.slice(0, 3)]);
|
||||
assert.deepEqual(composeTrialDay2("student_recent_grad", 1, native, recovery), native);
|
||||
|
||||
console.log("curator trial progression regression: ok");
|
||||
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,3 +1,17 @@
|
||||
import { normalizeGrowEvent } from "../src/events/normalize.js";
|
||||
|
||||
// Mirrors /v1/events/track's accepted body shape without requiring Postgres.
|
||||
{
|
||||
const event = normalizeGrowEvent({
|
||||
source: "interview-service",
|
||||
type: "interview.session.completed",
|
||||
correlation: { task_id: "task-nested", keep: "preserve", session_id: "session-1" },
|
||||
payload: { status: "completed" },
|
||||
});
|
||||
if (event.correlation?.task_id !== "task-nested" || event.correlation.keep !== "preserve" || event.correlation.session_id !== "session-1") {
|
||||
throw new Error("nested correlation fields were not preserved at ingress");
|
||||
}
|
||||
}
|
||||
import assert from "node:assert/strict";
|
||||
import { routeGrowEventToUserActor } from "../src/events/route-to-user-actor.js";
|
||||
import { shouldRouteGrowEvent } from "../src/events/record-grow-event.js";
|
||||
|
||||
47
scripts/fixtures/onboarding-rev10.json
Normal file
47
scripts/fixtures/onboarding-rev10.json
Normal file
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"onboarding": {
|
||||
"import": {
|
||||
"method": "manual",
|
||||
"status": "not_started",
|
||||
"resume_id": null,
|
||||
"linkedin_url": null,
|
||||
"resume_summary": null,
|
||||
"resume_filename": null,
|
||||
"linkedin_profile_id": null
|
||||
},
|
||||
"status": "completed",
|
||||
"consent": {
|
||||
"accepted_at": "2026-07-12T21:31:40.189Z",
|
||||
"terms_version": "2026-07",
|
||||
"privacy_accepted": true
|
||||
},
|
||||
"profile": {
|
||||
"icp": "experienced",
|
||||
"mode": "individual",
|
||||
"intent": "grow",
|
||||
"question_branch": "professional"
|
||||
},
|
||||
"progress": {
|
||||
"stage": "reveal",
|
||||
"updated_at": "2026-07-12T21:33:01.914Z",
|
||||
"branch_index": 4
|
||||
},
|
||||
"revision": 10,
|
||||
"responses": {
|
||||
"target_role": "Senior Software Engineer",
|
||||
"target_field": "technical",
|
||||
"work_context": "clients",
|
||||
"career_barriers": ["offers"],
|
||||
"desired_outcomes": ["role"],
|
||||
"experience_level": "5+",
|
||||
"target_milestone": null,
|
||||
"venture_industry": null,
|
||||
"current_situation": "employed",
|
||||
"weekly_time_commitment": "steady"
|
||||
},
|
||||
"qx_estimate": 100,
|
||||
"completed_at": "2026-07-12T21:31:42.957Z",
|
||||
"access_choice": "trial",
|
||||
"schema_version": 3
|
||||
}
|
||||
}
|
||||
239
scripts/home-insight-snapshot.test.ts
Normal file
239
scripts/home-insight-snapshot.test.ts
Normal 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");
|
||||
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");
|
||||
31
scripts/interview-user-identity.test.ts
Normal file
31
scripts/interview-user-identity.test.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { interviewService } from "../src/services/product-service-clients.js";
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
const requests: Array<{ url: string; headers: Headers; body: string }> = [];
|
||||
|
||||
globalThis.fetch = async (input, init) => {
|
||||
const request = new Request(input, init);
|
||||
requests.push({
|
||||
url: request.url,
|
||||
headers: request.headers,
|
||||
body: await request.text(),
|
||||
});
|
||||
return new Response("{}", { status: 200, headers: { "content-type": "application/json" } });
|
||||
};
|
||||
|
||||
try {
|
||||
await interviewService.configure({ user_id: "user_from_payload" }, "user_canonical");
|
||||
await interviewService.preview({ user_id: "user_from_payload" }, "user_canonical");
|
||||
await interviewService.editQuestions({ session_id: "session-1", questions: ["q1"] }, "user_canonical");
|
||||
await interviewService.approve("session-1", "user_canonical");
|
||||
|
||||
assert.equal(requests.length, 4);
|
||||
for (const request of requests) {
|
||||
assert.equal(request.headers.get("x-growqr-user"), "user_canonical");
|
||||
assert.match(request.headers.get("authorization") ?? "", /^Bearer /);
|
||||
}
|
||||
console.log("interview user identity forwarding: all assertions passed");
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
@@ -1,29 +1,5 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { extractQscoreSignals } from "../src/events/projectors/qscore-projector.js";
|
||||
import { buildMatchmakingGatewayEvent } from "../src/services/matchmaking-events.js";
|
||||
import type { GrowEventRow } from "../src/db/schema.js";
|
||||
|
||||
function event(overrides: Partial<GrowEventRow> & { type: string; payload: Record<string, unknown> }): GrowEventRow {
|
||||
return {
|
||||
id: `event-${overrides.type}`,
|
||||
userId: "user_test",
|
||||
orgId: null,
|
||||
source: "matchmaking-v2",
|
||||
type: overrides.type,
|
||||
category: "service",
|
||||
occurredAt: new Date("2026-07-09T00:00:00.000Z"),
|
||||
receivedAt: new Date("2026-07-09T00:00:01.000Z"),
|
||||
mission: overrides.mission ?? null,
|
||||
subject: overrides.subject ?? null,
|
||||
correlation: overrides.correlation ?? null,
|
||||
payload: overrides.payload,
|
||||
raw: {},
|
||||
dedupeKey: null,
|
||||
processingStatus: "pending",
|
||||
processingError: null,
|
||||
processedAt: null,
|
||||
};
|
||||
}
|
||||
|
||||
const saved = buildMatchmakingGatewayEvent({
|
||||
userId: "user_test",
|
||||
@@ -62,31 +38,6 @@ assert.equal(saved.payload.curatorTaskId, "task_123");
|
||||
assert.equal(saved.payload.opportunityId, "job_123");
|
||||
assert.equal(saved.payload.action, "mark_saved");
|
||||
|
||||
const generatedSignals = extractQscoreSignals(event({
|
||||
type: "matchmaking.matches.generated",
|
||||
payload: {
|
||||
action: "run_search",
|
||||
matchCount: 6,
|
||||
scanned: 120,
|
||||
result: { status: "completed" },
|
||||
},
|
||||
}));
|
||||
assert.ok(
|
||||
generatedSignals.some((signal) => signal.signalId === "matching.match_rate" && signal.score > 0),
|
||||
"generated matches should produce a match rate signal from match counts",
|
||||
);
|
||||
|
||||
const savedSignals = extractQscoreSignals(event({
|
||||
type: "matchmaking.match.saved",
|
||||
payload: {
|
||||
action: "mark_saved",
|
||||
opportunityId: "job_123",
|
||||
curatorTaskId: "task_123",
|
||||
result: { status: "completed" },
|
||||
},
|
||||
}));
|
||||
assert.equal(savedSignals.length, 0, "saved opportunities no longer emit a legacy signal");
|
||||
|
||||
const reviewed = buildMatchmakingGatewayEvent({
|
||||
userId: "user_test",
|
||||
action: "record_feedback",
|
||||
@@ -98,20 +49,5 @@ const reviewed = buildMatchmakingGatewayEvent({
|
||||
});
|
||||
assert.equal(reviewed.type, "matchmaking.matches.reviewed");
|
||||
|
||||
const appliedSignals = extractQscoreSignals(event({
|
||||
type: "matchmaking.match.applied",
|
||||
payload: {
|
||||
action: "mark_applied",
|
||||
opportunityId: "job_123",
|
||||
curatorTaskId: "task_123",
|
||||
applications_submitted: 1,
|
||||
result: { status: "completed" },
|
||||
},
|
||||
}));
|
||||
assert.ok(
|
||||
appliedSignals.some((signal) => signal.signalId === "matching.applications_submitted" && signal.score > 0),
|
||||
"applied opportunities should produce an applications submitted signal",
|
||||
);
|
||||
|
||||
console.log("matchmaking-events tests passed");
|
||||
process.exit(0);
|
||||
|
||||
194
scripts/onboarding-bulk-reset.ts
Executable file
194
scripts/onboarding-bulk-reset.ts
Executable file
@@ -0,0 +1,194 @@
|
||||
#!/usr/bin/env tsx
|
||||
/**
|
||||
* Staging-only bulk onboarding reset CLI.
|
||||
*
|
||||
* Resets onboarding for EVERY user in the backend `users` table (the
|
||||
* Clerk-mirrored enrollment source) in two phases:
|
||||
*
|
||||
* Phase 1 — user-service preferences reset (A2A).
|
||||
* Calls POST /api/v1/users/onboarding-reset on user-service with the A2A
|
||||
* key. This resets preferences.onboarding to the default in-progress v3
|
||||
* doc, preserving all unrelated preference keys. It targets users by
|
||||
* clerk_id WITHOUT impersonating a Clerk session. A 404 means the user has
|
||||
* no user-service profile (backend-only user) — a documented skip that
|
||||
* never blocks Phase 2.
|
||||
*
|
||||
* Phase 2 — backend completion ledger reset (direct, idempotent).
|
||||
* Calls resetOnboardingLedger(userId) for every backend user whose Phase 1
|
||||
* succeeded OR was skipped (404 backend-only). A Phase 1 error (non-404
|
||||
* failure) blocks Phase 2 for that user to avoid inconsistent state
|
||||
* (preferences say "completed" but ledger says not). This deletes only
|
||||
* completion-type ledger rows (dotted AND underscore aliases) plus
|
||||
* completion-bearing snapshots, preserving intermediate snapshots,
|
||||
* missions, curator context, and qscore baselines.
|
||||
*
|
||||
* GUARDS — all three must hold or the script aborts before any network call:
|
||||
* 1. NODE_ENV=staging (refuses production/development)
|
||||
* 2. ONBOARDING_BULK_RESET_ALLOWED=true (explicit opt-in flag)
|
||||
* 3. --confirm on the CLI (typed acknowledgement)
|
||||
*
|
||||
* Usage:
|
||||
* NODE_ENV=staging ONBOARDING_BULK_RESET_ALLOWED=true \
|
||||
* npx tsx scripts/onboarding-bulk-reset.ts --confirm
|
||||
*
|
||||
* Output: aggregate-only audit counts (no per-user PII) and a summary.
|
||||
*
|
||||
* This script is INTENTIONALLY not wired to any HTTP surface. It is a
|
||||
* staging-only operations tool; bulk reset must never be exposed as a public
|
||||
* endpoint. Per-user reset is the authenticated DELETE /users/onboarding route.
|
||||
*/
|
||||
|
||||
import { eq, asc } from "drizzle-orm";
|
||||
import { db } from "../src/db/client.js";
|
||||
import { users } from "../src/db/schema.js";
|
||||
import { config } from "../src/config.js";
|
||||
import { log } from "../src/log.js";
|
||||
import { resetOnboardingLedger } from "../src/events/onboarding-ledger.js";
|
||||
|
||||
type PrefOutcome =
|
||||
| { userId: string; ok: true }
|
||||
| { userId: string; ok: false; skipped: true; reason: string; status: number }
|
||||
| { userId: string; ok: false; skipped: false; error: string; status: number };
|
||||
|
||||
type LedgerOutcome =
|
||||
| { userId: string; ok: true; ledgerRowsDeleted: number }
|
||||
| { userId: string; ok: false; error: string };
|
||||
|
||||
function assertStagingGuard(argv: string[]): void {
|
||||
const isStaging = config.nodeEnv === "staging";
|
||||
const allowed = process.env.ONBOARDING_BULK_RESET_ALLOWED === "true";
|
||||
const confirmed = argv.includes("--confirm");
|
||||
|
||||
const failures: string[] = [];
|
||||
if (!isStaging) failures.push(`NODE_ENV must be "staging" (got "${config.nodeEnv}")`);
|
||||
if (!allowed) failures.push("ONBOARDING_BULK_RESET_ALLOWED must be set to \"true\"");
|
||||
if (!confirmed) failures.push("--confirm argument is required");
|
||||
|
||||
if (failures.length > 0) {
|
||||
console.error("onboarding-bulk-reset: ABORTED — guard checks failed:");
|
||||
for (const f of failures) console.error(` - ${f}`);
|
||||
console.error("");
|
||||
console.error("This script is staging-only and will reset onboarding for EVERY user.");
|
||||
console.error("Re-run with all guards satisfied to proceed.");
|
||||
process.exit(2);
|
||||
}
|
||||
}
|
||||
|
||||
function userServiceUrl(): string {
|
||||
return config.userServiceUrl.replace(/\/$/, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Phase 1: reset preferences.onboarding via the user-service A2A endpoint.
|
||||
* A 404 means the user has no user-service profile (backend-only) — a clean
|
||||
* skip that does NOT block the backend ledger reset.
|
||||
*/
|
||||
async function resetPreferences(clerkId: string, a2aKey: string): Promise<PrefOutcome> {
|
||||
const res = await fetch(`${userServiceUrl()}/api/v1/users/onboarding-reset`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
authorization: `Bearer ${a2aKey}`,
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ clerk_id: clerkId }),
|
||||
});
|
||||
if (res.status === 404) {
|
||||
return { userId: clerkId, ok: false, skipped: true, reason: "no user-service profile (backend-only)", status: 404 };
|
||||
}
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => "");
|
||||
return { userId: clerkId, ok: false, skipped: false, error: text || res.statusText, status: res.status };
|
||||
}
|
||||
return { userId: clerkId, ok: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Phase 2: directly reset the backend completion ledger for a user.
|
||||
* Always runs after Phase 1 (success or skip) — backend-only users still need
|
||||
* their ledger cleared. Idempotent.
|
||||
*/
|
||||
async function resetLedger(userId: string): Promise<LedgerOutcome> {
|
||||
try {
|
||||
const ledgerRowsDeleted = await resetOnboardingLedger(userId);
|
||||
return { userId, ok: true, ledgerRowsDeleted };
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return { userId, ok: false, error: message };
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
assertStagingGuard(process.argv.slice(2));
|
||||
|
||||
const a2aKey = config.a2aAllowedKey;
|
||||
if (!a2aKey) {
|
||||
console.error("onboarding-bulk-reset: ABORTED — A2A_ALLOWED_KEY is not configured");
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const allUsers = await db.select({ id: users.id }).from(users).orderBy(asc(users.id));
|
||||
const total = allUsers.length;
|
||||
|
||||
console.log(`onboarding-bulk-reset: targeting ${total} user(s)`);
|
||||
console.log(`onboarding-bulk-reset: NODE_ENV=${config.nodeEnv}, guard=on`);
|
||||
console.log(`onboarding-bulk-reset: user-service=${userServiceUrl()}`);
|
||||
|
||||
let prefOk = 0;
|
||||
let prefSkipped = 0;
|
||||
let prefErrors = 0;
|
||||
|
||||
let ledgerOk = 0;
|
||||
let ledgerErrors = 0;
|
||||
let ledgerTotal = 0;
|
||||
|
||||
let ledgerBlocked = 0;
|
||||
|
||||
for (const u of allUsers) {
|
||||
// Phase 1: user-service preferences reset (A2A).
|
||||
const pref = await resetPreferences(u.id, a2aKey);
|
||||
if (pref.ok) {
|
||||
prefOk += 1;
|
||||
} else if (pref.skipped) {
|
||||
prefSkipped += 1;
|
||||
} else {
|
||||
prefErrors += 1;
|
||||
}
|
||||
|
||||
// Phase 2: backend ledger reset — ONLY after Phase 1 succeeds or is
|
||||
// skipped (404 / backend-only). A pref error (non-404 failure) leaves
|
||||
// preferences.onboarding untouched, so clearing the ledger here would
|
||||
// create an inconsistent state (prefs say "completed", ledger says not).
|
||||
// Skip the ledger for errored users so an operator can retry after fix.
|
||||
if (!pref.ok && !pref.skipped) {
|
||||
ledgerBlocked += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const ledger = await resetLedger(u.id);
|
||||
if (ledger.ok) {
|
||||
ledgerOk += 1;
|
||||
ledgerTotal += ledger.ledgerRowsDeleted;
|
||||
} else {
|
||||
ledgerErrors += 1;
|
||||
}
|
||||
}
|
||||
|
||||
console.log("");
|
||||
console.log("onboarding-bulk-reset: summary (aggregate only — no per-user PII)");
|
||||
console.log(` total users: ${total}`);
|
||||
console.log(` preferences reset: ${prefOk} ok, ${prefSkipped} skipped (backend-only), ${prefErrors} error(s)`);
|
||||
console.log(` ledger reset: ${ledgerOk} ok, ${ledgerErrors} error(s), ${ledgerBlocked} blocked by pref error, ${ledgerTotal} row(s) deleted`);
|
||||
|
||||
const hadErrors = prefErrors > 0 || ledgerErrors > 0;
|
||||
|
||||
log.info(
|
||||
{ total, prefOk, prefSkipped, prefErrors, ledgerOk, ledgerErrors, ledgerBlocked, ledgerTotal },
|
||||
"onboarding bulk reset complete",
|
||||
);
|
||||
process.exit(hadErrors ? 1 : 0);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error("onboarding-bulk-reset: fatal", err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -70,5 +70,45 @@ assert.equal(
|
||||
"invalid completion timestamps should normalize to a valid ISO timestamp",
|
||||
);
|
||||
|
||||
// ── Regression: underscore completion aliases must be valid (reset deletes them) ──
|
||||
// The reset only reopens the gate if EVERY alias form that isValidOnboardingLedgerEvent
|
||||
// accepts is also in the reset delete scope. Pin both sides agree per alias.
|
||||
for (const underscore of ["onboarding_completed", "user_onboarding_completed", "profile_onboarding_completed"]) {
|
||||
const dotted = normalizeOnboardingEventType(underscore);
|
||||
assert.equal(
|
||||
isValidOnboardingLedgerEvent({ type: underscore, payload: {} }),
|
||||
true,
|
||||
`${underscore} should satisfy onboarding status (normalized to ${dotted})`,
|
||||
);
|
||||
}
|
||||
|
||||
// ── Regression: completedAtFromOnboardingPayload paths mirrored by reset SQL ──
|
||||
// resetOnboardingLedger's jsonb predicate checks top-level completed_at/completedAt
|
||||
// and onboarding.completed_at/completedAt. Pin that the JS helper (used for status
|
||||
// validity) agrees a snapshot carrying any of these is a completion snapshot.
|
||||
for (const [label, payload] of [
|
||||
["top-level completed_at", { completed_at: now }],
|
||||
["top-level completedAt", { completedAt: now }],
|
||||
["onboarding.completed_at", { onboarding: { completed_at: now } }],
|
||||
["onboarding.completedAt", { onboarding: { completedAt: now } }],
|
||||
] as const) {
|
||||
assert.equal(
|
||||
completedAtFromOnboardingPayload(payload),
|
||||
now,
|
||||
`completion timestamp extracted from ${label}`,
|
||||
);
|
||||
assert.equal(
|
||||
isValidOnboardingLedgerEvent({ type: "onboarding.snapshot.saved", payload }),
|
||||
true,
|
||||
`snapshot with ${label} is a completion snapshot`,
|
||||
);
|
||||
}
|
||||
|
||||
// ── Regression: intermediate snapshots (no completion marker) stay invalid ──
|
||||
assert.equal(
|
||||
completedAtFromOnboardingPayload({ onboarding: { current_step: 2 } }),
|
||||
undefined,
|
||||
"intermediate snapshot has no completion timestamp",
|
||||
);
|
||||
console.log("onboarding-ledger tests passed");
|
||||
process.exit(0);
|
||||
|
||||
160
scripts/onboarding-preferences.test.ts
Normal file
160
scripts/onboarding-preferences.test.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
DEFAULT_ONBOARDING_DATA,
|
||||
defaultOnboardingData,
|
||||
extractOnboardingData,
|
||||
assertOnboardingRevision,
|
||||
deriveOnboardingPayload,
|
||||
mergeOnboardingPatch,
|
||||
OnboardingRevisionConflict,
|
||||
} from "../src/events/onboarding-ledger.js";
|
||||
|
||||
/**
|
||||
* Focused tests for the onboarding preference helpers (pure — no DB/network).
|
||||
* Covers: default v3 shape, revision conflict, partial update merge,
|
||||
* status/completed_at consistency + payload derivation, and unrelated
|
||||
* preference preservation. Matches the scripts/X.test.ts convention
|
||||
* (node:assert over pure functions).
|
||||
*/
|
||||
|
||||
// ── 1. default v3 response ───────────────────────────────────────────────────
|
||||
{
|
||||
const def = defaultOnboardingData();
|
||||
assert.equal(def.schema_version, 3, "default is v3");
|
||||
assert.equal(def.revision, 0);
|
||||
assert.equal(def.status, "in_progress");
|
||||
assert.equal(def.access_choice, null);
|
||||
assert.equal(def.qx_estimate, null);
|
||||
assert.equal(def.completed_at, null);
|
||||
assert.equal(def.consent.privacy_accepted, false);
|
||||
assert.equal(def.progress.stage, "consent");
|
||||
// DEFAULT constant must match the factory.
|
||||
assert.deepEqual(def, DEFAULT_ONBOARDING_DATA);
|
||||
// Factory returns a fresh clone, not the shared constant.
|
||||
assert.notEqual(def, DEFAULT_ONBOARDING_DATA);
|
||||
}
|
||||
|
||||
// extractOnboardingData returns the default when nothing is stored.
|
||||
{
|
||||
const empty = extractOnboardingData(undefined);
|
||||
assert.equal(empty.schema_version, 3);
|
||||
assert.equal(empty.revision, 0);
|
||||
const fromNonObject = extractOnboardingData("nonsense");
|
||||
assert.equal(fromNonObject.status, "in_progress");
|
||||
}
|
||||
|
||||
// ── 2. revision conflict (optimistic concurrency) ───────────────────────────
|
||||
{
|
||||
const current = extractOnboardingData({ revision: 5 });
|
||||
// Matching revision is accepted (no throw).
|
||||
assert.doesNotThrow(() => assertOnboardingRevision(5, current));
|
||||
|
||||
// Mismatched revision throws the typed conflict.
|
||||
assert.throws(
|
||||
() => assertOnboardingRevision(4, current),
|
||||
(err) => err instanceof OnboardingRevisionConflict && err.expected === 4 && err.actual === 5,
|
||||
);
|
||||
}
|
||||
|
||||
// ── 3. partial update merge (revision bump + progress stamp) ────────────────
|
||||
{
|
||||
const stored = extractOnboardingData({ revision: 2 });
|
||||
const incoming = { ...stored, revision: 2, profile: { ...stored.profile, mode: "founder" } };
|
||||
const merged = mergeOnboardingPatch({ onboarding: stored }, incoming);
|
||||
const next = extractOnboardingData(merged.onboarding);
|
||||
|
||||
assert.equal(next.revision, 3, "revision bumps by one from the stored value");
|
||||
assert.equal(next.profile.mode, "founder", "incoming profile change is applied");
|
||||
assert.ok(next.progress.updated_at, "progress.updated_at is stamped on save");
|
||||
}
|
||||
|
||||
// ── 4. status/completed_at consistency + payload derivation ─────────────────
|
||||
{
|
||||
const base = defaultOnboardingData();
|
||||
const completed = {
|
||||
...base,
|
||||
revision: 0,
|
||||
status: "completed" as const,
|
||||
profile: { ...base.profile, intent: "land-a-role", mode: "student", icp: "intern", question_branch: "student" },
|
||||
responses: {
|
||||
...base.responses,
|
||||
career_barriers: ["no-network"],
|
||||
desired_outcomes: ["interviews", "offer"],
|
||||
target_role: "Product Intern",
|
||||
target_field: "technical",
|
||||
weekly_time_commitment: "5-10",
|
||||
experience_level: "0-2",
|
||||
work_context: "audience",
|
||||
},
|
||||
// deliberately omit completed_at; merge must stamp it.
|
||||
};
|
||||
|
||||
const merged = mergeOnboardingPatch({}, completed);
|
||||
const next = extractOnboardingData(merged.onboarding);
|
||||
assert.equal(next.status, "completed");
|
||||
assert.ok(next.completed_at, "completed status without completed_at is stamped");
|
||||
|
||||
// Payload is a faithful projection — no invented completion math.
|
||||
const payload = deriveOnboardingPayload(next);
|
||||
assert.equal(payload.schema_version, 1);
|
||||
assert.equal(payload.source_revision, next.revision);
|
||||
assert.equal(payload.mode, "student");
|
||||
assert.equal(payload.onboarding_icp, "intern");
|
||||
assert.equal(payload.target_role, "Product Intern");
|
||||
assert.equal(payload.target_field, "technical");
|
||||
assert.deepEqual(payload.goals, ["interviews", "offer"]);
|
||||
assert.deepEqual(payload.barriers, ["no-network"]);
|
||||
assert.equal(payload.weekly_time_commitment, "5-10");
|
||||
assert.equal(payload.experience_context, "0-2 · audience");
|
||||
}
|
||||
|
||||
// completed_at is cleared when regressing to in_progress with an explicit null.
|
||||
{
|
||||
const completed = { ...defaultOnboardingData(), status: "completed" as const, completed_at: "2026-07-10T00:00:00.000Z" };
|
||||
const regressed = { ...completed, status: "in_progress" as const, completed_at: null };
|
||||
const merged = mergeOnboardingPatch({}, regressed);
|
||||
const next = extractOnboardingData(merged.onboarding);
|
||||
assert.equal(next.status, "in_progress");
|
||||
assert.equal(next.completed_at, null, "stale completed_at is cleared on regression");
|
||||
}
|
||||
|
||||
// ── 5. unrelated preference preservation ────────────────────────────────────
|
||||
{
|
||||
const preferences = {
|
||||
onboarding: { revision: 1, status: "in_progress" },
|
||||
interview_preferences: { focus_areas: ["behavioral"] },
|
||||
resume_preferences: { target_title: "Data Scientist" },
|
||||
mission_preferences: { active_goal: "land-offer" },
|
||||
target_roles: ["Product Intern"],
|
||||
target_companies: ["Acme"],
|
||||
};
|
||||
const incoming = { ...extractOnboardingData(preferences.onboarding), revision: 1, profile: { intent: "x", mode: "student", icp: "intern", question_branch: "student" } };
|
||||
const merged = mergeOnboardingPatch(preferences, incoming);
|
||||
|
||||
// The onboarding blob is updated.
|
||||
assert.equal(extractOnboardingData(merged.onboarding).revision, 2);
|
||||
// Every unrelated preference key is preserved verbatim.
|
||||
assert.deepEqual(merged.interview_preferences, { focus_areas: ["behavioral"] });
|
||||
assert.deepEqual(merged.resume_preferences, { target_title: "Data Scientist" });
|
||||
assert.deepEqual(merged.mission_preferences, { active_goal: "land-offer" });
|
||||
assert.deepEqual(merged.target_roles, ["Product Intern"]);
|
||||
assert.deepEqual(merged.target_companies, ["Acme"]);
|
||||
}
|
||||
|
||||
// ── 6. access_choice persistence (trial | full | null) ───────────────────────
|
||||
{
|
||||
const base = defaultOnboardingData();
|
||||
const trial = { ...base, revision: 0, access_choice: "trial" as const };
|
||||
const mergedTrial = mergeOnboardingPatch({}, trial);
|
||||
assert.equal(extractOnboardingData(mergedTrial.onboarding).access_choice, "trial");
|
||||
|
||||
const full = { ...base, revision: 0, access_choice: "full" as const };
|
||||
const mergedFull = mergeOnboardingPatch({}, full);
|
||||
assert.equal(extractOnboardingData(mergedFull.onboarding).access_choice, "full");
|
||||
|
||||
// Garbage access_choice is rejected back to null by extraction.
|
||||
const garbage = extractOnboardingData({ access_choice: "pro" });
|
||||
assert.equal(garbage.access_choice, null);
|
||||
}
|
||||
|
||||
console.log("onboarding-preferences: all assertions passed");
|
||||
176
scripts/onboarding-reset.test.ts
Normal file
176
scripts/onboarding-reset.test.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, join } from "node:path";
|
||||
import {
|
||||
COMPLETION_EVENT_TYPES,
|
||||
COMPLETION_EVENT_TYPE_ALIASES,
|
||||
SNAPSHOT_EVENT_TYPE_ALIASES,
|
||||
resetOnboardingPreferences,
|
||||
defaultOnboardingData,
|
||||
extractOnboardingData,
|
||||
ONBOARDING_LEDGER_QUERY_TYPES,
|
||||
} from "../src/events/onboarding-ledger.js";
|
||||
|
||||
/**
|
||||
* Focused tests for onboarding reset helpers (pure — no DB/network).
|
||||
* Covers: per-user reset state (preferences reset to default, unrelated keys
|
||||
* preserved), ledger type scope (only completion types deleted, snapshots
|
||||
* preserved), and bulk guard behavior (assertStagingGuard invariants).
|
||||
*
|
||||
* The async resetOnboardingLedger is exercised in integration; here we assert
|
||||
* its type-scope contract via the exported COMPLETION_EVENT_TYPES set, which is
|
||||
* exactly the WHERE clause it builds.
|
||||
*/
|
||||
|
||||
// ── 1. resetOnboardingPreferences: resets onboarding to default v3 ──────────
|
||||
{
|
||||
const preferences = {
|
||||
onboarding: {
|
||||
schema_version: 3,
|
||||
revision: 5,
|
||||
status: "completed",
|
||||
completed_at: "2026-07-10T00:00:00.000Z",
|
||||
progress: { stage: "done", branch_index: 3, updated_at: "2026-07-10T00:00:00.000Z" },
|
||||
consent: { privacy_accepted: true, accepted_at: "2026-07-09T00:00:00.000Z", terms_version: "2026-07" },
|
||||
profile: { intent: "land-a-role", mode: "student", icp: "intern", question_branch: "student" },
|
||||
responses: { career_barriers: ["x"], desired_outcomes: ["y"], current_situation: null, target_milestone: null, weekly_time_commitment: "5-10", target_role: "Intern", target_field: "tech", venture_industry: null, experience_level: "0-2", work_context: "audience" },
|
||||
import: { method: "manual", status: "not_started", resume_id: null, resume_filename: null, resume_summary: null, linkedin_profile_id: null, linkedin_url: null },
|
||||
access_choice: "trial",
|
||||
qx_estimate: 42,
|
||||
},
|
||||
interview_preferences: { focus_areas: ["behavioral"] },
|
||||
resume_preferences: { target_title: "Data Scientist" },
|
||||
target_roles: ["Product Intern"],
|
||||
};
|
||||
|
||||
const reset = resetOnboardingPreferences(preferences);
|
||||
const next = extractOnboardingData(reset.onboarding);
|
||||
|
||||
// Onboarding blob is back to defaults.
|
||||
assert.equal(next.schema_version, 3);
|
||||
assert.equal(next.revision, 0, "revision resets to 0");
|
||||
assert.equal(next.status, "in_progress", "status reverts to in_progress");
|
||||
assert.equal(next.completed_at, null, "completed_at is cleared");
|
||||
assert.equal(next.progress.stage, "consent", "progress stage resets to consent");
|
||||
assert.equal(next.access_choice, null);
|
||||
assert.equal(next.qx_estimate, null);
|
||||
assert.deepEqual(next, defaultOnboardingData());
|
||||
|
||||
// Unrelated preference keys are preserved verbatim.
|
||||
assert.deepEqual(reset.interview_preferences, { focus_areas: ["behavioral"] });
|
||||
assert.deepEqual(reset.resume_preferences, { target_title: "Data Scientist" });
|
||||
assert.deepEqual(reset.target_roles, ["Product Intern"]);
|
||||
}
|
||||
|
||||
// ── 2. resetOnboardingPreferences: idempotent on already-default prefs ─────
|
||||
{
|
||||
const empty = {};
|
||||
const reset = resetOnboardingPreferences(empty);
|
||||
assert.deepEqual(reset.onboarding, defaultOnboardingData());
|
||||
// Original object is not mutated.
|
||||
assert.equal(Object.keys(empty).length, 0, "original preferences not mutated");
|
||||
}
|
||||
// ── 3. Ledger reset scope: completion aliases (both forms) + completion snapshots
|
||||
// resetOnboardingLedger builds: DELETE FROM grow_events WHERE userId = ? AND (
|
||||
// type IN (COMPLETION_EVENT_TYPE_ALIASES)
|
||||
// OR (type IN (SNAPSHOT_EVENT_TYPE_ALIASES) AND payload-has-completion)
|
||||
// ). This pins the contract: (a) all completion aliases — dotted AND underscore —
|
||||
// are deleted so a legacy underscore completion row can't keep the gate shut;
|
||||
// (b) snapshot types are NOT blanket completion types (only completion-bearing
|
||||
// snapshots are deleted via the jsonb predicate); (c) intermediate snapshots are
|
||||
// preserved because they never satisfy the completion predicate.
|
||||
{
|
||||
// (a) Completion aliases cover BOTH dotted and underscore forms — 6 total.
|
||||
assert.equal(COMPLETION_EVENT_TYPE_ALIASES.length, 6, "3 dotted + 3 underscore completion aliases");
|
||||
for (const dotted of Object.keys(COMPLETION_EVENT_TYPES)) {
|
||||
const underscore = dotted.replaceAll(".", "_");
|
||||
assert.ok(COMPLETION_EVENT_TYPE_ALIASES.includes(dotted), `${dotted} in reset scope`);
|
||||
assert.ok(COMPLETION_EVENT_TYPE_ALIASES.includes(underscore), `${underscore} alias in reset scope`);
|
||||
}
|
||||
|
||||
// (b) Snapshot types are handled in a SEPARATE phase, never as completion types.
|
||||
assert.equal(COMPLETION_EVENT_TYPES["onboarding.snapshot.saved"], undefined,
|
||||
"snapshot must never be a blanket completion type");
|
||||
assert.equal(SNAPSHOT_EVENT_TYPE_ALIASES.includes("onboarding.snapshot.saved"), true,
|
||||
"dotted snapshot alias is in the snapshot reset phase");
|
||||
assert.equal(SNAPSHOT_EVENT_TYPE_ALIASES.includes("onboarding_snapshot_saved"), true,
|
||||
"underscore snapshot alias is in the snapshot reset phase");
|
||||
|
||||
// (c) The reset completion set is a strict subset of the status-query list:
|
||||
// status reads ALL aliases + snapshots; reset deletes only completions and
|
||||
// completion-bearing snapshots, preserving intermediate saves.
|
||||
for (const alias of COMPLETION_EVENT_TYPE_ALIASES) {
|
||||
assert.ok((ONBOARDING_LEDGER_QUERY_TYPES as readonly string[]).includes(alias),
|
||||
`${alias} is also queryable for status`);
|
||||
}
|
||||
}
|
||||
// ── 4. Bulk guard behavior: real spawn of assertStagingGuard ───────────────
|
||||
// Spawns the actual CLI with controlled env and asserts exit code 2 (abort)
|
||||
// for each independent failure leg. We only assert abort cases — the
|
||||
// all-flags-pass case would proceed past the guard and hit the network/DB.
|
||||
{
|
||||
const script = "scripts/onboarding-bulk-reset.ts";
|
||||
|
||||
// Leg 1: production env (even with all other flags) must abort.
|
||||
const prod = spawnSync("npx", ["tsx", script, "--confirm"], {
|
||||
env: { ...process.env, NODE_ENV: "production", ONBOARDING_BULK_RESET_ALLOWED: "true" },
|
||||
encoding: "utf8",
|
||||
});
|
||||
assert.equal(prod.status, 2, "production must abort even with all flags");
|
||||
assert.match(prod.stderr, /NODE_ENV must be "staging"/, "production abort names the env failure");
|
||||
|
||||
// Leg 2: staging + allowed but missing --confirm must abort.
|
||||
const noConfirm = spawnSync("npx", ["tsx", script], {
|
||||
env: { ...process.env, NODE_ENV: "staging", ONBOARDING_BULK_RESET_ALLOWED: "true" },
|
||||
encoding: "utf8",
|
||||
});
|
||||
assert.equal(noConfirm.status, 2, "staging without --confirm must abort");
|
||||
assert.match(noConfirm.stderr, /--confirm/, "missing-confirm abort names the confirm failure");
|
||||
|
||||
// Leg 3: staging + confirm but no opt-in flag must abort.
|
||||
const noFlag = spawnSync("npx", ["tsx", script, "--confirm"], {
|
||||
env: { ...process.env, NODE_ENV: "staging", ONBOARDING_BULK_RESET_ALLOWED: "false" },
|
||||
encoding: "utf8",
|
||||
});
|
||||
assert.equal(noFlag.status, 2, "staging without opt-in flag must abort");
|
||||
assert.match(noFlag.stderr, /ONBOARDING_BULK_RESET_ALLOWED/, "missing-flag abort names the flag failure");
|
||||
}
|
||||
|
||||
// ── 5. Bulk CLI structure: A2A endpoint + Phase 2 gating (source read) ──────
|
||||
// The CLI's network/DB behavior is exercised in integration. Here we assert
|
||||
// the structural contract by reading the script source directly: (a) it calls
|
||||
// the user-service A2A onboarding-reset endpoint (not the backend DELETE
|
||||
// route), (b) it authenticates with a2aAllowedKey (not serviceToken), (c) it
|
||||
// treats 404 as a skip, and (d) it gates the backend ledger reset on Phase 1
|
||||
// success-or-skip so a pref error never leaves inconsistent state.
|
||||
{
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const src = readFileSync(join(here, "onboarding-bulk-reset.ts"), "utf8");
|
||||
|
||||
// (a) Calls the user-service A2A onboarding-reset endpoint by path.
|
||||
assert.match(src, /\/api\/v1\/users\/onboarding-reset/, "CLI must call the user-service A2A reset endpoint");
|
||||
// Must NOT call the backend DELETE /users/onboarding route (old impersonation path).
|
||||
assert.doesNotMatch(src, /method:\s*["']DELETE["']/, "CLI must not use the backend DELETE route");
|
||||
|
||||
// (b) Authenticates with the A2A key, not the per-user service token.
|
||||
assert.match(src, /a2aAllowedKey/, "CLI must use config.a2aAllowedKey for A2A auth");
|
||||
assert.doesNotMatch(src, /serviceToken/, "CLI must not reference serviceToken (per-user impersonation path)");
|
||||
|
||||
// (c) Treats a 404 from user-service as a documented skip (backend-only user).
|
||||
assert.match(src, /status === 404/, "CLI must detect 404 from user-service");
|
||||
assert.match(src, /backend-only/, "CLI must document 404 as backend-only skip");
|
||||
|
||||
// (d) Phase 2 ledger reset is gated on Phase 1 success-or-skip.
|
||||
assert.match(src, /!pref\.ok && !pref\.skipped/, "CLI must skip ledger when pref errored (not ok, not skipped)");
|
||||
assert.match(src, /ledgerBlocked/, "CLI must count pref-blocked ledger skips");
|
||||
|
||||
// (e) Calls resetOnboardingLedger directly (not via HTTP).
|
||||
assert.match(src, /import \{[^}]*resetOnboardingLedger[^}]*\} from/, "CLI must import resetOnboardingLedger directly");
|
||||
|
||||
// (f) Aggregate-only output — no per-user email/PII in the loop.
|
||||
assert.doesNotMatch(src, /u\.email/, "CLI must not emit per-user email (PII)");
|
||||
}
|
||||
console.log("onboarding-reset tests passed");
|
||||
process.exit(0);
|
||||
94
scripts/onboarding-rev10-read.test.ts
Normal file
94
scripts/onboarding-rev10-read.test.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { serializeOnboardingState } from "../src/services/onboarding-serialization.js";
|
||||
|
||||
type StoredFixture = { onboarding: Record<string, unknown> };
|
||||
const fixturePath = fileURLToPath(new URL("./fixtures/onboarding-rev10.json", import.meta.url));
|
||||
const fixture = JSON.parse(readFileSync(fixturePath, "utf8")) as StoredFixture;
|
||||
const response = serializeOnboardingState("user_3EX1LG3gBk3KY6kfD9PiTkWubs4", fixture.onboarding);
|
||||
|
||||
assert.equal(response.id, "user_3EX1LG3gBk3KY6kfD9PiTkWubs4");
|
||||
assert.equal(response.revision, 10);
|
||||
assert.equal(response.data.revision, 10);
|
||||
assert.equal(response.data.status, "completed");
|
||||
assert.equal(response.data.profile.icp, "experienced");
|
||||
assert.equal(response.data.access_choice, "trial");
|
||||
assert.equal(response.data.completed_at, "2026-07-12T21:31:42.957Z");
|
||||
assert.equal(response.updatedAt, "2026-07-12T21:33:01.914Z");
|
||||
assert.deepEqual(response.payload, {
|
||||
schema_version: 1,
|
||||
source_revision: 10,
|
||||
primary_intent: "grow",
|
||||
mode: "individual",
|
||||
question_branch: "professional",
|
||||
onboarding_icp: "experienced",
|
||||
curator_registry_icp: null,
|
||||
target_role: "Senior Software Engineer",
|
||||
target_field: "technical",
|
||||
experience_context: "5+ · clients",
|
||||
goals: ["role"],
|
||||
barriers: ["offers"],
|
||||
priority: "role",
|
||||
weekly_time_commitment: "steady",
|
||||
});
|
||||
|
||||
const serialized = JSON.parse(JSON.stringify(response));
|
||||
assert.deepEqual(serialized, response, "GET response must be JSON serializable without losing rev10 state");
|
||||
|
||||
const compose = readFileSync(fileURLToPath(new URL("../docker-compose.override.yml", import.meta.url)), "utf8");
|
||||
assert.match(
|
||||
compose,
|
||||
/user-service-staging:\s+external:\s+true\s+name:\s+user-service-staging_default/s,
|
||||
"backend must join the canonical user-service staging network",
|
||||
);
|
||||
assert.doesNotMatch(compose, /name:\s+growqr-app_growqr-net/, "obsolete substitute user network must not be configured");
|
||||
assert.match(compose, /RESUME_SERVICE_URL:\s+http:\/\/growqr_resume_api:8000/, "backend must route Resume through canonical Docker DNS");
|
||||
for (const modelKey of ["LLM_MODEL", "GROW_AGENT_MODEL", "CONVERSATION_ACTOR_MODEL"]) {
|
||||
assert.match(compose, new RegExp(`${modelKey}:\\s+kimi-k2\\.6`), `${modelKey} must stay on the verified staging model`);
|
||||
}
|
||||
assert.doesNotMatch(compose, /minimax-m3/, "staging compose must not restore the broken MiniMax model pin");
|
||||
|
||||
const canonicalServiceNetworks = [
|
||||
["interview-service", "interview-service_default"],
|
||||
["roleplay-service", "roleplay-service_default"],
|
||||
["resume-builder-staging", "resume-builder-staging_default"],
|
||||
["course-service", "course-service_default"],
|
||||
["qscore-service-staging", "qscore-service-staging_default"],
|
||||
["matchmaking-v2-staging", "matchmaking-v2_default"],
|
||||
["user-service-staging", "user-service-staging_default"],
|
||||
] as const;
|
||||
|
||||
for (const [key, name] of canonicalServiceNetworks) {
|
||||
assert.match(
|
||||
compose,
|
||||
new RegExp(`${key}:\\s+external:\\s+true\\s+name:\\s+${name.replace(/[.*+?^${}()|[\\]\\]/g, "\\$&")}`, "s"),
|
||||
`backend must join canonical network ${name}`,
|
||||
);
|
||||
}
|
||||
|
||||
for (const obsoleteName of [
|
||||
"resume-builder_default",
|
||||
"courses_service_default",
|
||||
"qscore-service_default",
|
||||
"matchmaking-service_default",
|
||||
"growqr-app_growqr-net",
|
||||
]) {
|
||||
assert.doesNotMatch(compose, new RegExp(`name:\\s+${obsoleteName}`), `obsolete substitute network ${obsoleteName} must not be configured`);
|
||||
}
|
||||
|
||||
|
||||
const stagingCompose = readFileSync(fileURLToPath(new URL("./staging-compose.sh", import.meta.url)), "utf8");
|
||||
assert.match(stagingCompose, /PROJECT=growqr-backend-staging/, "staging wrapper must pin the compose project name");
|
||||
for (const [, name] of canonicalServiceNetworks) {
|
||||
assert.match(stagingCompose, new RegExp(name.replace(/[.*+?^${}()|[\\]\\]/g, "\\$&")), `staging wrapper must verify ${name}`);
|
||||
}
|
||||
|
||||
const packageJson = JSON.parse(readFileSync(fileURLToPath(new URL("../package.json", import.meta.url)), "utf8")) as {
|
||||
scripts?: Record<string, string>;
|
||||
};
|
||||
assert.equal(packageJson.scripts?.["compose:up"], "scripts/staging-compose.sh up");
|
||||
assert.equal(packageJson.scripts?.["compose:down"], "scripts/staging-compose.sh down");
|
||||
assert.equal(packageJson.scripts?.["staging:preflight"], "scripts/staging-compose.sh preflight");
|
||||
assert.equal(packageJson.scripts?.["staging:verify"], "scripts/staging-compose.sh verify");
|
||||
console.log("onboarding-rev10-read: fixture serialization and staging network assertions passed");
|
||||
95
scripts/qscore-raw-event-contract.test.ts
Normal file
95
scripts/qscore-raw-event-contract.test.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { canonicalGrowEventEnvelope, correlateEventWithServiceSession } from "../src/events/record-grow-event.js";
|
||||
import { normalizeGrowEvent } from "../src/events/normalize.js";
|
||||
import {
|
||||
projectedSignalsFromQscoreResponse,
|
||||
QSCORE_GROW_EVENT_DESTINATION,
|
||||
QSCORE_GROW_EVENT_TOPIC,
|
||||
} from "../src/events/qscore-event-outbox.js";
|
||||
|
||||
const input = {
|
||||
id: "evt-real-1",
|
||||
userId: "untrusted-body-user",
|
||||
orgId: "growqr",
|
||||
source: "interview-service",
|
||||
type: "interview.review.completed",
|
||||
category: "service" as const,
|
||||
occurredAt: "2026-07-14T10:00:00.000Z",
|
||||
mission: { instanceId: "mission-1", missionId: "interview-to-offer", stageId: "mock-interview" },
|
||||
subject: { serviceId: "interview", externalId: "session-1" },
|
||||
correlation: { sessionId: "session-1", taskId: "task-1" },
|
||||
payload: {
|
||||
review: {
|
||||
overall_score: 78,
|
||||
rubric_scores: { communication: 82, technical_accuracy: 70 },
|
||||
},
|
||||
},
|
||||
dedupeKey: "interview:session-1:review",
|
||||
};
|
||||
|
||||
const normalized = normalizeGrowEvent(input, { userId: "clerk-user-real" });
|
||||
assert.equal(
|
||||
normalized.type,
|
||||
"interview.feedback.generated",
|
||||
"backend-only consumers may retain staging's legacy event alias",
|
||||
);
|
||||
const canonical = canonicalGrowEventEnvelope(normalized, "clerk-user-real");
|
||||
assert.deepEqual(canonical, {
|
||||
...input,
|
||||
userId: "clerk-user-real",
|
||||
raw: input,
|
||||
});
|
||||
const sessionOnlyInput = { ...input, correlation: { sessionId: "session-1" } };
|
||||
const sessionOnlyNormalized = normalizeGrowEvent(sessionOnlyInput, { userId: "clerk-user-real" });
|
||||
const correlated = correlateEventWithServiceSession(sessionOnlyNormalized, "clerk-user-real", {
|
||||
userId: "clerk-user-real",
|
||||
missionInstanceId: "mission-1",
|
||||
missionId: "interview-to-offer",
|
||||
stageId: "mock-interview",
|
||||
metadata: { task_id: "task-derived" },
|
||||
});
|
||||
const enrichedCanonical = canonicalGrowEventEnvelope(correlated, "clerk-user-real");
|
||||
assert.deepEqual(enrichedCanonical.correlation, {
|
||||
sessionId: "session-1",
|
||||
task_id: "task-derived",
|
||||
missionInstanceId: "mission-1",
|
||||
missionId: "interview-to-offer",
|
||||
stageId: "mock-interview",
|
||||
});
|
||||
assert.deepEqual(enrichedCanonical.raw, sessionOnlyInput, "raw producer correlation must remain unchanged");
|
||||
assert.equal(QSCORE_GROW_EVENT_TOPIC, "qscore.grow_event.raw");
|
||||
assert.equal(QSCORE_GROW_EVENT_DESTINATION, "/v1/events/ingest");
|
||||
|
||||
const projected = projectedSignalsFromQscoreResponse({
|
||||
projected_signals: [
|
||||
{ signal_id: "interview.overall_score", score: 45, present: true, evidence: { eventId: input.id } },
|
||||
{ signal_id: "interview.sessions_completed", evidence: { eventId: input.id } },
|
||||
],
|
||||
});
|
||||
assert.deepEqual(projected, [
|
||||
{ signalId: "interview.overall_score", score: 45, present: true, raw: { eventId: input.id } },
|
||||
{ signalId: "interview.sessions_completed", raw: { eventId: input.id } },
|
||||
]);
|
||||
|
||||
const projectorSource = await readFile(new URL("../src/events/projectors/qscore-projector.ts", import.meta.url), "utf8");
|
||||
for (const forbidden of ["clampScore", "BASELINE_SCORE", "extractResumeSignals", "forwardSignalsToQscoreService"]) {
|
||||
assert.equal(projectorSource.includes(forbidden), false, `projector must not contain ${forbidden}`);
|
||||
}
|
||||
|
||||
const serviceAgentSource = await readFile(new URL("../src/services/service-agents.ts", import.meta.url), "utf8");
|
||||
assert.equal(/q_score\s*:\s*\d/.test(serviceAgentSource), false, "service agents must not inject numeric QScore data");
|
||||
|
||||
const homeSource = await readFile(new URL("../src/home/home-feed.ts", import.meta.url), "utf8");
|
||||
for (const forbidden of ["|| 47", "score - 29", "Math.max(35", "ensureOnboardingBaselineQscore"]) {
|
||||
assert.equal(homeSource.includes(forbidden), false, `Home must not contain synthetic QScore fallback ${forbidden}`);
|
||||
}
|
||||
|
||||
const servicesSource = await readFile(new URL("../src/routes/services.ts", import.meta.url), "utf8");
|
||||
assert.equal(
|
||||
/app\.get\("\/qscore\/current"[\s\S]*?qscore\.review\.opened/.test(servicesSource),
|
||||
false,
|
||||
"polling the current QScore must remain a read and must not emit a synthetic review event",
|
||||
);
|
||||
|
||||
console.log("qscore raw-event ownership contract: ok");
|
||||
37
scripts/resume-gateway.test.ts
Normal file
37
scripts/resume-gateway.test.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
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);
|
||||
const path = new URL(request.url).pathname;
|
||||
if (request.method === "DELETE" && path.endsWith("/api/v1/resumes/resume-1")) {
|
||||
return new Response(null, { status: 204 });
|
||||
}
|
||||
return new Response("<html>preview</html>", {
|
||||
status: 200,
|
||||
headers: { "content-type": "text/html; charset=utf-8" },
|
||||
});
|
||||
}) as typeof fetch;
|
||||
|
||||
const app = serviceRoutes({ skipAuth: true });
|
||||
try {
|
||||
const preview = await app.request("http://backend.test/resume/export/resumes/resume-1/preview");
|
||||
assert.equal(preview.status, 200);
|
||||
assert.equal(await preview.text(), "<html>preview</html>");
|
||||
assert.equal(new URL(requests[0]!.url).pathname, "/api/v1/export/resumes/resume-1/preview");
|
||||
assert.equal(requests[0]!.headers.get("x-growqr-user"), "user_test");
|
||||
assert.match(requests[0]!.headers.get("authorization") ?? "", /^Bearer /);
|
||||
|
||||
const deleted = await app.request("http://backend.test/resume/resumes/resume-1", { method: "DELETE" });
|
||||
assert.equal(deleted.status, 204);
|
||||
assert.equal(await deleted.text(), "");
|
||||
assert.equal(new URL(requests[1]!.url).pathname, "/api/v1/resumes/resume-1");
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
|
||||
console.log("resume gateway proxy tests passed");
|
||||
30
scripts/resume-user-identity.test.ts
Normal file
30
scripts/resume-user-identity.test.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { resumeService } from "../src/services/product-service-clients.js";
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
const requests: Array<{ url: string; headers: Headers; body: string }> = [];
|
||||
|
||||
globalThis.fetch = async (input, init) => {
|
||||
const request = new Request(input, init);
|
||||
requests.push({
|
||||
url: request.url,
|
||||
headers: request.headers,
|
||||
body: await request.text(),
|
||||
});
|
||||
return new Response("{}", { status: 200, headers: { "content-type": "application/json" } });
|
||||
};
|
||||
|
||||
try {
|
||||
await resumeService.state("user_canonical");
|
||||
await resumeService.listResumes("user_canonical");
|
||||
await resumeService.createResume({ user_id: "user_from_payload" }, "user_canonical");
|
||||
|
||||
assert.equal(requests.length, 3);
|
||||
for (const request of requests) {
|
||||
assert.equal(request.headers.get("x-growqr-user"), "user_canonical");
|
||||
assert.match(request.headers.get("authorization") ?? "", /^Bearer /);
|
||||
}
|
||||
console.log("resume user identity forwarding: all assertions passed");
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
65
scripts/roleplay-drafts.test.ts
Normal file
65
scripts/roleplay-drafts.test.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
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);
|
||||
const url = new URL(request.url);
|
||||
const body = url.pathname.endsWith("/drafts")
|
||||
? { drafts: [{ session_id: "draft-1" }] }
|
||||
: url.pathname.endsWith("/plan")
|
||||
? { session_id: "draft-1", status: "draft" }
|
||||
: 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" },
|
||||
});
|
||||
}) as typeof fetch;
|
||||
|
||||
const app = serviceRoutes({ skipAuth: true });
|
||||
try {
|
||||
const drafts = await app.request("http://backend.test/roleplay/drafts?limit=25");
|
||||
assert.equal(drafts.status, 200);
|
||||
assert.deepEqual(await drafts.json(), { drafts: [{ session_id: "draft-1" }] });
|
||||
|
||||
const plan = await app.request("http://backend.test/roleplay/plan/draft-1");
|
||||
assert.equal(plan.status, 200);
|
||||
assert.deepEqual(await plan.json(), { session_id: "draft-1", status: "draft" });
|
||||
const approved = await app.request("http://backend.test/roleplay/approve", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ session_id: "draft-1" }),
|
||||
});
|
||||
assert.equal(approved.status, 200);
|
||||
assert.deepEqual(await approved.json(), { session_id: "draft-1", status: "configured", approved: true });
|
||||
|
||||
const deleted = await app.request("http://backend.test/roleplay/drafts/draft-1", { method: "DELETE" });
|
||||
assert.equal(deleted.status, 200);
|
||||
assert.deepEqual(await deleted.json(), { deleted: true, session_id: "draft-1" });
|
||||
|
||||
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 /);
|
||||
}
|
||||
console.log("roleplay draft gateway: all assertions passed");
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
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");
|
||||
101
scripts/roleplay-qscore-identity.test.ts
Normal file
101
scripts/roleplay-qscore-identity.test.ts
Normal 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;
|
||||
}
|
||||
23
scripts/rq-score-contract.test.ts
Normal file
23
scripts/rq-score-contract.test.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
|
||||
const [proxy, latestRoute, servicesRoute, schema, migration, homeTypes] = await Promise.all([
|
||||
readFile(new URL("../src/services/qscore-proxy.ts", import.meta.url), "utf8"),
|
||||
readFile(new URL("../src/v1/qscore/qscore-routes.ts", import.meta.url), "utf8"),
|
||||
readFile(new URL("../src/routes/services.ts", import.meta.url), "utf8"),
|
||||
readFile(new URL("../src/db/schema.ts", import.meta.url), "utf8"),
|
||||
readFile(new URL("../drizzle/0016_rq_score_snapshot.sql", import.meta.url), "utf8"),
|
||||
readFile(new URL("../src/home/types.ts", import.meta.url), "utf8"),
|
||||
]);
|
||||
|
||||
assert.match(proxy, /const rqScore = body\.rq_score;/, "service proxy must require rq_score");
|
||||
assert.doesNotMatch(proxy, /body\.q_score/, "service proxy must not accept the old q_score field");
|
||||
assert.match(latestRoute, /rq_score: result\.rq_score/, "latest API must expose rq_score");
|
||||
assert.doesNotMatch(latestRoute, /\bscore: result\.rq_score/, "latest API must not alias RQ Score as score");
|
||||
assert.match(servicesRoute, /rq_score: rqScore/, "gateway API must expose rq_score");
|
||||
assert.match(schema, /rqScore: integer\("rq_score"\)/, "snapshot schema must map rq_score");
|
||||
assert.match(migration, /RENAME COLUMN "score" TO "rq_score"/, "migration must preserve snapshot data while renaming the column");
|
||||
assert.match(homeTypes, /rqScore: \{ from:/, "home API must expose rqScore");
|
||||
assert.doesNotMatch(homeTypes, /\bqx:/, "home API must not expose the old qx field");
|
||||
|
||||
console.log("rq score API and database contract: ok");
|
||||
@@ -1,106 +1,30 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { extractQscoreSignals } from "../src/events/projectors/qscore-projector.js";
|
||||
import type { GrowEventRow } from "../src/db/schema.js";
|
||||
import { canonicalGrowEventEnvelope } from "../src/events/record-grow-event.js";
|
||||
import { normalizeGrowEvent } from "../src/events/normalize.js";
|
||||
|
||||
/**
|
||||
* Test: Service-ingest projector behavior across multiple service sources.
|
||||
* Verifies that canonical Grow events from different services produce
|
||||
* registry-valid signals with correct source attribution.
|
||||
*/
|
||||
/** Every service family is forwarded as a canonical event with unchanged real
|
||||
* payload. Extraction and signal validation now run only in QScore. */
|
||||
const cases = [
|
||||
["resume-builder", "resume.analysis.completed", { analysis: { score_breakdown: [{ category: "ATS Compatibility", score: 75 }] } }],
|
||||
["qscore-service", "qscore.signal.projected", { score: 65 }],
|
||||
["social-branding-service", "brand.profile.updated", { profile: { headline: "Real headline" } }],
|
||||
["unknown-service", "service.completed", {}],
|
||||
["matchmaking-v2", "matchmaking.match.applied", { applications_submitted: 3 }],
|
||||
] as const;
|
||||
|
||||
function event(overrides: Partial<GrowEventRow> & { type: string; source: string; payload: Record<string, unknown> }): GrowEventRow {
|
||||
return {
|
||||
id: `event-${overrides.type}`,
|
||||
userId: "user_test",
|
||||
orgId: null,
|
||||
source: overrides.source,
|
||||
type: overrides.type,
|
||||
for (const [source, type, payload] of cases) {
|
||||
const canonical = canonicalGrowEventEnvelope(normalizeGrowEvent({
|
||||
id: `event-${type}`,
|
||||
userId: "real-user",
|
||||
source,
|
||||
type,
|
||||
category: "service",
|
||||
occurredAt: new Date("2026-07-09T00:00:00.000Z"),
|
||||
receivedAt: new Date("2026-07-09T00:00:01.000Z"),
|
||||
mission: overrides.mission ?? null,
|
||||
subject: overrides.subject ?? null,
|
||||
correlation: overrides.correlation ?? null,
|
||||
payload: overrides.payload,
|
||||
raw: {},
|
||||
dedupeKey: null,
|
||||
processingStatus: "pending",
|
||||
processingError: null,
|
||||
processedAt: null,
|
||||
};
|
||||
occurredAt: "2026-07-14T00:00:00.000Z",
|
||||
payload,
|
||||
}), "real-user");
|
||||
assert.equal(canonical.source, source);
|
||||
assert.equal(canonical.type, type);
|
||||
assert.deepEqual(canonical.payload, payload);
|
||||
}
|
||||
|
||||
// ── 1. Resume analysis produces resume signals ──────────────────────────────
|
||||
{
|
||||
const signals = extractQscoreSignals(event({
|
||||
source: "resume-builder",
|
||||
type: "resume.analysis.completed",
|
||||
payload: { analysis: { score_breakdown: [{ category: "ATS Compatibility", score: 75 }] } },
|
||||
}));
|
||||
assert.ok(signals.length > 0, "resume analysis event should produce signals");
|
||||
assert.ok(
|
||||
signals.some((s) => s.signalId.startsWith("resume.")),
|
||||
"resume source should produce resume.* signals",
|
||||
);
|
||||
}
|
||||
|
||||
// ── 2. Generic scored service event (qscore source) produces completion score ─
|
||||
{
|
||||
const signals = extractQscoreSignals(event({
|
||||
source: "qscore-service",
|
||||
type: "qscore.signal.projected",
|
||||
payload: { score: 65 },
|
||||
}));
|
||||
assert.ok(
|
||||
signals.some((s) => s.score === 65),
|
||||
"qscore source event with score should forward the score",
|
||||
);
|
||||
}
|
||||
|
||||
// ── 3. Social branding pre-computed signals forwarded as-is ─────────────────
|
||||
{
|
||||
const signals = extractQscoreSignals(event({
|
||||
source: "social-branding-service",
|
||||
type: "brand.profile.updated",
|
||||
payload: {
|
||||
qscore_signals: [
|
||||
{ signalId: "linkedin.headline_quality", score: 70 },
|
||||
{ signalId: "linkedin.summary_complete", score: 65 },
|
||||
],
|
||||
},
|
||||
}));
|
||||
assert.ok(
|
||||
signals.some((s) => s.signalId === "linkedin.headline_quality" && s.score === 70),
|
||||
"social branding should forward pre-computed signals as-is",
|
||||
);
|
||||
assert.ok(
|
||||
signals.some((s) => s.signalId === "linkedin.summary_complete"),
|
||||
"social branding should forward all pre-computed signals",
|
||||
);
|
||||
}
|
||||
|
||||
// ── 4. Unknown source with no score produces no signals ──────────────────────
|
||||
{
|
||||
const signals = extractQscoreSignals(event({
|
||||
source: "unknown-service",
|
||||
type: "service.completed",
|
||||
payload: {},
|
||||
}));
|
||||
assert.equal(signals.length, 0, "unknown source with no score should produce no signals");
|
||||
}
|
||||
|
||||
// ── 5. Matchmaking applied event with count ──────────────────────────────────
|
||||
{
|
||||
const signals = extractQscoreSignals(event({
|
||||
source: "matchmaking-v2",
|
||||
type: "matchmaking.match.applied",
|
||||
payload: { applications_submitted: 3 },
|
||||
}));
|
||||
assert.ok(
|
||||
signals.some((s) => s.signalId === "matching.applications_submitted" && s.score > 0),
|
||||
"matchmaking applied with count should produce applications_submitted signal",
|
||||
);
|
||||
}
|
||||
|
||||
console.log("service-ingest-projector tests passed");
|
||||
process.exit(0);
|
||||
console.log("service raw-ingest forwarding tests passed");
|
||||
|
||||
116
scripts/service-session-correlation.test.ts
Normal file
116
scripts/service-session-correlation.test.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { correlateEventWithServiceSession, normalizeServiceId, resolveUniqueSessionUserId, serviceEventExternalIds } from "../src/events/record-grow-event.js";
|
||||
import { canonicalGrowEventType, normalizeGrowEvent } from "../src/events/normalize.js";
|
||||
import type { GrowEventEnvelope } from "../src/events/envelope.js";
|
||||
|
||||
{
|
||||
const normalized = normalizeGrowEvent({
|
||||
source: "interview-service",
|
||||
type: "interview.session.completed",
|
||||
correlation: { session_id: "session-1", taskId: "nested-task", keep: "yes" },
|
||||
task_id: "top-level-task",
|
||||
payload: { curator_task_id: "payload-task" },
|
||||
});
|
||||
assert.equal(normalized.correlation?.session_id, "session-1");
|
||||
assert.equal(normalized.correlation?.keep, "yes");
|
||||
assert.equal(normalized.correlation?.task_id, "nested-task");
|
||||
assert.equal(normalized.correlation?.taskId, undefined);
|
||||
assert.equal(normalized.correlation?.curatorTaskId, undefined);
|
||||
}
|
||||
{
|
||||
const standalone = normalizeGrowEvent({ source: "social-service", type: "social.account.connected" });
|
||||
assert.equal(standalone.correlation, undefined);
|
||||
assert.equal(standalone.type, "brand.account.connected");
|
||||
}
|
||||
{
|
||||
assert.equal(resolveUniqueSessionUserId([{ userId: "user-a" }]), "user-a");
|
||||
assert.equal(resolveUniqueSessionUserId([{ userId: "user-a" }, { userId: "user-a" }]), "user-a");
|
||||
assert.equal(resolveUniqueSessionUserId([{ userId: "user-a" }, { userId: "user-b" }]), undefined);
|
||||
}
|
||||
|
||||
const event: GrowEventEnvelope = {
|
||||
id: "event-1",
|
||||
userId: "user-a",
|
||||
source: "interview-service",
|
||||
type: "interview.session.completed",
|
||||
category: "service",
|
||||
occurredAt: "2026-07-14T00:00:00.000Z",
|
||||
correlation: { sessionId: "session-1" },
|
||||
payload: { status: "completed" },
|
||||
};
|
||||
|
||||
const link = {
|
||||
userId: "user-a",
|
||||
missionInstanceId: "sprint-1",
|
||||
missionId: "curator-sprint",
|
||||
stageId: "day-1:interview",
|
||||
metadata: { task_id: "curator-task-1" },
|
||||
};
|
||||
|
||||
{
|
||||
const correlated = correlateEventWithServiceSession(event, "user-a", link);
|
||||
assert.deepEqual(correlated.correlation, {
|
||||
sessionId: "session-1",
|
||||
task_id: "curator-task-1",
|
||||
missionInstanceId: "sprint-1",
|
||||
missionId: "curator-sprint",
|
||||
stageId: "day-1:interview",
|
||||
});
|
||||
assert.deepEqual(correlated.mission, {
|
||||
instanceId: "sprint-1",
|
||||
missionId: "curator-sprint",
|
||||
stageId: "day-1:interview",
|
||||
});
|
||||
}
|
||||
|
||||
{
|
||||
const foreign = correlateEventWithServiceSession(event, "user-a", { ...link, userId: "user-b" });
|
||||
assert.strictEqual(foreign, event, "a service session owned by another user must never enrich the event");
|
||||
}
|
||||
{
|
||||
const sessionless = correlateEventWithServiceSession(event, "user-a", undefined);
|
||||
assert.strictEqual(sessionless, event, "without an exact session link, historical events must not enrich correlation");
|
||||
}
|
||||
|
||||
{
|
||||
const unresolved = correlateEventWithServiceSession(event, undefined, link);
|
||||
assert.strictEqual(unresolved, event, "an unresolved event must never inherit session context");
|
||||
}
|
||||
|
||||
{
|
||||
const explicit = correlateEventWithServiceSession({
|
||||
...event,
|
||||
mission: { missionId: "explicit-mission", stageId: "explicit-stage" },
|
||||
correlation: { sessionId: "session-1", task_id: "explicit-task" },
|
||||
}, "user-a", link);
|
||||
assert.equal(explicit.correlation?.task_id, "explicit-task");
|
||||
assert.deepEqual(explicit.mission, { missionId: "explicit-mission", stageId: "explicit-stage" });
|
||||
}
|
||||
|
||||
{
|
||||
assert.deepEqual(serviceEventExternalIds({
|
||||
...event,
|
||||
correlation: { courseId: "course-1", lesson_id: "lesson-1", externalId: "course-1" },
|
||||
subject: { serviceId: "courses-service", externalId: "lesson-1" },
|
||||
payload: { resume_id: "resume-1", assessmentId: "assessment-1" },
|
||||
}), ["course-1", "lesson-1", "resume-1", "assessment-1"]);
|
||||
}
|
||||
|
||||
{
|
||||
assert.equal(normalizeServiceId("social-branding-service"), "brand");
|
||||
assert.equal(normalizeServiceId("courses-service"), "course");
|
||||
assert.equal(normalizeServiceId("cover-letter-service"), "cover_letter");
|
||||
assert.equal(normalizeServiceId("matchmaking-v2"), "matchmaking");
|
||||
}
|
||||
|
||||
{
|
||||
assert.equal(canonicalGrowEventType("roleplay.session.completed"), "roleplay.scenario.completed");
|
||||
assert.equal(canonicalGrowEventType("roleplay.review.completed"), "roleplay.feedback.generated");
|
||||
assert.equal(canonicalGrowEventType("course.video.completed"), "course.completed");
|
||||
assert.equal(canonicalGrowEventType("course.video.started"), "course.started");
|
||||
assert.equal(canonicalGrowEventType("course.lesson.started"), "course.started");
|
||||
assert.equal(canonicalGrowEventType("social.profile.synced"), "brand.profile.updated");
|
||||
assert.equal(canonicalGrowEventType("resume.analysis.completed"), "resume.analysis.completed");
|
||||
}
|
||||
|
||||
console.log("service session correlation tests passed");
|
||||
122
scripts/service-session-projection-order.test.ts
Normal file
122
scripts/service-session-projection-order.test.ts
Normal 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");
|
||||
11
scripts/service-token-config.test.ts
Normal file
11
scripts/service-token-config.test.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { resolveServiceToken } from "../src/config.js";
|
||||
|
||||
assert.equal(resolveServiceToken({}), "", "missing SERVICE_TOKEN must resolve to an empty config value");
|
||||
assert.equal(
|
||||
resolveServiceToken({ SERVICE_TOKEN: "explicit" }),
|
||||
"explicit",
|
||||
"explicit SERVICE_TOKEN must be preserved",
|
||||
);
|
||||
|
||||
console.log("service-token-config: all assertions passed");
|
||||
@@ -1,238 +1,30 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { extractQscoreSignals } from "../src/events/projectors/qscore-projector.js";
|
||||
import { ONBOARDING_BASELINE_SIGNAL_ID } from "../src/events/onboarding-qscore.js";
|
||||
import type { GrowEventRow } from "../src/db/schema.js";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { canonicalGrowEventEnvelope } from "../src/events/record-grow-event.js";
|
||||
import { normalizeGrowEvent } from "../src/events/normalize.js";
|
||||
|
||||
/**
|
||||
* Test: All signal IDs emitted by the projector must be valid members of the
|
||||
* qscore_service v2 signal registry. The onboarding baseline signal must be
|
||||
* "onboarding.completed" (not the non-registry "onboarding.completed_baseline").
|
||||
* Signal registration and all 67 extraction policies belong to QScore. This
|
||||
* backend contract test prevents the old local registry/projector from being
|
||||
* reintroduced while proving the complete raw evidence reaches QScore.
|
||||
*/
|
||||
|
||||
// v2 registry — union of all signal_ids across all 7 profession formula JSONs.
|
||||
// Sourced from qscore_service/app/scoring/formula/v2/*/formula.json
|
||||
const V2_REGISTRY = new Set<string>([
|
||||
// resume
|
||||
"resume.uploaded",
|
||||
"resume.ats_compatibility",
|
||||
"resume.keyword_relevance",
|
||||
"resume.quantified_achievements",
|
||||
"resume.grammar_clarity",
|
||||
"resume.format_structure",
|
||||
"resume.contact_info",
|
||||
"resume.page_count",
|
||||
// interview
|
||||
"interview.sessions_completed",
|
||||
"interview.overall_score",
|
||||
"interview.response_clarity",
|
||||
"interview.technical_accuracy",
|
||||
"interview.behavioral_quality",
|
||||
"interview.improvement_over_time",
|
||||
"interview.type_diversity",
|
||||
// roleplay
|
||||
"roleplay.scenarios_completed",
|
||||
"roleplay.situational_judgment",
|
||||
"roleplay.empathy_demonstrated",
|
||||
"roleplay.problem_resolution",
|
||||
"roleplay.communication_effectiveness",
|
||||
// courses
|
||||
"courses.started",
|
||||
"courses.completed",
|
||||
"courses.completion_rate",
|
||||
"courses.difficulty",
|
||||
"courses.pathway_relevance",
|
||||
// matching
|
||||
"matching.jobs_viewed",
|
||||
"matching.applications_submitted",
|
||||
"matching.application_quality",
|
||||
"matching.match_rate",
|
||||
// onboarding
|
||||
"onboarding.completed",
|
||||
"onboarding.initial_skills_score",
|
||||
"onboarding.goals_clarity",
|
||||
"onboarding.profile_completeness",
|
||||
"onboarding.self_assessment_accuracy",
|
||||
"onboarding.motivation_quality",
|
||||
// coverletter
|
||||
"coverletter.uploaded",
|
||||
"coverletter.customization",
|
||||
"coverletter.persuasiveness",
|
||||
// linkedin
|
||||
"linkedin.account_connected",
|
||||
"linkedin.profile_photo",
|
||||
"linkedin.headline_quality",
|
||||
"linkedin.summary_complete",
|
||||
"linkedin.experience_detail",
|
||||
"linkedin.skills_listed",
|
||||
// portfolio
|
||||
"portfolio.website_exists",
|
||||
"portfolio.website_quality",
|
||||
"portfolio.content_relevance",
|
||||
// content
|
||||
"content.articles_count",
|
||||
"content.quality",
|
||||
"content.platform_credibility",
|
||||
// ugc
|
||||
"ugc.speaking_count",
|
||||
"ugc.event_credibility",
|
||||
"ugc.verifiable_evidence",
|
||||
// assessments
|
||||
"assessments.taken",
|
||||
"assessments.passed",
|
||||
"assessments.avg_score",
|
||||
"assessments.skill_diversity",
|
||||
// presentations
|
||||
"presentations.submitted",
|
||||
"presentations.content_quality",
|
||||
"presentations.delivery_score",
|
||||
"presentations.visual_aids",
|
||||
// events
|
||||
"events.webinars_attended",
|
||||
"events.meetups_attended",
|
||||
"events.participation_quality",
|
||||
// mentor
|
||||
"mentor.feedback_count",
|
||||
"mentor.score",
|
||||
"mentor.implementation_rate",
|
||||
]);
|
||||
|
||||
function event(overrides: Partial<GrowEventRow> & { type: string; payload: Record<string, unknown>; source: string }): GrowEventRow {
|
||||
return {
|
||||
id: `event-${overrides.type}`,
|
||||
userId: "user_test",
|
||||
orgId: null,
|
||||
source: overrides.source,
|
||||
type: overrides.type,
|
||||
category: "service",
|
||||
occurredAt: new Date("2026-07-09T00:00:00.000Z"),
|
||||
receivedAt: new Date("2026-07-09T00:00:01.000Z"),
|
||||
mission: overrides.mission ?? null,
|
||||
subject: overrides.subject ?? null,
|
||||
correlation: overrides.correlation ?? null,
|
||||
payload: overrides.payload,
|
||||
raw: {},
|
||||
dedupeKey: null,
|
||||
processingStatus: "pending",
|
||||
processingError: null,
|
||||
processedAt: null,
|
||||
};
|
||||
}
|
||||
|
||||
// ── 1. Onboarding signal ID must be the registry-valid "onboarding.completed" ─
|
||||
assert.equal(
|
||||
ONBOARDING_BASELINE_SIGNAL_ID,
|
||||
"onboarding.completed",
|
||||
"Onboarding baseline signal ID must be 'onboarding.completed' (registry-valid), not 'onboarding.completed_baseline'",
|
||||
);
|
||||
assert.ok(
|
||||
V2_REGISTRY.has(ONBOARDING_BASELINE_SIGNAL_ID),
|
||||
"Onboarding signal ID must appear in v2 registry",
|
||||
);
|
||||
|
||||
// ── 2. All projector signal IDs must be in the registry ──────────────────────
|
||||
// Construct representative events for each source family and check every emitted
|
||||
// signal ID is registry-valid.
|
||||
|
||||
// Resume with full breakdown
|
||||
const resumeSignals = extractQscoreSignals(event({
|
||||
const raw = {
|
||||
id: "evt-full-evidence",
|
||||
source: "resume-builder",
|
||||
type: "resume.analysis.completed",
|
||||
category: "service" as const,
|
||||
occurredAt: "2026-07-14T00:00:00.000Z",
|
||||
payload: {
|
||||
analysis: {
|
||||
score_breakdown: [
|
||||
{ category: "ATS Compatibility", score: 70 },
|
||||
{ category: "Content Quality", score: 65 },
|
||||
{ category: "Formatting", score: 80 },
|
||||
],
|
||||
dimensional_scores: [
|
||||
{ dimension: "Keywords", score: 60 },
|
||||
{ dimension: "Quantification", score: 55 },
|
||||
],
|
||||
score_breakdown: [{ category: "ATS Compatibility", score: 70 }],
|
||||
dimensional_scores: [{ dimension: "Keywords", score: 60 }],
|
||||
},
|
||||
},
|
||||
}));
|
||||
for (const s of resumeSignals) {
|
||||
assert.ok(
|
||||
V2_REGISTRY.has(s.signalId),
|
||||
`Resume signal '${s.signalId}' is not in the v2 registry`,
|
||||
);
|
||||
}
|
||||
};
|
||||
const event = canonicalGrowEventEnvelope(normalizeGrowEvent(raw), "real-user");
|
||||
assert.deepEqual(event.payload, raw.payload, "raw evidence must reach QScore without signal extraction");
|
||||
|
||||
// Interview with rubric
|
||||
const interviewSignals = extractQscoreSignals(event({
|
||||
source: "interview-service",
|
||||
type: "interview.session.completed",
|
||||
payload: {
|
||||
review: {
|
||||
overall_score: 72,
|
||||
rubric_scores: { content_quality: 70, role_alignment: 68, language: 65 },
|
||||
historical_comparison: { overall_delta: 5 },
|
||||
},
|
||||
session_count: 3,
|
||||
type_diversity: 2,
|
||||
},
|
||||
}));
|
||||
for (const s of interviewSignals) {
|
||||
assert.ok(
|
||||
V2_REGISTRY.has(s.signalId),
|
||||
`Interview signal '${s.signalId}' is not in the v2 registry`,
|
||||
);
|
||||
}
|
||||
|
||||
// Roleplay with rubric
|
||||
const roleplaySignals = extractQscoreSignals(event({
|
||||
source: "roleplay-service",
|
||||
type: "roleplay.scenario.completed",
|
||||
payload: {
|
||||
review: {
|
||||
rubric_scores: {
|
||||
scenario_adherence: 75,
|
||||
emotional_intelligence: 70,
|
||||
adaptability: 68,
|
||||
content: 72,
|
||||
},
|
||||
},
|
||||
scenario_count: 4,
|
||||
},
|
||||
}));
|
||||
for (const s of roleplaySignals) {
|
||||
assert.ok(
|
||||
V2_REGISTRY.has(s.signalId),
|
||||
`Roleplay signal '${s.signalId}' is not in the v2 registry`,
|
||||
);
|
||||
}
|
||||
|
||||
// Courses
|
||||
const courseSignals = extractQscoreSignals(event({
|
||||
source: "courses-service",
|
||||
type: "course.completed",
|
||||
payload: {
|
||||
courseId: "c1",
|
||||
completed_count: 2,
|
||||
watchPct: 0.9,
|
||||
difficulty: "intermediate",
|
||||
label: "high relevance",
|
||||
},
|
||||
}));
|
||||
for (const s of courseSignals) {
|
||||
assert.ok(
|
||||
V2_REGISTRY.has(s.signalId),
|
||||
`Course signal '${s.signalId}' is not in the v2 registry`,
|
||||
);
|
||||
}
|
||||
|
||||
// Matchmaking
|
||||
const matchSignals = extractQscoreSignals(event({
|
||||
source: "matchmaking-v2",
|
||||
type: "matchmaking.feed.viewed",
|
||||
payload: { jobs_viewed: 25 },
|
||||
}));
|
||||
for (const s of matchSignals) {
|
||||
assert.ok(
|
||||
V2_REGISTRY.has(s.signalId),
|
||||
`Matchmaking signal '${s.signalId}' is not in the v2 registry`,
|
||||
);
|
||||
}
|
||||
|
||||
console.log("signal-registry tests passed");
|
||||
process.exit(0);
|
||||
const projector = await readFile(new URL("../src/events/projectors/qscore-projector.ts", import.meta.url), "utf8");
|
||||
assert.equal(projector.includes("extractQscoreSignals"), false);
|
||||
assert.equal(projector.includes("signalId"), false);
|
||||
console.log("signal registry ownership test passed");
|
||||
|
||||
64
scripts/staging-compose.sh
Executable file
64
scripts/staging-compose.sh
Executable file
@@ -0,0 +1,64 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
PROJECT=growqr-backend-staging
|
||||
COMPOSE="docker compose -p $PROJECT -f docker-compose.yml -f docker-compose.override.yml"
|
||||
REQUIRED_NETWORKS="growqr-backend-staging_default interview-service_default roleplay-service_default resume-builder-staging_default course-service_default qscore-service-staging_default matchmaking-v2_default user-service-staging_default"
|
||||
EXTERNAL_NETWORKS="interview-service_default roleplay-service_default resume-builder-staging_default course-service_default qscore-service-staging_default matchmaking-v2_default user-service-staging_default"
|
||||
|
||||
preflight() {
|
||||
for network in $EXTERNAL_NETWORKS; do
|
||||
docker network inspect "$network" >/dev/null
|
||||
done
|
||||
$COMPOSE config --quiet
|
||||
}
|
||||
|
||||
verify() {
|
||||
actual=$(docker inspect growqr-backend --format '{{range $name, $_ := .NetworkSettings.Networks}}{{$name}} {{end}}')
|
||||
for network in $REQUIRED_NETWORKS; do
|
||||
case " $actual " in
|
||||
*" $network "*) ;;
|
||||
*) echo "missing backend network: $network" >&2; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
for network in $actual; do
|
||||
case " $REQUIRED_NETWORKS " in
|
||||
*" $network "*) ;;
|
||||
*) echo "unexpected backend network: $network" >&2; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
docker exec growqr-backend node --input-type=module -e '
|
||||
const checks = [
|
||||
["user-service", "http://growqr_user_service:8003/health"],
|
||||
["qscore", "http://qscore-service-staging-api-1:8000/health"],
|
||||
["course", "http://growqr-course-service:8010/health"],
|
||||
["resume", "http://growqr_resume_api:8000/health"],
|
||||
["interview", "http://interview-service-api-1:8000/health"],
|
||||
["roleplay", "http://roleplay-service-api-1:8000/health"],
|
||||
];
|
||||
let failed = false;
|
||||
for (const [name, url] of checks) {
|
||||
try {
|
||||
const response = await fetch(url, { signal: AbortSignal.timeout(3000) });
|
||||
console.log(name, response.status);
|
||||
if (response.status !== 200) failed = true;
|
||||
} catch (error) {
|
||||
console.error(name, "ERROR", error.message);
|
||||
failed = true;
|
||||
}
|
||||
}
|
||||
if (failed) process.exit(1);
|
||||
'
|
||||
docker exec growqr-backend node --input-type=module -e "const response=await fetch('http://127.0.0.1:4000/healthz',{signal:AbortSignal.timeout(3000)});console.log('backend',response.status);if(response.status!==200)process.exit(1)"
|
||||
}
|
||||
|
||||
action=${1:-}
|
||||
[ "$#" -gt 0 ] && shift
|
||||
case "$action" in
|
||||
preflight) preflight ;;
|
||||
verify) verify ;;
|
||||
up) preflight; $COMPOSE up -d "$@"; verify ;;
|
||||
down) $COMPOSE down "$@" ;;
|
||||
*) echo "Usage: scripts/staging-compose.sh <preflight|verify|up|down> [compose args]" >&2; exit 2 ;;
|
||||
esac
|
||||
@@ -15,9 +15,9 @@ const TODAY = "2026-07-10";
|
||||
{
|
||||
const days = ["2026-07-10", "2026-07-09", "2026-07-08"];
|
||||
const streak = computeStreakFromDays(days, TODAY);
|
||||
assert.equal(streak.current, 3, "current streak should be 3 for 3 consecutive days ending today");
|
||||
assert.equal(streak.longest, 3, "longest should match current when run is unbroken");
|
||||
assert.equal(streak.lastCompletedDate, "2026-07-10");
|
||||
assert.equal(streak.current_days, 3, "current streak should be 3 for 3 consecutive days ending today");
|
||||
assert.equal(streak.longest_days, 3, "longest should match current when run is unbroken");
|
||||
assert.equal(streak.last_completed_on, "2026-07-10");
|
||||
}
|
||||
|
||||
// ── 2. Same-day duplicate must not inflate the streak ────────────────────────
|
||||
@@ -30,9 +30,9 @@ const TODAY = "2026-07-10";
|
||||
// loop checks `days.includes(cursor)` which is set-based, so current stays 2.
|
||||
// But the longest loop iterates ALL entries including the dup, which could
|
||||
// inflate longest to 3. The correct behavior: duplicates must not inflate.
|
||||
assert.equal(streak.current, 2, "duplicate day must not inflate current streak");
|
||||
assert.equal(streak.current_days, 2, "duplicate day must not inflate current streak");
|
||||
assert.equal(
|
||||
streak.longest,
|
||||
streak.longest_days,
|
||||
2,
|
||||
"duplicate day must not inflate longest streak — duplicates should be deduped before counting",
|
||||
);
|
||||
@@ -43,8 +43,8 @@ const TODAY = "2026-07-10";
|
||||
// Completed today and 3 days ago — gap breaks current streak
|
||||
const days = ["2026-07-10", "2026-07-07"];
|
||||
const streak = computeStreakFromDays(days, TODAY);
|
||||
assert.equal(streak.current, 1, "gap resets current streak to 1 (only today)");
|
||||
assert.equal(streak.longest, 1, "longest should also be 1 with only isolated days");
|
||||
assert.equal(streak.current_days, 1, "gap resets current streak to 1 (only today)");
|
||||
assert.equal(streak.longest_days, 1, "longest should also be 1 with only isolated days");
|
||||
}
|
||||
|
||||
// ── 4. Recovery: longest captures a past longer run even if current is broken ─
|
||||
@@ -59,8 +59,8 @@ const TODAY = "2026-07-10";
|
||||
"2026-07-01",
|
||||
];
|
||||
const streak = computeStreakFromDays(days, TODAY);
|
||||
assert.equal(streak.current, 1, "current is 1 after a gap even if a longer past run exists");
|
||||
assert.equal(streak.longest, 5, "longest should capture the 5-day past run");
|
||||
assert.equal(streak.current_days, 1, "current is 1 after a gap even if a longer past run exists");
|
||||
assert.equal(streak.longest_days, 5, "longest should capture the 5-day past run");
|
||||
}
|
||||
|
||||
// ── 5. Seven-day eligibility: a 7-day streak counts as eligible ──────────────
|
||||
@@ -70,25 +70,25 @@ const TODAY = "2026-07-10";
|
||||
"2026-07-07", "2026-07-06", "2026-07-05", "2026-07-04",
|
||||
];
|
||||
const streak = computeStreakFromDays(days, TODAY);
|
||||
assert.equal(streak.current, 7, "7 consecutive days should yield current=7");
|
||||
assert.equal(streak.longest, 7, "7 consecutive days should yield longest=7");
|
||||
assert.ok(streak.current >= 7, "7-day streak eligibility threshold met");
|
||||
assert.equal(streak.current_days, 7, "7 consecutive days should yield current=7");
|
||||
assert.equal(streak.longest_days, 7, "7 consecutive days should yield longest=7");
|
||||
assert.ok(streak.current_days >= 7, "7-day streak eligibility threshold met");
|
||||
}
|
||||
|
||||
// ── 6. Empty days yields zero streak ─────────────────────────────────────────
|
||||
{
|
||||
const streak = computeStreakFromDays([], TODAY);
|
||||
assert.equal(streak.current, 0);
|
||||
assert.equal(streak.longest, 0);
|
||||
assert.equal(streak.lastCompletedDate, null);
|
||||
assert.equal(streak.current_days, 0);
|
||||
assert.equal(streak.longest_days, 0);
|
||||
assert.equal(streak.last_completed_on, null);
|
||||
}
|
||||
|
||||
// ── 7. Not completed today but completed yesterday: current is 0 ─────────────
|
||||
// ── 7. Yesterday keeps the current streak alive until today's IST day closes ─
|
||||
{
|
||||
const days = ["2026-07-09"];
|
||||
const streak = computeStreakFromDays(days, TODAY);
|
||||
assert.equal(streak.current, 0, "current is 0 if today is not completed");
|
||||
assert.equal(streak.longest, 1, "longest should still count the 1-day run");
|
||||
assert.equal(streak.current_days, 1, "yesterday remains current during the one-day grace window");
|
||||
assert.equal(streak.longest_days, 1, "longest should still count the 1-day run");
|
||||
}
|
||||
|
||||
console.log("streak-policy tests passed");
|
||||
|
||||
315
scripts/user-profile.test.ts
Normal file
315
scripts/user-profile.test.ts
Normal file
@@ -0,0 +1,315 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { config } from "../src/config.js";
|
||||
import { resolveTrustedServiceTokens } from "../src/auth/clerk.js";
|
||||
import { fetchOnboardingReadProfile, fetchUserProfile, patchPreferencesRequest } from "../src/services/user-profile.js";
|
||||
|
||||
/**
|
||||
* Regression test for the PATCH /onboarding 500.
|
||||
*
|
||||
* Root cause: the PATCH handler parsed its JSON body via `c.req.json()`
|
||||
* (consuming `c.req.raw` / setting bodyUsed), then called fetchUserProfile
|
||||
* with the same Request. fetchUserProfile replayed the inbound method+body to
|
||||
* user-service /me, so fetchUserService did `await req.arrayBuffer()` on an
|
||||
* already-consumed body → `TypeError: Body is unusable` → uncaught → global
|
||||
* onError → {"error":"internal"}, 500. GET /onboarding worked only because it
|
||||
* never reads a body, so fetchUserService skipped arrayBuffer.
|
||||
*
|
||||
* This test reproduces the exact precondition — consume the body first — and
|
||||
* asserts the fix: fetchUserProfile issues an explicit GET that never touches
|
||||
* the inbound Request's body. It would have thrown before the fix and passes
|
||||
* after.
|
||||
*/
|
||||
|
||||
// Capture the outgoing fetch call.
|
||||
type FetchCall = { method: string; url: string; bodyTouched: boolean };
|
||||
|
||||
async function runFetchUserProfileAfterBodyConsumed(): Promise<{
|
||||
profile: Record<string, unknown>;
|
||||
call: FetchCall;
|
||||
}> {
|
||||
const originalFetch = globalThis.fetch;
|
||||
let captured: FetchCall | null = null;
|
||||
|
||||
const req = new Request("https://backend.test/api/growqr/users/onboarding", {
|
||||
method: "PATCH",
|
||||
headers: { "content-type": "application/json", authorization: "Bearer test" },
|
||||
body: JSON.stringify({ data: { access_choice: "trial" }, expectedRevision: 0 }),
|
||||
});
|
||||
|
||||
// Simulate what the PATCH handler does: parse the JSON body. This marks
|
||||
// req.bodyUsed = true — the precondition that triggered the 500.
|
||||
await req.json();
|
||||
|
||||
globalThis.fetch = (async (input: URL | Request, init?: RequestInit) => {
|
||||
const target = input instanceof URL ? input : new URL(input instanceof Request ? input.url : String(input));
|
||||
// Detect if the caller tried to re-read the original (consumed) Request body.
|
||||
let bodyTouched = false;
|
||||
if (input instanceof Request) {
|
||||
try {
|
||||
await input.clone().arrayBuffer();
|
||||
bodyTouched = input.bodyUsed;
|
||||
} catch {
|
||||
bodyTouched = true; // Body is unusable — the original bug.
|
||||
}
|
||||
}
|
||||
captured = { method: init?.method ?? input.method ?? "GET", url: target.href, bodyTouched };
|
||||
return new Response(JSON.stringify({ preferences: { onboarding: { access_choice: "trial" } } }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}) as typeof globalThis.fetch;
|
||||
|
||||
try {
|
||||
const profile = await fetchUserProfile(req);
|
||||
assert(captured, "fetch was never called");
|
||||
return { profile, call: captured };
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
}
|
||||
|
||||
// ── 1. no throw when inbound body was already consumed ──────────────────────
|
||||
{
|
||||
const { call } = await runFetchUserProfileAfterBodyConsumed();
|
||||
assert.equal(call.bodyTouched, false, "fetchUserProfile must not re-read the inbound Request body");
|
||||
}
|
||||
|
||||
// ── 2. outgoing method is GET (a read, never PATCH) ─────────────────────────
|
||||
{
|
||||
const { call } = await runFetchUserProfileAfterBodyConsumed();
|
||||
assert.equal(call.method, "GET", "fetchUserProfile must issue GET /me regardless of inbound method");
|
||||
}
|
||||
|
||||
// ── 3. target is /api/v1/users/me and profile parses ───────────────────────
|
||||
{
|
||||
const { profile, call } = await runFetchUserProfileAfterBodyConsumed();
|
||||
assert.ok(call.url.endsWith("/api/v1/users/me"), `expected /me URL, got ${call.url}`);
|
||||
assert.deepEqual(profile, { preferences: { onboarding: { access_choice: "trial" } } });
|
||||
}
|
||||
|
||||
// ── 4. content-type header is stripped (GET has no body) ────────────────────
|
||||
{
|
||||
const originalFetch = globalThis.fetch;
|
||||
let capturedHeaders: Headers | null = null;
|
||||
globalThis.fetch = (async (input: URL | Request, init?: RequestInit) => {
|
||||
capturedHeaders = new Headers(init?.headers ?? {});
|
||||
return new Response("{}", { status: 200, headers: { "content-type": "application/json" } });
|
||||
}) as typeof globalThis.fetch;
|
||||
try {
|
||||
const req = new Request("https://backend.test/api/growqr/users/onboarding", {
|
||||
method: "PATCH",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
await req.json();
|
||||
await fetchUserProfile(req);
|
||||
assert.ok(capturedHeaders, "fetch was never called");
|
||||
assert.equal(capturedHeaders!.get("content-type"), null, "content-type must be stripped from GET /me");
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
}
|
||||
|
||||
// ── 5. non-2xx surfaces a thrown error (caller handles) ─────────────────────
|
||||
{
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = (async () => new Response("nope", { status: 503 })) as typeof globalThis.fetch;
|
||||
try {
|
||||
const req = new Request("https://backend.test/api/growqr/users/onboarding", { method: "GET" });
|
||||
await assert.rejects(
|
||||
() => fetchUserProfile(req),
|
||||
/user-service \/me fetch failed: 503/,
|
||||
);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
}
|
||||
|
||||
// ── 6. synthetic PATCH /me must not carry a stale content-length ───────────
|
||||
// Regression for the second PATCH 500: patchPreferencesRequest copied inbound
|
||||
// headers including content-length, then set a DIFFERENT body — undici kept the
|
||||
// stale length, user-service waited for bytes that never arrive, and closed
|
||||
// the socket (~2.8s "other side closed"). The Request constructor does NOT
|
||||
// recompute content-length when an explicit body is provided alongside
|
||||
// forwarded headers, so the deletes inside patchPreferencesRequest are
|
||||
// load-bearing. This tests the REAL exported function.
|
||||
{
|
||||
// Inbound request with a body whose length differs from the synthetic body.
|
||||
const inboundBody = JSON.stringify({ data: { access_choice: "trial" }, expectedRevision: 0 });
|
||||
const req = new Request("https://backend.test/api/growqr/users/onboarding", {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"content-length": String(Buffer.byteLength(inboundBody)),
|
||||
"transfer-encoding": "chunked",
|
||||
},
|
||||
body: inboundBody,
|
||||
});
|
||||
|
||||
const preferences = { onboarding: { access_choice: "trial" } };
|
||||
const synthetic = patchPreferencesRequest(req, preferences);
|
||||
|
||||
// The synthetic request must not carry the stale inbound length/transfer headers.
|
||||
assert.equal(synthetic.headers.get("content-length"), null, "stale content-length must be stripped from synthetic PATCH /me");
|
||||
assert.equal(synthetic.headers.get("transfer-encoding"), null, "transfer-encoding must be stripped from synthetic PATCH /me");
|
||||
assert.equal(synthetic.headers.get("host"), null, "host must be stripped");
|
||||
assert.equal(synthetic.headers.get("cookie"), null, "cookie must be stripped");
|
||||
assert.equal(synthetic.headers.get("content-type"), "application/json", "content-type must be set");
|
||||
assert.equal(synthetic.method, "PATCH", "method must be PATCH");
|
||||
assert.ok(synthetic.url.endsWith("/api/v1/users/me"), `target must be /me, got ${synthetic.url}`);
|
||||
|
||||
// The body must be the synthetic preferences blob, not the inbound payload.
|
||||
const sentBody = await synthetic.text();
|
||||
assert.deepEqual(JSON.parse(sentBody), { preferences }, "synthetic body must carry only { preferences }");
|
||||
assert.notEqual(sentBody.length, Buffer.byteLength(inboundBody), "synthetic body length must differ from inbound (else the test proves nothing)");
|
||||
}
|
||||
|
||||
// ── 7. trusted service auth uses A2A state with scoped identity ─────────────
|
||||
{
|
||||
const originalFetch = globalThis.fetch;
|
||||
const mutableConfig = config as unknown as { serviceToken: string; a2aAllowedKey: string; nodeEnv: string };
|
||||
const savedConfig = { serviceToken: config.serviceToken, a2aAllowedKey: config.a2aAllowedKey, nodeEnv: config.nodeEnv };
|
||||
Object.assign(mutableConfig, { serviceToken: "test-service-token", a2aAllowedKey: "test-a2a-key", nodeEnv: "production" });
|
||||
const userId = "user_3EX1LG3gBk3KY6kfD9PiTkWubs4/scope";
|
||||
let target = "";
|
||||
let auth = "";
|
||||
let forwardedScope: string | null = null;
|
||||
globalThis.fetch = (async (input: URL | Request, init?: RequestInit) => {
|
||||
target = String(input instanceof URL ? input : input.url);
|
||||
const headers = new Headers(init?.headers ?? {});
|
||||
auth = headers.get("authorization") ?? "";
|
||||
forwardedScope = headers.get("x-growqr-user");
|
||||
return new Response(JSON.stringify({ preferences: { onboarding: { revision: 10, status: "completed", access_choice: "trial", profile: { icp: "experienced" } } } }), { status: 200 });
|
||||
}) as typeof globalThis.fetch;
|
||||
try {
|
||||
const req = new Request("https://backend.test/users/onboarding", {
|
||||
headers: { authorization: "Bearer test-service-token", "x-growqr-user": userId },
|
||||
});
|
||||
const profile = await fetchOnboardingReadProfile(req, userId);
|
||||
assert.ok(target.endsWith(`/api/state/${encodeURIComponent(userId)}`), `A2A target must be scoped to user: ${target}`);
|
||||
assert.equal(auth, "Bearer test-a2a-key", "A2A key must be used downstream");
|
||||
assert.equal(forwardedScope, null, "user scope must be encoded in URL, not forwarded as trust header");
|
||||
assert.equal((profile.preferences as Record<string, any>).onboarding.revision, 10);
|
||||
const onboarding = (profile.preferences as Record<string, any>).onboarding;
|
||||
assert.equal(onboarding.status, "completed");
|
||||
assert.equal(onboarding.profile.icp, "experienced");
|
||||
assert.equal(onboarding.access_choice, "trial");
|
||||
} finally {
|
||||
Object.assign(mutableConfig, savedConfig);
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
}
|
||||
|
||||
// Trusted auth without a matching scope is rejected before any downstream call.
|
||||
{
|
||||
const originalFetch = globalThis.fetch;
|
||||
let calls = 0;
|
||||
const mutableConfig = config as unknown as { serviceToken: string; a2aAllowedKey: string; nodeEnv: string };
|
||||
const savedConfig = { serviceToken: config.serviceToken, a2aAllowedKey: config.a2aAllowedKey, nodeEnv: config.nodeEnv };
|
||||
Object.assign(mutableConfig, { serviceToken: "test-service-token", a2aAllowedKey: "test-a2a-key", nodeEnv: "production" });
|
||||
globalThis.fetch = (async () => { calls += 1; return new Response("unexpected", { status: 200 }); }) as typeof globalThis.fetch;
|
||||
try {
|
||||
const req = new Request("https://backend.test/users/onboarding", { headers: { authorization: "Bearer test-service-token" } });
|
||||
await assert.rejects(() => fetchOnboardingReadProfile(req, "user_spoof"), /matching x-growqr-user/);
|
||||
assert.equal(calls, 0, "unscoped trusted auth must not call user-service");
|
||||
} finally {
|
||||
Object.assign(mutableConfig, savedConfig);
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
}
|
||||
|
||||
// Production never treats A2A_ALLOWED_KEY as an inbound backend token.
|
||||
{
|
||||
const originalFetch = globalThis.fetch;
|
||||
const mutableConfig = config as unknown as { serviceToken: string; a2aAllowedKey: string; nodeEnv: string };
|
||||
const savedConfig = { serviceToken: config.serviceToken, a2aAllowedKey: config.a2aAllowedKey, nodeEnv: config.nodeEnv };
|
||||
Object.assign(mutableConfig, { serviceToken: "test-service-token", a2aAllowedKey: "test-a2a-key", nodeEnv: "production" });
|
||||
let target = "";
|
||||
let auth = "";
|
||||
globalThis.fetch = (async (input: URL | Request, init?: RequestInit) => {
|
||||
target = String(input instanceof URL ? input : input.url);
|
||||
auth = new Headers(init?.headers ?? {}).get("authorization") ?? "";
|
||||
return new Response(JSON.stringify({ preferences: { onboarding: { revision: 10 } } }), { status: 200 });
|
||||
}) as typeof globalThis.fetch;
|
||||
try {
|
||||
const req = new Request("https://backend.test/users/onboarding", {
|
||||
headers: { authorization: "Bearer test-a2a-key", "x-growqr-user": "user_spoof" },
|
||||
});
|
||||
await fetchOnboardingReadProfile(req, "user_real");
|
||||
assert.ok(target.endsWith("/api/v1/users/me"), "production A2A key must use Clerk /me path");
|
||||
assert.equal(auth, "Bearer test-a2a-key");
|
||||
} finally {
|
||||
Object.assign(mutableConfig, savedConfig);
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
}
|
||||
|
||||
// Clerk JWTs always use /me, even when a spoofed x-growqr-user is present.
|
||||
{
|
||||
const originalFetch = globalThis.fetch;
|
||||
let target = "";
|
||||
let auth = "";
|
||||
let forwardedScope: string | null = null;
|
||||
globalThis.fetch = (async (input: URL | Request, init?: RequestInit) => {
|
||||
target = String(input instanceof URL ? input : input.url);
|
||||
const headers = new Headers(init?.headers ?? {});
|
||||
auth = headers.get("authorization") ?? "";
|
||||
forwardedScope = headers.get("x-growqr-user");
|
||||
return new Response(JSON.stringify({ preferences: { onboarding: { revision: 10 } } }), { status: 200 });
|
||||
}) as typeof globalThis.fetch;
|
||||
try {
|
||||
const req = new Request("https://backend.test/users/onboarding", {
|
||||
headers: { authorization: "Bearer eyJ.clerk.jwt", "x-growqr-user": "user_spoof" },
|
||||
});
|
||||
await fetchOnboardingReadProfile(req, "user_real");
|
||||
assert.ok(target.endsWith("/api/v1/users/me"), `Clerk target must be /me: ${target}`);
|
||||
assert.equal(auth, "Bearer eyJ.clerk.jwt", "Clerk JWT must be forwarded unchanged");
|
||||
assert.equal(forwardedScope, null, "spoofable x-growqr-user must not be forwarded to /me");
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
}
|
||||
|
||||
// An invalid Clerk JWT remains a /me error and never falls back to A2A.
|
||||
{
|
||||
const originalFetch = globalThis.fetch;
|
||||
let calls = 0;
|
||||
globalThis.fetch = (async () => { calls += 1; return new Response('{"detail":"invalid"}', { status: 401 }); }) as typeof globalThis.fetch;
|
||||
try {
|
||||
const req = new Request("https://backend.test/users/onboarding", { headers: { authorization: "Bearer invalid.jwt" } });
|
||||
await assert.rejects(() => fetchOnboardingReadProfile(req, "user_real"), /user-service \/me fetch failed: 401/);
|
||||
assert.equal(calls, 1, "invalid Clerk JWT must not trigger an A2A fallback");
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
}
|
||||
// ── 8. trusted token resolution excludes A2A in production ────────────────
|
||||
{
|
||||
assert.equal(config.a2aAllowedKey, process.env.A2A_ALLOWED_KEY ?? "", "A2A config must not have a dev fallback");
|
||||
const production = resolveTrustedServiceTokens({
|
||||
nodeEnv: "production",
|
||||
serviceToken: "service-token",
|
||||
a2aAllowedKey: "a2a-token",
|
||||
});
|
||||
assert.equal(production.has("service-token"), true);
|
||||
assert.equal(production.has("a2a-token"), false, "production must exclude A2A token");
|
||||
|
||||
const development = resolveTrustedServiceTokens({
|
||||
nodeEnv: "development",
|
||||
serviceToken: "service-token",
|
||||
a2aAllowedKey: "a2a-token",
|
||||
});
|
||||
assert.equal(development.has("service-token"), true);
|
||||
assert.equal(development.has("a2a-token"), true, "development must include configured A2A token");
|
||||
}
|
||||
|
||||
// Compose must never inject a development service token in production.
|
||||
{
|
||||
const compose = readFileSync(new URL("../docker-compose.yml", import.meta.url), "utf8");
|
||||
assert.doesNotMatch(compose, /dev-service-token/, "compose must not contain a development service token fallback");
|
||||
assert.match(compose, /SERVICE_TOKEN:\s*\$\{SERVICE_TOKEN:-\}/, "compose SERVICE_TOKEN default must be explicitly empty");
|
||||
}
|
||||
|
||||
|
||||
console.log("user-profile: all assertions passed");
|
||||
15
scripts/ws-ticket.test.ts
Normal file
15
scripts/ws-ticket.test.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { signWsTicket, verifyWsTicket } from "../src/services/ws-tickets.js";
|
||||
|
||||
const issued = signWsTicket({ secret: "ticket-secret", audience: "interview", subject: "user-1", sessionId: "session-1", nowSeconds: 1_000 });
|
||||
assert.equal(verifyWsTicket({ secret: "ticket-secret", ticket: issued.ticket, audience: "interview", sessionId: "session-1", subject: "user-1", nowSeconds: 1_001 }).sid, "session-1");
|
||||
for (const invalid of [
|
||||
{ audience: "roleplay" as const, sessionId: "session-1", subject: "user-1", nowSeconds: 1_001 },
|
||||
{ audience: "interview" as const, sessionId: "session-2", subject: "user-1", nowSeconds: 1_001 },
|
||||
{ audience: "interview" as const, sessionId: "session-1", subject: "user-2", nowSeconds: 1_001 },
|
||||
{ audience: "interview" as const, sessionId: "session-1", subject: "user-1", nowSeconds: 1_061 },
|
||||
]) assert.throws(() => verifyWsTicket({ secret: "ticket-secret", ticket: issued.ticket, ...invalid }));
|
||||
assert.throws(() => verifyWsTicket({ secret: "ticket-secret", ticket: "not-a-ticket", audience: "interview", sessionId: "session-1", nowSeconds: 1_001 }));
|
||||
assert.throws(() => verifyWsTicket({ secret: "a2a-key", ticket: issued.ticket, audience: "interview", sessionId: "session-1", subject: "user-1", nowSeconds: 1_001 }));
|
||||
assert.throws(() => signWsTicket({ secret: "", audience: "interview", subject: "user-1", sessionId: "session-1" }));
|
||||
console.log("ws-ticket tests passed");
|
||||
@@ -4,12 +4,11 @@ import { db } from "../../db/client.js";
|
||||
import {
|
||||
growActiveMissions,
|
||||
growEvents,
|
||||
growQscoreSignals,
|
||||
missionActions,
|
||||
} from "../../db/schema.js";
|
||||
import { listActiveMissionsPg } from "../../grow/persistence.js";
|
||||
import { listMissionActions } from "../../missions/actions.js";
|
||||
import { DEFAULT_QSCORE_ORG_ID, getQscoreFromService } from "../../services/qscore-proxy.js";
|
||||
import { DEFAULT_QSCORE_ORG_ID, getQscoreFromService, getQscoreLatestSignalsFromService } from "../../services/qscore-proxy.js";
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === "object" && !Array.isArray(value);
|
||||
@@ -30,7 +29,6 @@ async function platformAnalytics() {
|
||||
completedMissions,
|
||||
totalActions,
|
||||
doneActions,
|
||||
qscoreSignalCount,
|
||||
] = await Promise.all([
|
||||
scalarCount(growEvents),
|
||||
scalarCount(growEvents, eq(growEvents.category, "service")),
|
||||
@@ -39,7 +37,6 @@ async function platformAnalytics() {
|
||||
scalarCount(growActiveMissions, eq(growActiveMissions.status, "completed")),
|
||||
scalarCount(missionActions),
|
||||
scalarCount(missionActions, eq(missionActions.status, "done")),
|
||||
scalarCount(growQscoreSignals),
|
||||
]);
|
||||
|
||||
const serviceUsage = await db
|
||||
@@ -65,29 +62,28 @@ async function platformAnalytics() {
|
||||
completedMissions,
|
||||
missionActions: totalActions,
|
||||
completedActions: doneActions,
|
||||
qscoreSignals: qscoreSignalCount,
|
||||
},
|
||||
serviceUsage,
|
||||
};
|
||||
}
|
||||
|
||||
async function userQscoreAnalytics(userId: string) {
|
||||
const result = await getQscoreFromService(userId, DEFAULT_QSCORE_ORG_ID);
|
||||
const [result, latestEvidence] = await Promise.all([
|
||||
getQscoreFromService(userId, DEFAULT_QSCORE_ORG_ID),
|
||||
getQscoreLatestSignalsFromService(userId, DEFAULT_QSCORE_ORG_ID),
|
||||
]);
|
||||
|
||||
const breakdown = result?.breakdown ?? {};
|
||||
const latestSignals = Array.isArray(breakdown.signals) ? breakdown.signals : [];
|
||||
const timelineRaw = Array.isArray(breakdown.signalTimeline) ? breakdown.signalTimeline : latestSignals;
|
||||
const signalTimeline = timelineRaw.map((item) => {
|
||||
const s = isRecord(item) ? item : {};
|
||||
return {
|
||||
signalId: typeof s.signalId === "string" ? s.signalId : typeof s.signal_id === "string" ? s.signal_id : "",
|
||||
score: typeof s.score === "number" ? s.score : 0,
|
||||
present: typeof s.present === "boolean" ? s.present : true,
|
||||
source: typeof s.source === "string" ? s.source : "",
|
||||
occurredAt: typeof s.occurredAt === "string" ? s.occurredAt : result?.calculated_at ?? new Date().toISOString(),
|
||||
updatedAt: typeof s.updatedAt === "string" ? s.updatedAt : result?.calculated_at ?? new Date().toISOString(),
|
||||
};
|
||||
});
|
||||
const latestSignals = latestEvidence ?? [];
|
||||
const timelineRaw = latestSignals;
|
||||
const signalTimeline = timelineRaw.map((signal) => ({
|
||||
signalId: signal.signal_id,
|
||||
score: signal.score,
|
||||
present: signal.present,
|
||||
source: signal.source,
|
||||
occurredAt: signal.last_occurred_at,
|
||||
updatedAt: signal.last_seen_at || null,
|
||||
}));
|
||||
|
||||
return {
|
||||
kind: "user-qscore",
|
||||
@@ -95,13 +91,13 @@ async function userQscoreAnalytics(userId: string) {
|
||||
generatedAt: new Date().toISOString(),
|
||||
current: result
|
||||
? {
|
||||
score: result.q_score,
|
||||
rq_score: result.rq_score,
|
||||
iq_score: result.iq_score,
|
||||
eq_score: result.eq_score,
|
||||
sq_score: result.sq_score,
|
||||
signalCount: signalTimeline.length,
|
||||
dimensions: isRecord(breakdown.dimensions) ? breakdown.dimensions : result.quotients,
|
||||
summary: typeof breakdown.summary === "string" ? breakdown.summary : null,
|
||||
summary: null,
|
||||
updatedAt: result.calculated_at || new Date().toISOString(),
|
||||
}
|
||||
: null,
|
||||
|
||||
@@ -75,7 +75,7 @@ function safeAgentRegistry() {
|
||||
{ id: "interview", name: "Interview Agent", role: "Interview Coach", service: "interview-service", description: "Interview prep specialist.", toolNames: ["prepare_interview_handoff"] },
|
||||
{ id: "roleplay", name: "Roleplay Agent", role: "Roleplay Coach", service: "roleplay-service", description: "Workplace conversation practice specialist.", toolNames: ["prepare_roleplay_handoff"] },
|
||||
{ id: "resume", name: "Resume Agent", role: "Resume Agent", service: "resume-service", description: "Resume positioning and optimization specialist.", toolNames: ["prepare_resume_handoff"] },
|
||||
{ id: "qscore", name: "Q Score Agent", role: "Q Score Analyst", service: "qscore-service", description: "Readiness score analyst.", toolNames: ["explain_qscore"] },
|
||||
{ id: "qscore", name: "RQ Score Agent", role: "RQ Score Analyst", service: "qscore-service", description: "Readiness score analyst.", toolNames: ["explain_qscore"] },
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ This mission is not an autonomous agent. The conversation layer (Grow) owns the
|
||||
2. Interview prep plan
|
||||
3. Mock interview session
|
||||
4. Communication roleplay
|
||||
5. Final readiness Q Score
|
||||
5. Final readiness RQ Score
|
||||
|
||||
## Artifacts
|
||||
- Interview prep plan
|
||||
|
||||
@@ -213,7 +213,7 @@ function buildUnifiedTools(): Array<{
|
||||
type: "function" as const,
|
||||
function: {
|
||||
name: "compute_qscore",
|
||||
description: "Compute or refresh the user's Q Score via the qscore-service microservice.",
|
||||
description: "Compute or refresh the user's RQ Score via the qscore-service microservice.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {},
|
||||
@@ -253,7 +253,7 @@ function buildUnifiedTools(): Array<{
|
||||
type: "function" as const,
|
||||
function: {
|
||||
name: "start_interview_to_offer",
|
||||
description: "Start the Interview-to-Offer Accelerator workflow. This is a guided end-to-end pipeline: (1) Analyze and tailor the resume for the role, (2) Create mock interview practice, (3) Create mock roleplay practice, (4) Compute Q Score readiness. Use this when the user has a specific interview scheduled and wants comprehensive preparation.",
|
||||
description: "Start the Interview-to-Offer Accelerator workflow. This is a guided end-to-end pipeline: (1) Analyze and tailor the resume for the role, (2) Create mock interview practice, (3) Create mock roleplay practice, (4) Compute RQ Score readiness. Use this when the user has a specific interview scheduled and wants comprehensive preparation.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
@@ -810,7 +810,7 @@ async function dispatchUnifiedTool(
|
||||
|
||||
case "compute_qscore": {
|
||||
const qscoreModule = getSubAgentModule("qscore");
|
||||
if (!qscoreModule?.service) return { ok: false, error: "Q Score Agent module not available" };
|
||||
if (!qscoreModule?.service) return { ok: false, error: "RQ Score Agent module not available" };
|
||||
const result = await runServiceAgentProbe(
|
||||
{ id: qscoreModule.id, name: qscoreModule.name, role: qscoreModule.role, kind: "score", description: qscoreModule.description, service: qscoreModule.service },
|
||||
{ userId, goal: c.state.workflowGoal || "general assessment" },
|
||||
@@ -929,12 +929,12 @@ async function dispatchUnifiedTool(
|
||||
|
||||
c.broadcast("workflow.updated", workflowSnapshot(c.state));
|
||||
|
||||
// Step 4: Q Score — compute readiness
|
||||
// Step 4: RQ Score — compute readiness
|
||||
const qscoreModule = getSubAgentModule("qscore");
|
||||
const qscoreMod = c.state.modules.find(m => m.id === "qscore");
|
||||
if (qscoreMod && qscoreModule?.service) {
|
||||
qscoreMod.status = "running";
|
||||
appendTimelineEvent(c.state, qscoreMod, "module", "Q Score is computing your readiness score...");
|
||||
appendTimelineEvent(c.state, qscoreMod, "module", "RQ Score is computing your readiness score...");
|
||||
c.broadcast("workflow.updated", workflowSnapshot(c.state));
|
||||
|
||||
try {
|
||||
@@ -947,7 +947,7 @@ async function dispatchUnifiedTool(
|
||||
appendTimelineEvent(c.state, qscoreMod, "module", qscoreResult.summary);
|
||||
} catch (err) {
|
||||
qscoreMod.status = "blocked";
|
||||
appendTimelineEvent(c.state, qscoreMod, "module", `Q Score computation failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
appendTimelineEvent(c.state, qscoreMod, "module", `RQ Score computation failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -181,7 +181,7 @@ function serviceStartReply(task: DailyMissionTask) {
|
||||
return "This is a Roleplay service handoff. Tell me the scenario you want to practice and the outcome you want from the conversation.";
|
||||
}
|
||||
if (service.includes("q score") || service.includes("qscore") || routePath.includes("/agents/qscore")) {
|
||||
return "This is a Q Score check. Tell me the signal you want to improve or the readiness question you want scored.";
|
||||
return "This is an RQ Score check. Tell me the signal you want to improve or the readiness question you want scored.";
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -14,6 +14,17 @@ export type AuthContext = {
|
||||
};
|
||||
};
|
||||
|
||||
/** Resolve inbound service tokens without allowing A2A in production. */
|
||||
export function resolveTrustedServiceTokens(input: {
|
||||
nodeEnv: string;
|
||||
serviceToken: string;
|
||||
a2aAllowedKey: string;
|
||||
}): ReadonlySet<string> {
|
||||
return new Set(
|
||||
[input.serviceToken, input.nodeEnv !== "production" ? input.a2aAllowedKey : ""].filter(Boolean),
|
||||
);
|
||||
}
|
||||
|
||||
export const clerk = config.clerkSecretKey
|
||||
? createClerkClient({ secretKey: config.clerkSecretKey })
|
||||
: null;
|
||||
@@ -28,12 +39,7 @@ export const requireUser = createMiddleware<AuthContext>(async (c, next) => {
|
||||
|
||||
// Service-to-service path (Grow stack calling backend).
|
||||
// Header `x-growqr-user` is REQUIRED so we can scope the call.
|
||||
const trustedServiceTokens = new Set(
|
||||
[
|
||||
config.serviceToken,
|
||||
config.nodeEnv !== "production" ? config.a2aAllowedKey : "",
|
||||
].filter(Boolean),
|
||||
);
|
||||
const trustedServiceTokens = resolveTrustedServiceTokens(config);
|
||||
|
||||
if (
|
||||
token &&
|
||||
|
||||
@@ -13,6 +13,10 @@ function parseBool(value: string | undefined, fallback: boolean): boolean {
|
||||
}
|
||||
|
||||
|
||||
/** Resolve the optional service-to-service token without implicit defaults. */
|
||||
export function resolveServiceToken(env: Record<string, string | undefined>): string {
|
||||
return env.SERVICE_TOKEN ?? "";
|
||||
}
|
||||
// ── Redis config resolver (pure, testable without module reload) ────────────
|
||||
// Exported so tests can assert gating/URL-inheritance behavior with synthetic
|
||||
// env objects, avoiding ESM module-cache hacks.
|
||||
@@ -56,8 +60,11 @@ export const config = {
|
||||
clerkSecretKey: process.env.CLERK_SECRET_KEY ?? "",
|
||||
clerkPublishableKey: process.env.CLERK_PUBLISHABLE_KEY ?? "",
|
||||
// Optional: lock service-to-service calls (actor → backend).
|
||||
serviceToken: process.env.SERVICE_TOKEN ?? "",
|
||||
a2aAllowedKey: process.env.A2A_ALLOWED_KEY ?? "dev-a2a-key",
|
||||
serviceToken: resolveServiceToken(process.env),
|
||||
// A2A is intentionally unset unless an operator configures it.
|
||||
a2aAllowedKey: process.env.A2A_ALLOWED_KEY ?? "",
|
||||
// Dedicated browser WebSocket ticket signing secret; never reuse A2A credentials.
|
||||
sessionTicketSecret: process.env.SESSION_TICKET_SECRET ?? "",
|
||||
|
||||
// Service → backend event stream. Redis is optional; HTTP /events/ingest/service is always available.
|
||||
// Explicit opt-in flags: both Redis ingestion paths are default-off. The REST
|
||||
@@ -113,6 +120,7 @@ export const config = {
|
||||
process.env.ROLEPLAY_PUBLIC_URL ?? process.env.ROLEPLAY_SERVICE_URL ?? "http://localhost:8008",
|
||||
qscoreServiceUrl:
|
||||
process.env.QSCORE_SERVICE_URL ?? "http://localhost:8000",
|
||||
qscoreEventDeliveryTimeoutMs: Number(process.env.QSCORE_EVENT_DELIVERY_TIMEOUT_MS ?? 5_000),
|
||||
resumeServiceUrl:
|
||||
process.env.RESUME_SERVICE_URL ?? "http://localhost:8002",
|
||||
userServiceUrl:
|
||||
@@ -197,4 +205,3 @@ export const config = {
|
||||
|
||||
required, // exported so other modules can fail fast on boot
|
||||
} as const;
|
||||
|
||||
|
||||
@@ -59,10 +59,35 @@ async function ensureOnboardingTable() {
|
||||
await db.execute(`CREATE UNIQUE INDEX IF NOT EXISTS onboarding_user_idx ON onboarding (user_id)`);
|
||||
}
|
||||
|
||||
async function ensureEventOutboxTable() {
|
||||
await db.execute(`
|
||||
CREATE TABLE IF NOT EXISTS event_outbox (
|
||||
id text PRIMARY KEY DEFAULT gen_random_uuid()::text NOT NULL,
|
||||
topic text NOT NULL,
|
||||
aggregate_id text NOT NULL,
|
||||
destination text NOT NULL,
|
||||
payload jsonb NOT NULL,
|
||||
headers jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
status text NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'processing', 'published')),
|
||||
attempts integer NOT NULL DEFAULT 0,
|
||||
available_at timestamp with time zone NOT NULL DEFAULT now(),
|
||||
locked_at timestamp with time zone,
|
||||
published_at timestamp with time zone,
|
||||
last_error text,
|
||||
response jsonb,
|
||||
created_at timestamp with time zone NOT NULL DEFAULT now(),
|
||||
updated_at timestamp with time zone NOT NULL DEFAULT now()
|
||||
)
|
||||
`);
|
||||
await db.execute(`CREATE INDEX IF NOT EXISTS event_outbox_delivery_idx ON event_outbox (status, available_at)`);
|
||||
await db.execute(`CREATE UNIQUE INDEX IF NOT EXISTS event_outbox_topic_aggregate_idx ON event_outbox (topic, aggregate_id)`);
|
||||
}
|
||||
|
||||
export async function ensureRuntimeSchema() {
|
||||
await ensureUserPlanColumn();
|
||||
await ensureGrowConversationsMetadataColumn();
|
||||
await ensureSystemNotificationsTables();
|
||||
await ensureOnboardingTable();
|
||||
await ensureEventOutboxTable();
|
||||
log.info("runtime schema ensured");
|
||||
}
|
||||
|
||||
@@ -279,7 +279,7 @@ export const qscoreSnapshots = pgTable("qscore_snapshots", {
|
||||
userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
|
||||
runId: text("run_id").references(() => workflowRuns.id, { onDelete: "cascade" }),
|
||||
snapshotType: text("snapshot_type", { enum: ["baseline", "module", "final"] }).notNull(),
|
||||
score: integer("score"),
|
||||
rqScore: integer("rq_score"),
|
||||
payload: jsonb("payload").$type<Record<string, unknown>>(),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
});
|
||||
@@ -380,6 +380,36 @@ export const growEvents = pgTable(
|
||||
}),
|
||||
);
|
||||
|
||||
// Transactional delivery queue for canonical GrowEvents. The grow_events row
|
||||
// and its QScore delivery intent are inserted in the same database transaction;
|
||||
// delivery is retried independently and is idempotent on (topic, aggregateId).
|
||||
export const eventOutbox = pgTable(
|
||||
"event_outbox",
|
||||
{
|
||||
id: text("id").primaryKey().default(sql`gen_random_uuid()::text`),
|
||||
topic: text("topic").notNull(),
|
||||
aggregateId: text("aggregate_id").notNull(),
|
||||
destination: text("destination").notNull(),
|
||||
payload: jsonb("payload").$type<Record<string, unknown>>().notNull(),
|
||||
headers: jsonb("headers").$type<Record<string, string>>().notNull().default(sql`'{}'::jsonb`),
|
||||
status: text("status", {
|
||||
enum: ["pending", "processing", "published"],
|
||||
}).notNull().default("pending"),
|
||||
attempts: integer("attempts").notNull().default(0),
|
||||
availableAt: timestamp("available_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
lockedAt: timestamp("locked_at", { withTimezone: true }),
|
||||
publishedAt: timestamp("published_at", { withTimezone: true }),
|
||||
lastError: text("last_error"),
|
||||
response: jsonb("response").$type<Record<string, unknown>>(),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
},
|
||||
(t) => ({
|
||||
deliveryIdx: index("event_outbox_delivery_idx").on(t.status, t.availableAt),
|
||||
aggregateIdx: uniqueIndex("event_outbox_topic_aggregate_idx").on(t.topic, t.aggregateId),
|
||||
}),
|
||||
);
|
||||
|
||||
export const missionServiceSessions = pgTable(
|
||||
"mission_service_sessions",
|
||||
{
|
||||
@@ -399,7 +429,7 @@ export const missionServiceSessions = pgTable(
|
||||
},
|
||||
(t) => ({
|
||||
userIdx: index("mission_service_sessions_user_idx").on(t.userId, t.updatedAt),
|
||||
externalIdx: uniqueIndex("mission_service_sessions_external_idx").on(t.serviceId, t.externalId),
|
||||
externalIdx: uniqueIndex("mission_service_sessions_external_idx").on(t.userId, t.serviceId, t.externalId),
|
||||
missionIdx: index("mission_service_sessions_mission_idx").on(t.missionInstanceId, t.stageId),
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -53,8 +53,10 @@ export type StoredGrowEvent = GrowEventEnvelope & {
|
||||
|
||||
export type QscoreSignal = {
|
||||
signalId: string;
|
||||
score: number;
|
||||
present: boolean;
|
||||
// QScore owns normalization and scoring. Raw readiness evidence returned by
|
||||
// its event-ingest API may identify a projected signal before a score run.
|
||||
score?: number;
|
||||
present?: boolean;
|
||||
source?: string;
|
||||
raw?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
@@ -27,9 +27,18 @@ function normalizeMission(value: unknown) {
|
||||
return { instanceId, missionId, stageId };
|
||||
}
|
||||
|
||||
function normalizeCorrelation(value: unknown) {
|
||||
function normalizeCorrelation(value: unknown, taskAliases: unknown[] = []) {
|
||||
const record = asRecord(value);
|
||||
const correlation: Record<string, unknown> = { ...record };
|
||||
const correlation: Record<string, unknown> = Object.fromEntries(Object.entries(record).filter(([, value]) => value !== undefined));
|
||||
delete correlation.taskId;
|
||||
delete correlation.curatorTaskId;
|
||||
delete correlation.curator_task_id;
|
||||
delete correlation.task_id;
|
||||
// Curator-launched and standalone service events share this ingress, so optional
|
||||
// task identity is canonicalized once instead of forcing downstream precedence guesses.
|
||||
const taskId = [...[record.task_id, record.taskId, record.curatorTaskId, record.curator_task_id], ...taskAliases]
|
||||
.map(getString)
|
||||
.find((item): item is string => Boolean(item));
|
||||
const requestId = getString(record.requestId ?? record.request_id);
|
||||
const sessionId = getString(record.sessionId ?? record.session_id);
|
||||
const resumeId = getString(record.resumeId ?? record.resume_id);
|
||||
@@ -38,9 +47,37 @@ function normalizeCorrelation(value: unknown) {
|
||||
if (sessionId) correlation.sessionId = sessionId;
|
||||
if (resumeId) correlation.resumeId = resumeId;
|
||||
if (externalId) correlation.externalId = externalId;
|
||||
if (taskId) correlation.task_id = taskId;
|
||||
return Object.keys(correlation).length ? correlation : undefined;
|
||||
}
|
||||
|
||||
function normalizeTaskAliases(raw: Record<string, unknown>, payload: Record<string, unknown>) {
|
||||
return [raw.task_id, raw.taskId, raw.curatorTaskId, raw.curator_task_id, payload.task_id, payload.taskId, payload.curatorTaskId, payload.curator_task_id];
|
||||
}
|
||||
|
||||
const CANONICAL_EVENT_TYPE: Record<string, string> = {
|
||||
"interview.review.completed": "interview.feedback.generated",
|
||||
"interview.session_completed": "interview.session.completed",
|
||||
"roleplay.session.completed": "roleplay.scenario.completed",
|
||||
"roleplay.session_completed": "roleplay.scenario.completed",
|
||||
"roleplay.review.completed": "roleplay.feedback.generated",
|
||||
"course.video.completed": "course.completed",
|
||||
"course.lesson.completed": "course.completed",
|
||||
"course.video.started": "course.started",
|
||||
"course.lesson.started": "course.started",
|
||||
"course.progress_recorded": "course.progress.recorded",
|
||||
"social.profile.synced": "brand.profile.updated",
|
||||
"social.profile.updated": "brand.profile.updated",
|
||||
"social.post.scheduled": "brand.post.exported",
|
||||
"social.post.published": "brand.content.published",
|
||||
"social.content.generated": "brand.post.drafted",
|
||||
"social.account.connected": "brand.account.connected",
|
||||
};
|
||||
|
||||
export function canonicalGrowEventType(type: string): string {
|
||||
return CANONICAL_EVENT_TYPE[type] ?? type;
|
||||
}
|
||||
|
||||
export function normalizeGrowEvent(input: unknown, overrides: { userId?: string; source?: string } = {}): GrowEventEnvelope {
|
||||
const raw = asRecord(input);
|
||||
const payload = asRecord(raw.payload ?? raw.data ?? raw.event_payload);
|
||||
@@ -49,7 +86,7 @@ export function normalizeGrowEvent(input: unknown, overrides: { userId?: string;
|
||||
resume_id: raw.resume_id ?? payload.resume_id,
|
||||
external_id: raw.external_id ?? payload.external_id,
|
||||
request_id: raw.request_id ?? payload.request_id,
|
||||
});
|
||||
}, normalizeTaskAliases(raw, payload));
|
||||
const subject = normalizeSubject(raw.subject) ?? (() => {
|
||||
const serviceId = getString(raw.subject_service_id ?? payload.subject_service_id);
|
||||
const externalId = getString(raw.subject_external_id ?? payload.subject_external_id);
|
||||
@@ -60,7 +97,7 @@ export function normalizeGrowEvent(input: unknown, overrides: { userId?: string;
|
||||
})();
|
||||
|
||||
const source = overrides.source ?? getString(raw.source) ?? "unknown-service";
|
||||
const type = getString(raw.type ?? raw.event_type ?? raw.action) ?? "service.event";
|
||||
const type = canonicalGrowEventType(getString(raw.type ?? raw.event_type ?? raw.action) ?? "service.event");
|
||||
const categoryRaw = raw.category ?? payload.category;
|
||||
const category = isGrowEventCategory(categoryRaw) ? categoryRaw : "service";
|
||||
const userId = overrides.userId ?? getString(raw.userId ?? raw.user_id ?? payload.userId ?? payload.user_id);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { and, desc, eq, inArray } from "drizzle-orm";
|
||||
import { and, desc, eq, inArray, or, sql } from "drizzle-orm";
|
||||
import { db } from "../db/client.js";
|
||||
import { growEvents, type GrowEventRow } from "../db/schema.js";
|
||||
import { asRecord } from "./envelope.js";
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
markGrowEventProcessing,
|
||||
recordGrowEvent,
|
||||
} from "./record-grow-event.js";
|
||||
import { ensureOnboardingBaselineQscoreForCompletedAt } from "./onboarding-qscore.js";
|
||||
import {
|
||||
onboardingCompletedAtFromEvent,
|
||||
runCuratorOnboardingLoopSafely,
|
||||
@@ -38,11 +37,23 @@ export type OnboardingStatusEvent = {
|
||||
processingStatus: string;
|
||||
};
|
||||
|
||||
const COMPLETION_EVENT_TYPES = new Set<string>([
|
||||
"onboarding.completed",
|
||||
"user.onboarding.completed",
|
||||
"profile.onboarding.completed",
|
||||
]);
|
||||
export const COMPLETION_EVENT_TYPES: Record<string, true> = {
|
||||
"onboarding.completed": true,
|
||||
"user.onboarding.completed": true,
|
||||
"profile.onboarding.completed": true,
|
||||
};
|
||||
|
||||
// Every ledger row whose type (dotted OR underscore alias) marks a completion.
|
||||
// resetOnboardingLedger deletes all of these so the gate reopens regardless of
|
||||
// which alias form a legacy emitter wrote.
|
||||
export const COMPLETION_EVENT_TYPE_ALIASES: readonly string[] = [
|
||||
...Object.keys(COMPLETION_EVENT_TYPES),
|
||||
...Object.keys(COMPLETION_EVENT_TYPES).map((t) => t.replaceAll(".", "_")),
|
||||
];
|
||||
|
||||
// Snapshot ledger types in both alias forms. Only completion-bearing snapshots
|
||||
// are reset; intermediate step saves are preserved.
|
||||
export const SNAPSHOT_EVENT_TYPE_ALIASES = ["onboarding.snapshot.saved", "onboarding_snapshot_saved"] as const;
|
||||
|
||||
export function normalizeOnboardingEventType(type: string) {
|
||||
return type.toLowerCase().replaceAll("_", ".");
|
||||
@@ -72,7 +83,7 @@ export function completedAtFromOnboardingPayload(payload: Record<string, unknown
|
||||
|
||||
export function isValidOnboardingLedgerEvent(event: Pick<GrowEventRow, "type" | "payload">) {
|
||||
const normalizedType = normalizeOnboardingEventType(event.type);
|
||||
if (COMPLETION_EVENT_TYPES.has(normalizedType)) return true;
|
||||
if (COMPLETION_EVENT_TYPES[normalizedType]) return true;
|
||||
|
||||
// Snapshots are status-valid only when they are completion snapshots. Plain
|
||||
// intermediate step saves must not let a new seeker bypass onboarding.
|
||||
@@ -108,12 +119,6 @@ export async function getLatestValidOnboardingLedgerEvent(userId: string): Promi
|
||||
return statusEvent;
|
||||
}
|
||||
|
||||
export async function ensureOnboardingBaselineQscoreFromLedger(userId: string) {
|
||||
const event = await getLatestValidOnboardingLedgerEvent(userId);
|
||||
if (!event) return false;
|
||||
return ensureOnboardingBaselineQscoreForCompletedAt(userId, event.occurredAt);
|
||||
}
|
||||
|
||||
function onboardingContextFromInput(context?: Record<string, unknown>) {
|
||||
const input = context ?? {};
|
||||
const preferences = asRecord(input.preferences);
|
||||
@@ -125,10 +130,9 @@ function onboardingContextFromInput(context?: Record<string, unknown>) {
|
||||
};
|
||||
}
|
||||
|
||||
export async function ensureOnboardingSideEffectsForEvent(event: GrowEventRow) {
|
||||
export async function ensureOnboardingSideEffectsForEvent(event: GrowEventRow, contextOverride?: Record<string, unknown>) {
|
||||
if (!event.userId || !isValidOnboardingLedgerEvent(event)) {
|
||||
return {
|
||||
qscoreBaselineSeeded: false,
|
||||
curatorOnboarding: { status: "skipped" as const, reason: event.userId ? "not_onboarding_completion" : "missing_user_id" },
|
||||
missions: { status: "skipped" as const, reason: event.userId ? "not_onboarding_completion" : "missing_user_id", started: [], existing: [] },
|
||||
};
|
||||
@@ -139,13 +143,12 @@ export async function ensureOnboardingSideEffectsForEvent(event: GrowEventRow) {
|
||||
completedAtFromOnboardingPayload(event.payload) ??
|
||||
event.occurredAt.toISOString();
|
||||
|
||||
const qscoreBaselineSeeded = await ensureOnboardingBaselineQscoreForCompletedAt(event.userId, completedAt);
|
||||
const curatorOnboarding = await runCuratorOnboardingLoopSafely({
|
||||
userId: event.userId,
|
||||
completedAt,
|
||||
sourceEventId: event.id,
|
||||
source: event.source,
|
||||
context: onboardingContextFromInput(event.payload),
|
||||
context: contextOverride ?? onboardingContextFromInput(event.payload),
|
||||
});
|
||||
const missions = {
|
||||
status: "skipped" as const,
|
||||
@@ -154,7 +157,7 @@ export async function ensureOnboardingSideEffectsForEvent(event: GrowEventRow) {
|
||||
existing: [],
|
||||
};
|
||||
|
||||
return { qscoreBaselineSeeded, curatorOnboarding, missions };
|
||||
return { curatorOnboarding, missions };
|
||||
}
|
||||
|
||||
export async function recordAndProcessOnboardingCompletion(input: {
|
||||
@@ -188,7 +191,7 @@ export async function recordAndProcessOnboardingCompletion(input: {
|
||||
await markGrowEventProcessing(event.id);
|
||||
}
|
||||
|
||||
const sideEffects = await ensureOnboardingSideEffectsForEvent(event);
|
||||
const sideEffects = await ensureOnboardingSideEffectsForEvent(event, context);
|
||||
if (sideEffects.curatorOnboarding.status === "skipped" && sideEffects.curatorOnboarding.reason === "loop_failed") {
|
||||
await markGrowEventFailed(event.id, new Error("curator_onboarding_loop_failed"));
|
||||
} else if (event.processingStatus !== "processed") {
|
||||
@@ -197,3 +200,329 @@ export async function recordAndProcessOnboardingCompletion(input: {
|
||||
|
||||
return { event, ...sideEffects };
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// Onboarding preferences helpers (pure — no DB, no network).
|
||||
//
|
||||
// The canonical onboarding store is `preferences.onboarding` on the user-service
|
||||
// profile, shaped as the v3 OnboardingData object (schema_version: 3). These
|
||||
// helpers read/validate/merge that blob and derive the faithful projection used
|
||||
// by the curator. Completion percentages and QX estimates are OWNED by the
|
||||
// dashboard (branch-aware); the backend persists the client-supplied values and
|
||||
// never recomputes them.
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export type OnboardingAccessChoice = "trial" | "full" | null;
|
||||
|
||||
export type OnboardingData = {
|
||||
schema_version: 3;
|
||||
revision: number;
|
||||
status: "in_progress" | "completed";
|
||||
progress: { stage: string; branch_index: number; updated_at: string | null };
|
||||
consent: { privacy_accepted: boolean; accepted_at: string | null; terms_version: string };
|
||||
profile: {
|
||||
intent: string | null;
|
||||
mode: string | null;
|
||||
icp: string | null;
|
||||
question_branch: string | null;
|
||||
};
|
||||
responses: {
|
||||
current_situation: string | null;
|
||||
career_barriers: string[];
|
||||
desired_outcomes: string[];
|
||||
target_milestone: string | null;
|
||||
weekly_time_commitment: string | null;
|
||||
target_role: string | null;
|
||||
target_field: string | null;
|
||||
venture_industry: string | null;
|
||||
experience_level: string | null;
|
||||
work_context: string | null;
|
||||
};
|
||||
import: {
|
||||
method: string;
|
||||
status: string;
|
||||
resume_id: string | null;
|
||||
resume_filename: string | null;
|
||||
resume_summary: string | null;
|
||||
linkedin_profile_id: string | null;
|
||||
linkedin_url: string | null;
|
||||
};
|
||||
access_choice: OnboardingAccessChoice;
|
||||
qx_estimate: number | null;
|
||||
completed_at: string | null;
|
||||
};
|
||||
|
||||
export type OnboardingPayload = {
|
||||
schema_version: 1;
|
||||
source_revision: number;
|
||||
primary_intent: string | null;
|
||||
mode: string | null;
|
||||
question_branch: string | null;
|
||||
onboarding_icp: string | null;
|
||||
curator_registry_icp: string | null;
|
||||
target_role: string | null;
|
||||
target_field: string | null;
|
||||
experience_context: string | null;
|
||||
goals: string[];
|
||||
barriers: string[];
|
||||
priority: string | null;
|
||||
weekly_time_commitment: string | null;
|
||||
};
|
||||
|
||||
export const DEFAULT_ONBOARDING_DATA: OnboardingData = {
|
||||
schema_version: 3,
|
||||
revision: 0,
|
||||
status: "in_progress",
|
||||
progress: { stage: "consent", branch_index: 0, updated_at: null },
|
||||
consent: { privacy_accepted: false, accepted_at: null, terms_version: "2026-07" },
|
||||
profile: { intent: null, mode: null, icp: null, question_branch: null },
|
||||
responses: {
|
||||
current_situation: null,
|
||||
career_barriers: [],
|
||||
desired_outcomes: [],
|
||||
target_milestone: null,
|
||||
weekly_time_commitment: null,
|
||||
target_role: null,
|
||||
target_field: null,
|
||||
venture_industry: null,
|
||||
experience_level: null,
|
||||
work_context: null,
|
||||
},
|
||||
import: {
|
||||
method: "manual",
|
||||
status: "not_started",
|
||||
resume_id: null,
|
||||
resume_filename: null,
|
||||
resume_summary: null,
|
||||
linkedin_profile_id: null,
|
||||
linkedin_url: null,
|
||||
},
|
||||
access_choice: null,
|
||||
qx_estimate: null,
|
||||
completed_at: null,
|
||||
};
|
||||
|
||||
export function defaultOnboardingData(): OnboardingData {
|
||||
return structuredClone(DEFAULT_ONBOARDING_DATA);
|
||||
}
|
||||
|
||||
function isObject(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Coerce an arbitrary stored value into a valid v3 OnboardingData, filling gaps
|
||||
* from the defaults. Rejects non-object shapes and falls back to defaults so the
|
||||
* route always returns a well-formed v3 response (never throws).
|
||||
*/
|
||||
export function extractOnboardingData(stored: unknown): OnboardingData {
|
||||
const base = defaultOnboardingData();
|
||||
if (!isObject(stored)) return base;
|
||||
|
||||
const progress = isObject(stored.progress) ? stored.progress : {};
|
||||
const consent = isObject(stored.consent) ? stored.consent : {};
|
||||
const profile = isObject(stored.profile) ? stored.profile : {};
|
||||
const responses = isObject(stored.responses) ? stored.responses : {};
|
||||
const imp = isObject(stored.import) ? stored.import : {};
|
||||
|
||||
const rev = typeof stored.revision === "number" && Number.isFinite(stored.revision) ? stored.revision : base.revision;
|
||||
const status = stored.status === "completed" ? "completed" : "in_progress";
|
||||
const access = stored.access_choice === "trial" || stored.access_choice === "full" ? stored.access_choice : null;
|
||||
const qx = typeof stored.qx_estimate === "number" && Number.isFinite(stored.qx_estimate) ? stored.qx_estimate : null;
|
||||
const completed = typeof stored.completed_at === "string" && stored.completed_at.trim() ? stored.completed_at : null;
|
||||
|
||||
return {
|
||||
schema_version: 3,
|
||||
revision: rev,
|
||||
status,
|
||||
progress: {
|
||||
stage: typeof progress.stage === "string" ? progress.stage : base.progress.stage,
|
||||
branch_index: typeof progress.branch_index === "number" && Number.isFinite(progress.branch_index) ? progress.branch_index : base.progress.branch_index,
|
||||
updated_at: typeof progress.updated_at === "string" && progress.updated_at.trim() ? progress.updated_at : null,
|
||||
},
|
||||
consent: {
|
||||
privacy_accepted: typeof consent.privacy_accepted === "boolean" ? consent.privacy_accepted : base.consent.privacy_accepted,
|
||||
accepted_at: typeof consent.accepted_at === "string" && consent.accepted_at.trim() ? consent.accepted_at : null,
|
||||
terms_version: typeof consent.terms_version === "string" && consent.terms_version.trim() ? consent.terms_version : base.consent.terms_version,
|
||||
},
|
||||
profile: {
|
||||
intent: typeof profile.intent === "string" ? profile.intent : null,
|
||||
mode: typeof profile.mode === "string" ? profile.mode : null,
|
||||
icp: typeof profile.icp === "string" ? profile.icp : null,
|
||||
question_branch: typeof profile.question_branch === "string" ? profile.question_branch : null,
|
||||
},
|
||||
responses: {
|
||||
current_situation: typeof responses.current_situation === "string" ? responses.current_situation : null,
|
||||
career_barriers: Array.isArray(responses.career_barriers) ? responses.career_barriers.filter((x) => typeof x === "string") : [],
|
||||
desired_outcomes: Array.isArray(responses.desired_outcomes) ? responses.desired_outcomes.filter((x) => typeof x === "string") : [],
|
||||
target_milestone: typeof responses.target_milestone === "string" ? responses.target_milestone : null,
|
||||
weekly_time_commitment: typeof responses.weekly_time_commitment === "string" ? responses.weekly_time_commitment : null,
|
||||
target_role: typeof responses.target_role === "string" ? responses.target_role : null,
|
||||
target_field: typeof responses.target_field === "string" ? responses.target_field : null,
|
||||
venture_industry: typeof responses.venture_industry === "string" ? responses.venture_industry : null,
|
||||
experience_level: typeof responses.experience_level === "string" ? responses.experience_level : null,
|
||||
work_context: typeof responses.work_context === "string" ? responses.work_context : null,
|
||||
},
|
||||
import: {
|
||||
method: typeof imp.method === "string" ? imp.method : base.import.method,
|
||||
status: typeof imp.status === "string" ? imp.status : base.import.status,
|
||||
resume_id: typeof imp.resume_id === "string" ? imp.resume_id : null,
|
||||
resume_filename: typeof imp.resume_filename === "string" ? imp.resume_filename : null,
|
||||
resume_summary: typeof imp.resume_summary === "string" ? imp.resume_summary : null,
|
||||
linkedin_profile_id: typeof imp.linkedin_profile_id === "string" ? imp.linkedin_profile_id : null,
|
||||
linkedin_url: typeof imp.linkedin_url === "string" ? imp.linkedin_url : null,
|
||||
},
|
||||
access_choice: access,
|
||||
qx_estimate: qx,
|
||||
completed_at: completed,
|
||||
};
|
||||
}
|
||||
|
||||
export class OnboardingRevisionConflict extends Error {
|
||||
readonly expected: number;
|
||||
readonly actual: number;
|
||||
constructor(expected: number, actual: number) {
|
||||
super(`onboarding revision conflict: expected ${expected}, actual ${actual}`);
|
||||
this.name = "OnboardingRevisionConflict";
|
||||
this.expected = expected;
|
||||
this.actual = actual;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Optimistic-concurrency guard. Throws OnboardingRevisionConflict when the
|
||||
* client's expectedRevision does not match the currently stored revision.
|
||||
* `expectedRevision` may be undefined only when there is no existing revision
|
||||
* (initial create); any mismatch with an existing doc is a conflict.
|
||||
*/
|
||||
export function assertOnboardingRevision(expected: number | undefined, current: OnboardingData): void {
|
||||
if (expected !== current.revision) {
|
||||
throw new OnboardingRevisionConflict(expected ?? -1, current.revision);
|
||||
}
|
||||
}
|
||||
|
||||
function asStringArray(value: unknown): string[] {
|
||||
return Array.isArray(value) ? value.filter((x): x is string => typeof x === "string") : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the outbound OnboardingPayload (schema v1) from the stored v3
|
||||
* OnboardingData. This is a faithful projection — no completion math, no
|
||||
* invented fields. `curator_registry_icp` is left null (the curator resolves
|
||||
* the canonical ICP from `onboarding_icp` at projection time).
|
||||
*/
|
||||
export function deriveOnboardingPayload(data: OnboardingData): OnboardingPayload {
|
||||
const experienceContext = [data.responses.experience_level, data.responses.work_context]
|
||||
.filter((x) => typeof x === "string" && x.trim())
|
||||
.join(" · ") || null;
|
||||
const goals = asStringArray(data.responses.desired_outcomes);
|
||||
const barriers = asStringArray(data.responses.career_barriers);
|
||||
const priority = goals[0] ?? barriers[0] ?? data.responses.target_milestone ?? null;
|
||||
|
||||
return {
|
||||
schema_version: 1,
|
||||
source_revision: data.revision,
|
||||
primary_intent: data.profile.intent,
|
||||
mode: data.profile.mode,
|
||||
question_branch: data.profile.question_branch,
|
||||
onboarding_icp: data.profile.icp,
|
||||
curator_registry_icp: null,
|
||||
target_role: data.responses.target_role,
|
||||
target_field: data.responses.target_field,
|
||||
experience_context: experienceContext,
|
||||
goals,
|
||||
barriers,
|
||||
priority,
|
||||
weekly_time_commitment: data.responses.weekly_time_commitment,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Deep-merge an incoming PATCH `data` (full OnboardingData) into the stored
|
||||
* preferences object, returning a NEW preferences object. Preserves every
|
||||
* unrelated preference key (interview_preferences, resume_preferences,
|
||||
* mission_preferences, target_roles, etc.) untouched. Bumps revision by one
|
||||
* and stamps updated_at.
|
||||
*/
|
||||
export function mergeOnboardingPatch(
|
||||
currentPreferences: Record<string, unknown>,
|
||||
incoming: OnboardingData,
|
||||
): Record<string, unknown> {
|
||||
const updatedAt = new Date().toISOString();
|
||||
const stored = extractOnboardingData(currentPreferences.onboarding);
|
||||
const prevRevision = stored.revision;
|
||||
|
||||
const nextData: OnboardingData = {
|
||||
...incoming,
|
||||
// The client sends the revision it based its edits on; the stored revision
|
||||
// is authoritative. We bump from the previously stored revision.
|
||||
revision: prevRevision + 1,
|
||||
progress: { ...incoming.progress, updated_at: updatedAt },
|
||||
};
|
||||
|
||||
// Status/completed_at consistency: completed ⇒ completed_at must be set.
|
||||
if (nextData.status === "completed" && !nextData.completed_at) {
|
||||
nextData.completed_at = updatedAt;
|
||||
}
|
||||
// If regressed to in_progress, clear a stale completed_at.
|
||||
if (nextData.status === "in_progress" && nextData.completed_at && incoming.completed_at === null) {
|
||||
nextData.completed_at = null;
|
||||
}
|
||||
|
||||
return { ...currentPreferences, onboarding: nextData };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the next preferences object for a reset: replace `preferences.onboarding`
|
||||
* with a fresh default v3 doc (revision 0) and preserve every unrelated
|
||||
* preference key. Pure — no DB, no network. The revision resets to 0 so the
|
||||
* next save starts a fresh optimistic-concurrency lineage.
|
||||
*/
|
||||
export function resetOnboardingPreferences(
|
||||
currentPreferences: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
return { ...currentPreferences, onboarding: defaultOnboardingData() };
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// Onboarding reset
|
||||
//
|
||||
// The onboarding gate reopens when no valid completion ledger row exists for a
|
||||
// user (getLatestValidOnboardingLedgerEvent returns null). The reset deletes:
|
||||
// 1. Every completion-type row in EITHER alias form (dotted like
|
||||
// "onboarding.completed" or underscore like "onboarding_completed") — these
|
||||
// carry the `onboarding:completed:${userId}` dedupe key.
|
||||
// 2. Snapshot rows ("onboarding.snapshot.saved" / "onboarding_snapshot_saved")
|
||||
// ONLY when their payload carries a completion marker (completed_at /
|
||||
// completedAt at the top level or under onboarding). Intermediate step
|
||||
// saves — the vast majority of snapshots — are preserved.
|
||||
// Missions, curator context, and qscore baselines are never touched. Returns
|
||||
// the count of rows removed for audit output.
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// jsonb predicate matching the same paths completedAtFromOnboardingPayload
|
||||
// inspects for snapshot payloads. Mirrors the JS validity rule in SQL so the
|
||||
// reset deletes exactly the snapshots that would otherwise keep the gate shut.
|
||||
const SNAPSHOT_HAS_COMPLETION = sql<boolean>`${growEvents.payload}->>'completed_at' IS NOT NULL
|
||||
OR ${growEvents.payload}->>'completedAt' IS NOT NULL
|
||||
OR ${growEvents.payload}->'onboarding'->>'completed_at' IS NOT NULL
|
||||
OR ${growEvents.payload}->'onboarding'->>'completedAt' IS NOT NULL`;
|
||||
|
||||
export async function resetOnboardingLedger(userId: string): Promise<number> {
|
||||
const deleted = await db
|
||||
.delete(growEvents)
|
||||
.where(
|
||||
and(
|
||||
eq(growEvents.userId, userId),
|
||||
or(
|
||||
inArray(growEvents.type, [...COMPLETION_EVENT_TYPE_ALIASES]),
|
||||
and(
|
||||
inArray(growEvents.type, [...SNAPSHOT_EVENT_TYPE_ALIASES]),
|
||||
SNAPSHOT_HAS_COMPLETION,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
.returning({ id: growEvents.id });
|
||||
return deleted.length;
|
||||
}
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
import { forwardSignalsToQscoreService } from "../services/service-agents.js";
|
||||
import { DEFAULT_QSCORE_ORG_ID } from "../services/qscore-proxy.js";
|
||||
|
||||
export const ONBOARDING_BASELINE_SIGNAL_ID = "onboarding.completed";
|
||||
|
||||
/**
|
||||
* Workbook threshold for a completed onboarding baseline signal
|
||||
* (Complete=Yes → 0.5). qscore_service owns the aggregate score; this is the
|
||||
* per-signal evidence weight forwarded to it.
|
||||
*/
|
||||
export const ONBOARDING_BASELINE_SIGNAL_SCORE = 0.5;
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
|
||||
}
|
||||
|
||||
function parseCompletedAt(value: unknown): Date | null {
|
||||
if (value instanceof Date) {
|
||||
return Number.isNaN(value.getTime()) ? null : value;
|
||||
}
|
||||
if (typeof value !== "string" || !value.trim()) return null;
|
||||
const parsed = new Date(value);
|
||||
return Number.isNaN(parsed.getTime()) ? new Date() : parsed;
|
||||
}
|
||||
|
||||
function onboardingCompletedAt(preferences: Record<string, unknown> | undefined): Date | null {
|
||||
const onboarding = asRecord(preferences?.onboarding);
|
||||
return parseCompletedAt(onboarding.completed_at ?? onboarding.completedAt);
|
||||
}
|
||||
|
||||
/**
|
||||
* Forward an onboarding.completed baseline signal to qscore_service when
|
||||
* onboarding is completed.
|
||||
*
|
||||
* qscore_service OWNS all scoring. The backend no longer seeds local qscore
|
||||
* tables; instead it forwards a single baseline readiness signal and lets
|
||||
* qscore_service compute the score. Idempotency is delegated to qscore_service's
|
||||
* latest-signal upsert semantics.
|
||||
*/
|
||||
export async function ensureOnboardingBaselineQscore(
|
||||
userId: string,
|
||||
preferences: Record<string, unknown> | undefined,
|
||||
): Promise<boolean> {
|
||||
const completedAt = onboardingCompletedAt(preferences);
|
||||
if (!completedAt) return false;
|
||||
return ensureOnboardingBaselineQscoreForCompletedAt(userId, completedAt);
|
||||
}
|
||||
|
||||
export async function ensureOnboardingBaselineQscoreForCompletedAt(
|
||||
userId: string,
|
||||
completedAtInput: string | Date,
|
||||
): Promise<boolean> {
|
||||
const completedAt = parseCompletedAt(completedAtInput);
|
||||
if (!completedAt) return false;
|
||||
|
||||
await forwardSignalsToQscoreService({
|
||||
orgId: DEFAULT_QSCORE_ORG_ID,
|
||||
userId,
|
||||
profession: "student",
|
||||
source: "onboarding",
|
||||
signals: [
|
||||
{
|
||||
signalId: ONBOARDING_BASELINE_SIGNAL_ID,
|
||||
score: ONBOARDING_BASELINE_SIGNAL_SCORE,
|
||||
present: true,
|
||||
raw: {
|
||||
reason: "completed onboarding baseline",
|
||||
completedAt: completedAt.toISOString(),
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -1,385 +1,21 @@
|
||||
import { type GrowEventRow } from "../../db/schema.js";
|
||||
import { asRecord, clampScore, getNumber, getString, type QscoreSignal } from "../envelope.js";
|
||||
import { forwardSignalsToQscoreService } from "../../services/service-agents.js";
|
||||
import { DEFAULT_QSCORE_ORG_ID } from "../../services/qscore-proxy.js";
|
||||
import type { GrowEventRow } from "../../db/schema.js";
|
||||
import { publishQscoreOutboxForEvent } from "../qscore-event-outbox.js";
|
||||
|
||||
function signal(signalId: string, score: number, raw?: Record<string, unknown>, present = true): QscoreSignal {
|
||||
return { signalId, score: clampScore(score), present, raw };
|
||||
}
|
||||
|
||||
function nestedNumber(record: Record<string, unknown>, keys: string[]): number | undefined {
|
||||
for (const key of keys) {
|
||||
const direct = getNumber(record[key]);
|
||||
if (direct !== undefined) return direct;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const RESUME_UPLOAD_BASELINE_SCORE = 35;
|
||||
|
||||
function extractResumeSignals(event: GrowEventRow): QscoreSignal[] {
|
||||
const payload = event.payload ?? {};
|
||||
const analysis = asRecord(payload.analysis ?? payload.result ?? payload);
|
||||
const scoreBreakdown = Array.isArray(analysis.score_breakdown) ? analysis.score_breakdown : [];
|
||||
const dimensions = Array.isArray(analysis.dimensional_scores) ? analysis.dimensional_scores : [];
|
||||
|
||||
const byCategory = new Map<string, number>();
|
||||
for (const item of scoreBreakdown) {
|
||||
const row = asRecord(item);
|
||||
const category = typeof row.category === "string" ? row.category : undefined;
|
||||
const score = getNumber(row.score);
|
||||
if (category && score !== undefined) byCategory.set(category, score);
|
||||
}
|
||||
|
||||
const byDimension = new Map<string, number>();
|
||||
for (const item of dimensions) {
|
||||
const row = asRecord(item);
|
||||
const dimension = typeof row.dimension === "string" ? row.dimension : undefined;
|
||||
const score = getNumber(row.score);
|
||||
if (dimension && score !== undefined) byDimension.set(dimension, score);
|
||||
}
|
||||
|
||||
const signals: QscoreSignal[] = [];
|
||||
if (event.type.includes("uploaded") || event.type.includes("created")) {
|
||||
// Uploading a resume is only a baseline readiness signal. The actual Q Score
|
||||
// should rise from parsed resume/interview/roleplay evidence, not jump to 100
|
||||
// immediately after onboarding.
|
||||
signals.push(signal("resume.uploaded", RESUME_UPLOAD_BASELINE_SCORE, { eventId: event.id }));
|
||||
}
|
||||
const ats = byCategory.get("ATS Compatibility") ?? nestedNumber(analysis, ["ats_score", "ats_compatibility", "atsCompatibility"]);
|
||||
if (ats !== undefined) signals.push(signal("resume.ats_compatibility", ats, { eventId: event.id }));
|
||||
const keywords = byDimension.get("Keywords") ?? nestedNumber(analysis, ["keyword_score", "keyword_relevance", "keywords"]);
|
||||
if (keywords !== undefined) {
|
||||
signals.push(signal("resume.keyword_relevance", keywords, { eventId: event.id }));
|
||||
}
|
||||
const quantification = byDimension.get("Quantification") ?? nestedNumber(analysis, ["quantification_score"]);
|
||||
if (quantification !== undefined) signals.push(signal("resume.quantified_achievements", quantification, { eventId: event.id }));
|
||||
const contentQuality = byCategory.get("Content Quality") ?? nestedNumber(analysis, ["content_quality", "clarity_score"]);
|
||||
if (contentQuality !== undefined) signals.push(signal("resume.grammar_clarity", contentQuality, { eventId: event.id }));
|
||||
const formatting = byCategory.get("Formatting") ?? nestedNumber(analysis, ["formatting", "format_score"]);
|
||||
if (formatting !== undefined) signals.push(signal("resume.format_structure", formatting, { eventId: event.id }));
|
||||
return signals;
|
||||
}
|
||||
|
||||
function interviewSessionsScore(count: number): number {
|
||||
if (count <= 0) return 0;
|
||||
if (count <= 2) return 20;
|
||||
if (count <= 5) return 35;
|
||||
return 50;
|
||||
}
|
||||
|
||||
function interviewDiversityScore(count: number): number {
|
||||
if (count <= 1) return 10;
|
||||
if (count === 2) return 20;
|
||||
return 30;
|
||||
}
|
||||
|
||||
function roleplayScenariosScore(count: number): number {
|
||||
if (count <= 0) return 0;
|
||||
if (count <= 3) return 20;
|
||||
if (count <= 6) return 35;
|
||||
return 50;
|
||||
}
|
||||
|
||||
function extractInterviewSignals(event: GrowEventRow): QscoreSignal[] {
|
||||
const payload = event.payload ?? {};
|
||||
const review = asRecord(payload.review ?? payload.result ?? payload);
|
||||
const status = String(review.status ?? payload.status ?? "");
|
||||
if (!event.type.includes("review") && !event.type.includes("completed") && status !== "completed") return [];
|
||||
|
||||
const signals: QscoreSignal[] = [];
|
||||
// A completed interview must always emit at least a single-completion signal.
|
||||
// When session_count is absent, default to 1 so the event is not silently lost.
|
||||
const sessionCount = getNumber(payload.session_count ?? payload.sessionCount) ?? 1;
|
||||
signals.push(signal("interview.sessions_completed", interviewSessionsScore(sessionCount), { eventId: event.id, sessionCount }));
|
||||
const typeDiversity = getNumber(payload.type_diversity ?? payload.typeDiversity);
|
||||
if (typeDiversity !== undefined) {
|
||||
signals.push(signal("interview.type_diversity", interviewDiversityScore(typeDiversity), { eventId: event.id, typeDiversity }));
|
||||
}
|
||||
const overall = getNumber(review.overall_score ?? review.overallScore ?? payload.overall_score);
|
||||
if (overall !== undefined) signals.push(signal("interview.overall_score", overall, { eventId: event.id }));
|
||||
const rubric = asRecord(review.rubric_scores ?? review.rubricScores);
|
||||
const content = getNumber(rubric.content_quality ?? rubric.content ?? rubric.communication ?? review.communication_score);
|
||||
if (content !== undefined) signals.push(signal("interview.response_clarity", content, { eventId: event.id }));
|
||||
const roleAlignment = getNumber(rubric.role_alignment ?? rubric.technical_accuracy ?? review.role_alignment_score);
|
||||
if (roleAlignment !== undefined) signals.push(signal("interview.technical_accuracy", roleAlignment, { eventId: event.id }));
|
||||
const language = getNumber(rubric.language ?? review.language_score);
|
||||
if (language !== undefined) signals.push(signal("interview.behavioral_quality", language, { eventId: event.id }));
|
||||
const historical = asRecord(review.historical_comparison ?? review.historicalComparison);
|
||||
const delta = getNumber(historical.overall_delta ?? historical.overallDelta);
|
||||
if (delta !== undefined) signals.push(signal("interview.improvement_over_time", 50 + delta * 2.5, { eventId: event.id, delta }));
|
||||
return signals;
|
||||
}
|
||||
|
||||
function extractRoleplaySignals(event: GrowEventRow): QscoreSignal[] {
|
||||
const payload = event.payload ?? {};
|
||||
const review = asRecord(payload.review ?? payload.result ?? payload);
|
||||
const status = String(review.status ?? payload.status ?? "");
|
||||
if (!event.type.includes("review") && !event.type.includes("completed") && status !== "completed") return [];
|
||||
|
||||
const signals: QscoreSignal[] = [];
|
||||
// A completed roleplay must always emit at least a single-completion signal.
|
||||
// When scenario_count is absent, default to 1 so the event is not silently lost.
|
||||
const scenarioCount = getNumber(payload.scenario_count ?? payload.scenarioCount) ?? 1;
|
||||
signals.push(signal("roleplay.scenarios_completed", roleplayScenariosScore(scenarioCount), { eventId: event.id, scenarioCount }));
|
||||
const rubric = asRecord(review.rubric_scores ?? review.rubricScores);
|
||||
const scenario = getNumber(rubric.scenario_adherence ?? review.scenario_adherence_score);
|
||||
if (scenario !== undefined) signals.push(signal("roleplay.situational_judgment", scenario, { eventId: event.id }));
|
||||
const empathy = getNumber(rubric.emotional_intelligence ?? review.emotional_intelligence_score);
|
||||
if (empathy !== undefined) signals.push(signal("roleplay.empathy_demonstrated", empathy, { eventId: event.id }));
|
||||
const adaptability = getNumber(rubric.adaptability ?? review.adaptability_score);
|
||||
if (adaptability !== undefined) signals.push(signal("roleplay.problem_resolution", adaptability, { eventId: event.id }));
|
||||
const communication = getNumber(rubric.content ?? rubric.communication ?? review.communication_score);
|
||||
if (communication !== undefined) signals.push(signal("roleplay.communication_effectiveness", communication, { eventId: event.id }));
|
||||
return signals;
|
||||
}
|
||||
|
||||
|
||||
function coursesCompletedScore(count: number): number {
|
||||
if (count <= 0) return 0;
|
||||
if (count <= 3) return 35;
|
||||
if (count <= 10) return 60;
|
||||
return 80;
|
||||
}
|
||||
|
||||
function courseCompletionRateScore(pct: number): number {
|
||||
if (pct < 50) return 20;
|
||||
if (pct <= 80) return 40;
|
||||
return 60;
|
||||
}
|
||||
|
||||
function courseDifficultyScore(difficulty: string): number | undefined {
|
||||
const d = difficulty.toLowerCase();
|
||||
if (d.includes("beginner") || d === "easy") return 20;
|
||||
if (d.includes("inter") || d.includes("medium")) return 35;
|
||||
if (d.includes("adv")) return 50;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function coursePathwayRelevanceScore(label: string): number | undefined {
|
||||
const l = label.toLowerCase();
|
||||
if (l.includes("high")) return 40;
|
||||
if (l.includes("med")) return 25;
|
||||
if (l.includes("low")) return 10;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function extractCourseSignals(event: GrowEventRow): QscoreSignal[] {
|
||||
const payload = event.payload ?? {};
|
||||
if (
|
||||
!event.type.includes("progress_recorded") &&
|
||||
!event.type.includes("completed") &&
|
||||
!event.type.includes("started") &&
|
||||
!event.type.includes("watch")
|
||||
)
|
||||
return [];
|
||||
|
||||
const watchPctRaw = getNumber(payload.watchPct ?? payload.watch_pct) ?? 0;
|
||||
const watchPctScaled = watchPctRaw <= 1 ? watchPctRaw * 100 : watchPctRaw;
|
||||
const completedCount = getNumber(payload.completed_count ?? payload.completedCount ?? payload.courses_completed);
|
||||
const difficulty = getString(payload.difficulty);
|
||||
const label = getString(payload.label ?? payload.pathway_label);
|
||||
const raw = {
|
||||
eventId: event.id,
|
||||
courseId: payload.courseId ?? payload.course_id,
|
||||
lessonId: payload.lessonId ?? payload.lesson_id,
|
||||
watchPct: watchPctRaw,
|
||||
/**
|
||||
* Compatibility adapter for event/mission processing.
|
||||
*
|
||||
* The backend never extracts, normalizes, thresholds, or scores a signal. The
|
||||
* canonical GrowEvent was queued transactionally when it was recorded. This
|
||||
* method only attempts that delivery and passes QScore-owned projected evidence
|
||||
* back to mission reducers when the QScore API returns it.
|
||||
*/
|
||||
export async function applyQscoreProjection(event: Pick<GrowEventRow, "id" | "userId">) {
|
||||
if (!event.userId) return { signals: [], score: undefined, delivery: "skipped" as const };
|
||||
const result = await publishQscoreOutboxForEvent(event.id);
|
||||
return {
|
||||
signals: result.signals,
|
||||
score: undefined,
|
||||
delivery: result.status,
|
||||
response: result.response,
|
||||
};
|
||||
|
||||
const signals: QscoreSignal[] = [];
|
||||
signals.push(signal("courses.started", 40, raw));
|
||||
signals.push(signal("courses.completion_rate", courseCompletionRateScore(watchPctScaled), { ...raw, completionPct: watchPctScaled }));
|
||||
if (watchPctScaled >= 80) {
|
||||
signals.push(signal("courses.completed", coursesCompletedScore(completedCount ?? 1), { ...raw, completedCount: completedCount ?? 1 }));
|
||||
}
|
||||
if (difficulty) {
|
||||
const diffScore = courseDifficultyScore(difficulty);
|
||||
if (diffScore !== undefined) signals.push(signal("courses.difficulty", diffScore, { ...raw, difficulty }));
|
||||
}
|
||||
if (label) {
|
||||
const relScore = coursePathwayRelevanceScore(label);
|
||||
if (relScore !== undefined) signals.push(signal("courses.pathway_relevance", relScore, { ...raw, label }));
|
||||
}
|
||||
return signals;
|
||||
}
|
||||
|
||||
function sourceSignalPrefix(source: string) {
|
||||
return source
|
||||
.toLowerCase()
|
||||
.replace(/-service$/, "")
|
||||
.replace(/[^a-z0-9]+/g, "_")
|
||||
.replace(/^_+|_+$/g, "") || "service";
|
||||
}
|
||||
|
||||
function extractScoredServiceSignals(event: GrowEventRow): QscoreSignal[] {
|
||||
const payload = event.payload ?? {};
|
||||
const review = asRecord(payload.review ?? payload.result ?? payload);
|
||||
const status = String(review.status ?? payload.status ?? "");
|
||||
const isCompletion =
|
||||
event.type.includes("completed") ||
|
||||
event.type.includes("updated") ||
|
||||
event.type.includes("signal_projected") ||
|
||||
event.type.includes("signal.projected") ||
|
||||
status === "completed";
|
||||
if (!isCompletion) return [];
|
||||
|
||||
const score = getNumber(
|
||||
payload.score ??
|
||||
payload.qscore ??
|
||||
payload.q_score ??
|
||||
payload.readiness_score ??
|
||||
payload.overall_score ??
|
||||
review.score ??
|
||||
review.qscore ??
|
||||
review.q_score ??
|
||||
review.readiness_score ??
|
||||
review.overall_score,
|
||||
);
|
||||
if (score === undefined) return [];
|
||||
|
||||
const prefix = sourceSignalPrefix(event.source);
|
||||
return [
|
||||
signal(`${prefix}.service_completion_score`, score, {
|
||||
eventId: event.id,
|
||||
source: event.source,
|
||||
type: event.type,
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
function countFromPayload(payload: Record<string, unknown>): number | undefined {
|
||||
const result = asRecord(payload.result);
|
||||
const request = asRecord(payload.request);
|
||||
const params = asRecord(request.params);
|
||||
const direct = getNumber(payload.matchCount ?? payload.matches ?? payload.shortlisted ?? result.matchCount ?? result.matches ?? result.shortlisted);
|
||||
if (direct !== undefined) return direct;
|
||||
const opportunities = Array.isArray(result.opportunities) ? result.opportunities : undefined;
|
||||
if (opportunities) return opportunities.length;
|
||||
const requestOpportunities = Array.isArray(params.opportunities) ? params.opportunities : undefined;
|
||||
return requestOpportunities?.length;
|
||||
}
|
||||
|
||||
|
||||
function jobsViewedScore(count: number): number {
|
||||
if (count <= 0) return 0;
|
||||
if (count <= 20) return 15;
|
||||
if (count <= 50) return 30;
|
||||
return 40;
|
||||
}
|
||||
|
||||
function applicationsSubmittedScore(count: number): number {
|
||||
if (count <= 0) return 0;
|
||||
if (count <= 5) return 20;
|
||||
if (count <= 15) return 35;
|
||||
return 50;
|
||||
}
|
||||
|
||||
function matchRateScore(pct: number): number {
|
||||
if (pct < 50) return 15;
|
||||
if (pct <= 75) return 35;
|
||||
return 50;
|
||||
}
|
||||
|
||||
function extractMatchmakingSignals(event: GrowEventRow): QscoreSignal[] {
|
||||
if (!event.type.startsWith("matchmaking.")) return [];
|
||||
const payload = event.payload ?? {};
|
||||
const count = countFromPayload(payload);
|
||||
const raw = {
|
||||
eventId: event.id,
|
||||
type: event.type,
|
||||
action: payload.action,
|
||||
opportunityId: payload.opportunityId ?? payload.matchId ?? payload.jobId ?? event.subject?.externalId,
|
||||
taskId: payload.taskId ?? payload.curatorTaskId ?? event.correlation?.taskId,
|
||||
matchCount: count,
|
||||
status: payload.status,
|
||||
};
|
||||
|
||||
const signals: QscoreSignal[] = [];
|
||||
|
||||
// Jobs viewed — from feed.viewed and match.viewed events with cumulative counts
|
||||
if (event.type === "matchmaking.feed.viewed" || event.type === "matchmaking.match.viewed") {
|
||||
const viewedCount = getNumber(payload.jobs_viewed ?? payload.jobsViewed ?? payload.viewed_count ?? payload.viewedCount ?? count);
|
||||
signals.push(signal("matching.jobs_viewed", jobsViewedScore(viewedCount ?? 1), { ...raw, viewedCount }));
|
||||
}
|
||||
|
||||
// Applications submitted — from match.applied and application.completed events
|
||||
if (event.type === "matchmaking.match.applied" || event.type === "matchmaking.application.completed") {
|
||||
const appliedCount = getNumber(payload.applications_submitted ?? payload.applicationsSubmitted ?? payload.applied_count ?? payload.appliedCount ?? count);
|
||||
signals.push(signal("matching.applications_submitted", applicationsSubmittedScore(appliedCount ?? 1), { ...raw, appliedCount }));
|
||||
}
|
||||
|
||||
// Application quality — from match quality scores if available
|
||||
const qualityScore = getNumber(payload.application_quality ?? payload.applicationQuality ?? payload.match_quality ?? payload.matchQuality);
|
||||
if (qualityScore !== undefined) {
|
||||
signals.push(signal("matching.application_quality", qualityScore, { ...raw, qualityScore }));
|
||||
}
|
||||
|
||||
// Match rate — from matches.generated with match data
|
||||
if (event.type === "matchmaking.matches.generated" && count !== undefined) {
|
||||
const totalCandidates = getNumber(payload.total_candidates ?? payload.totalCandidates) ?? count;
|
||||
const rate = totalCandidates > 0 ? (count / totalCandidates) * 100 : 0;
|
||||
signals.push(signal("matching.match_rate", matchRateScore(rate), { ...raw, matchRate: rate }));
|
||||
}
|
||||
|
||||
return signals;
|
||||
}
|
||||
|
||||
function extractSocialSignals(event: GrowEventRow): QscoreSignal[] {
|
||||
const payload = event.payload ?? {};
|
||||
const signals: QscoreSignal[] = [];
|
||||
// social-branding sends pre-computed qscore_signals array — forward as-is
|
||||
const incoming = Array.isArray(payload.qscore_signals) ? payload.qscore_signals : Array.isArray(payload.qscoreSignals) ? payload.qscoreSignals : undefined;
|
||||
if (incoming) {
|
||||
for (const entry of incoming) {
|
||||
const record = asRecord(entry);
|
||||
const id = getString(record.signalId ?? record.signal_id ?? record.id);
|
||||
const score = getNumber(record.score ?? record.value);
|
||||
if (id !== undefined && score !== undefined) {
|
||||
signals.push(signal(id, score, { eventId: event.id, source: event.source }));
|
||||
}
|
||||
}
|
||||
return signals;
|
||||
}
|
||||
// Fall back to inline fields for backward compatibility
|
||||
const inline = asRecord(payload.signals);
|
||||
for (const [key, value] of Object.entries(inline)) {
|
||||
const score = getNumber(value);
|
||||
if (score !== undefined) signals.push(signal(key, score, { eventId: event.id, source: event.source }));
|
||||
}
|
||||
return signals;
|
||||
}
|
||||
|
||||
export function extractQscoreSignals(event: GrowEventRow): QscoreSignal[] {
|
||||
const source = event.source.toLowerCase();
|
||||
if (source.includes("resume") || event.type.startsWith("resume.")) return extractResumeSignals(event);
|
||||
if (source.includes("interview") || event.type.startsWith("interview.")) return extractInterviewSignals(event);
|
||||
if (source.includes("roleplay") || event.type.startsWith("roleplay.")) return extractRoleplaySignals(event);
|
||||
if (source.includes("course") || event.type.startsWith("course.")) return extractCourseSignals(event);
|
||||
if (source.includes("matchmaking") || event.type.startsWith("matchmaking.")) return extractMatchmakingSignals(event);
|
||||
if (source.includes("social") || source.includes("branding") || source.includes("linkedin")) return extractSocialSignals(event);
|
||||
if (source.includes("qscore") || event.type.startsWith("qscore.")) return extractScoredServiceSignals(event);
|
||||
const scoredServiceSignals = extractScoredServiceSignals(event);
|
||||
if (scoredServiceSignals.length) return scoredServiceSignals;
|
||||
return [];
|
||||
}
|
||||
|
||||
export async function applyQscoreProjection(event: GrowEventRow) {
|
||||
if (!event.userId) return { signals: [], score: undefined };
|
||||
|
||||
const signals = extractQscoreSignals(event);
|
||||
if (!signals.length) return { signals, score: undefined };
|
||||
|
||||
// qscore_service OWNS all scoring. The backend only extracts signals and
|
||||
// forwards them; ingest persists signals and marks the user dirty for async
|
||||
// score computation. It does NOT return a score, so score stays undefined
|
||||
// here — readers fetch the computed score via the proxy endpoints.
|
||||
await forwardSignalsToQscoreService({
|
||||
orgId: event.orgId ?? DEFAULT_QSCORE_ORG_ID,
|
||||
userId: event.userId,
|
||||
profession: "student",
|
||||
source: event.source,
|
||||
signals,
|
||||
});
|
||||
|
||||
return { signals, score: undefined };
|
||||
}
|
||||
|
||||
@@ -71,8 +71,7 @@ export async function applyServiceSessionProjection(event: GrowEventRow) {
|
||||
missionInstanceId,
|
||||
missionId,
|
||||
stageId,
|
||||
taskId: getString(correlation.taskId ?? correlation.curatorTaskId ?? correlation.curator_task_id ?? payload.taskId ?? payload.curatorTaskId ?? payload.curator_task_id),
|
||||
curatorTaskId: getString(correlation.curatorTaskId ?? correlation.curator_task_id ?? correlation.taskId ?? payload.curatorTaskId ?? payload.curator_task_id ?? payload.taskId),
|
||||
task_id: getString(correlation.task_id ?? payload.task_id ?? payload.taskId ?? payload.curatorTaskId ?? payload.curator_task_id),
|
||||
courseId: getString(correlation.courseId ?? payload.courseId ?? payload.course_id),
|
||||
lessonId: getString(correlation.lessonId ?? payload.lessonId ?? payload.lesson_id),
|
||||
};
|
||||
@@ -102,14 +101,18 @@ export async function applyServiceSessionProjection(event: GrowEventRow) {
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: [missionServiceSessions.serviceId, missionServiceSessions.externalId],
|
||||
target: [missionServiceSessions.userId, missionServiceSessions.serviceId, missionServiceSessions.externalId],
|
||||
set: updateValues,
|
||||
})
|
||||
.returning();
|
||||
|
||||
// Touch the row if Drizzle ever returns no row for an upsert variant.
|
||||
if (!row) {
|
||||
await db.update(missionServiceSessions).set({ updatedAt: new Date() }).where(eq(missionServiceSessions.externalId, externalId));
|
||||
await db.update(missionServiceSessions).set({ updatedAt: new Date() }).where(and(
|
||||
eq(missionServiceSessions.userId, event.userId),
|
||||
eq(missionServiceSessions.serviceId, serviceId),
|
||||
eq(missionServiceSessions.externalId, externalId),
|
||||
));
|
||||
}
|
||||
return row ?? null;
|
||||
}
|
||||
|
||||
197
src/events/qscore-event-outbox.ts
Normal file
197
src/events/qscore-event-outbox.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
import { and, eq, lt, lte, or, sql } from "drizzle-orm";
|
||||
import { config } from "../config.js";
|
||||
import { db } from "../db/client.js";
|
||||
import { eventOutbox } from "../db/schema.js";
|
||||
import { log } from "../log.js";
|
||||
import { asRecord, getNumber, getString, type QscoreSignal } from "./envelope.js";
|
||||
|
||||
export const QSCORE_GROW_EVENT_TOPIC = "qscore.grow_event.raw";
|
||||
export const QSCORE_GROW_EVENT_DESTINATION = "/v1/events/ingest";
|
||||
|
||||
const LOCK_TIMEOUT_MS = 60_000;
|
||||
const DEFAULT_POLL_MS = 2_000;
|
||||
const DEFAULT_BATCH_SIZE = 25;
|
||||
|
||||
export type QscoreEventDeliveryResult = {
|
||||
status: "queued" | "published";
|
||||
signals: QscoreSignal[];
|
||||
response?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
type Deliver = (input: {
|
||||
destination: string;
|
||||
payload: Record<string, unknown>;
|
||||
headers: Record<string, string>;
|
||||
}) => Promise<Record<string, unknown>>;
|
||||
|
||||
function signalFromUnknown(value: unknown): QscoreSignal | null {
|
||||
const row = asRecord(value);
|
||||
const signalId = getString(row.signalId ?? row.signal_id ?? row.id);
|
||||
if (!signalId) return null;
|
||||
const score = getNumber(row.score ?? row.normalizedScore ?? row.normalized_score);
|
||||
const present = typeof row.present === "boolean" ? row.present : undefined;
|
||||
const suppliedRaw = asRecord(row.raw ?? row.evidence);
|
||||
const policyEvidence = compactRecord({
|
||||
rawValue: row.rawValue ?? row.raw_value,
|
||||
rawUnit: row.rawUnit ?? row.raw_unit,
|
||||
band: row.band,
|
||||
materialChange: row.materialChange ?? row.material_change,
|
||||
});
|
||||
return {
|
||||
signalId,
|
||||
...(score !== undefined ? { score } : {}),
|
||||
...(present !== undefined ? { present } : {}),
|
||||
...(getString(row.source) ? { source: getString(row.source) } : {}),
|
||||
raw: Object.keys(suppliedRaw).length ? suppliedRaw : policyEvidence,
|
||||
};
|
||||
}
|
||||
|
||||
function compactRecord(value: Record<string, unknown>) {
|
||||
return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== undefined));
|
||||
}
|
||||
|
||||
/** Read only QScore-owned projected evidence; never infer or score locally. */
|
||||
export function projectedSignalsFromQscoreResponse(body: Record<string, unknown>): QscoreSignal[] {
|
||||
const breakdown = asRecord(body.breakdown);
|
||||
const candidates = Array.isArray(body.projectedSignals)
|
||||
? body.projectedSignals
|
||||
: Array.isArray(body.projected_signals)
|
||||
? body.projected_signals
|
||||
: Array.isArray(body.signals)
|
||||
? body.signals
|
||||
: Array.isArray(breakdown.signals)
|
||||
? breakdown.signals
|
||||
: [];
|
||||
return candidates.map(signalFromUnknown).filter((signal): signal is QscoreSignal => signal !== null);
|
||||
}
|
||||
|
||||
async function postToQscore(input: Parameters<Deliver>[0]): Promise<Record<string, unknown>> {
|
||||
const response = await fetch(`${config.qscoreServiceUrl.replace(/\/$/, "")}${input.destination}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
...input.headers,
|
||||
...(config.a2aAllowedKey ? { authorization: `Bearer ${config.a2aAllowedKey}` } : {}),
|
||||
},
|
||||
body: JSON.stringify(input.payload),
|
||||
signal: AbortSignal.timeout(config.qscoreEventDeliveryTimeoutMs),
|
||||
});
|
||||
const text = await response.text();
|
||||
let body: unknown = {};
|
||||
if (text) {
|
||||
try { body = JSON.parse(text); } catch { body = { detail: text }; }
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Error(`qscore raw-event ingest failed (${response.status}): ${text.slice(0, 500)}`);
|
||||
}
|
||||
return asRecord(body);
|
||||
}
|
||||
|
||||
export async function publishQscoreOutboxItem(
|
||||
outboxId: string,
|
||||
deliver: Deliver = postToQscore,
|
||||
): Promise<QscoreEventDeliveryResult> {
|
||||
const now = new Date();
|
||||
const staleBefore = new Date(now.getTime() - LOCK_TIMEOUT_MS);
|
||||
const [claimed] = await db
|
||||
.update(eventOutbox)
|
||||
.set({
|
||||
status: "processing",
|
||||
attempts: sql`${eventOutbox.attempts} + 1`,
|
||||
lockedAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(and(
|
||||
eq(eventOutbox.id, outboxId),
|
||||
or(
|
||||
and(eq(eventOutbox.status, "pending"), lte(eventOutbox.availableAt, now)),
|
||||
and(eq(eventOutbox.status, "processing"), lt(eventOutbox.lockedAt, staleBefore)),
|
||||
),
|
||||
))
|
||||
.returning();
|
||||
|
||||
if (!claimed) {
|
||||
const [existing] = await db.select().from(eventOutbox).where(eq(eventOutbox.id, outboxId)).limit(1);
|
||||
const response = existing?.response ?? undefined;
|
||||
return {
|
||||
status: existing?.status === "published" ? "published" : "queued",
|
||||
signals: response ? projectedSignalsFromQscoreResponse(response) : [],
|
||||
response,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await deliver({
|
||||
destination: claimed.destination,
|
||||
payload: claimed.payload,
|
||||
headers: claimed.headers,
|
||||
});
|
||||
await db.update(eventOutbox).set({
|
||||
status: "published",
|
||||
response,
|
||||
publishedAt: new Date(),
|
||||
lockedAt: null,
|
||||
lastError: null,
|
||||
updatedAt: new Date(),
|
||||
}).where(eq(eventOutbox.id, claimed.id));
|
||||
return { status: "published", signals: projectedSignalsFromQscoreResponse(response), response };
|
||||
} catch (error) {
|
||||
const delaySeconds = Math.min(300, 2 ** Math.min(claimed.attempts, 8));
|
||||
await db.update(eventOutbox).set({
|
||||
status: "pending",
|
||||
availableAt: new Date(Date.now() + delaySeconds * 1_000),
|
||||
lockedAt: null,
|
||||
lastError: error instanceof Error ? error.message : String(error),
|
||||
updatedAt: new Date(),
|
||||
}).where(eq(eventOutbox.id, claimed.id));
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function publishQscoreOutboxForEvent(eventId: string): Promise<QscoreEventDeliveryResult> {
|
||||
const [row] = await db
|
||||
.select({ id: eventOutbox.id, status: eventOutbox.status, response: eventOutbox.response })
|
||||
.from(eventOutbox)
|
||||
.where(and(eq(eventOutbox.topic, QSCORE_GROW_EVENT_TOPIC), eq(eventOutbox.aggregateId, eventId)))
|
||||
.limit(1);
|
||||
if (!row) return { status: "queued", signals: [] };
|
||||
if (row.status === "published") {
|
||||
return {
|
||||
status: "published",
|
||||
signals: row.response ? projectedSignalsFromQscoreResponse(row.response) : [],
|
||||
response: row.response ?? undefined,
|
||||
};
|
||||
}
|
||||
try {
|
||||
return await publishQscoreOutboxItem(row.id);
|
||||
} catch (error) {
|
||||
log.warn({ error, eventId }, "QScore raw GrowEvent queued for retry");
|
||||
return { status: "queued", signals: [] };
|
||||
}
|
||||
}
|
||||
|
||||
export async function flushQscoreEventOutbox(limit = DEFAULT_BATCH_SIZE): Promise<number> {
|
||||
const now = new Date();
|
||||
const staleBefore = new Date(now.getTime() - LOCK_TIMEOUT_MS);
|
||||
const rows = await db
|
||||
.select({ id: eventOutbox.id })
|
||||
.from(eventOutbox)
|
||||
.where(and(
|
||||
eq(eventOutbox.topic, QSCORE_GROW_EVENT_TOPIC),
|
||||
or(
|
||||
and(eq(eventOutbox.status, "pending"), lte(eventOutbox.availableAt, now)),
|
||||
and(eq(eventOutbox.status, "processing"), lt(eventOutbox.lockedAt, staleBefore)),
|
||||
),
|
||||
))
|
||||
.limit(limit);
|
||||
const settled = await Promise.allSettled(rows.map((row) => publishQscoreOutboxItem(row.id)));
|
||||
return settled.filter((result) => result.status === "fulfilled").length;
|
||||
}
|
||||
|
||||
export function startQscoreEventOutboxPublisher(pollMs = DEFAULT_POLL_MS) {
|
||||
const timer = setInterval(() => {
|
||||
flushQscoreEventOutbox().catch((error) => log.error({ error }, "QScore event outbox flush failed"));
|
||||
}, pollMs);
|
||||
timer.unref();
|
||||
void flushQscoreEventOutbox().catch((error) => log.error({ error }, "initial QScore event outbox flush failed"));
|
||||
return () => clearInterval(timer);
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { and, eq, or } from "drizzle-orm";
|
||||
import { db } from "../db/client.js";
|
||||
import { growEvents, missionServiceSessions, users, type GrowEventRow } from "../db/schema.js";
|
||||
import { eventOutbox, growEvents, missionServiceSessions, users, type GrowEventRow } from "../db/schema.js";
|
||||
import { asRecord, getString, type GrowEventEnvelope } from "./envelope.js";
|
||||
import { normalizeGrowEvent } from "./normalize.js";
|
||||
import { QSCORE_GROW_EVENT_DESTINATION, QSCORE_GROW_EVENT_TOPIC } from "./qscore-event-outbox.js";
|
||||
import { DEFAULT_QSCORE_ORG_ID } from "../services/qscore-proxy.js";
|
||||
|
||||
async function ensureUser(userId: string) {
|
||||
await db
|
||||
@@ -11,29 +13,134 @@ async function ensureUser(userId: string) {
|
||||
.onConflictDoNothing();
|
||||
}
|
||||
|
||||
type ServiceSessionLink = Pick<
|
||||
typeof missionServiceSessions.$inferSelect,
|
||||
"userId" | "missionInstanceId" | "missionId" | "stageId" | "metadata"
|
||||
>;
|
||||
|
||||
export function correlateEventWithServiceSession(
|
||||
event: GrowEventEnvelope,
|
||||
resolvedUserId: string | undefined,
|
||||
link: ServiceSessionLink | undefined,
|
||||
): GrowEventEnvelope {
|
||||
if (!resolvedUserId || !link || link.userId !== resolvedUserId) return event;
|
||||
const metadata = asRecord(link.metadata);
|
||||
const taskId = getString(metadata.task_id ?? metadata.taskId ?? metadata.curatorTaskId ?? metadata.curator_task_id);
|
||||
const missionInstanceId = getString(link.missionInstanceId ?? metadata.missionInstanceId ?? metadata.mission_instance_id);
|
||||
const missionId = getString(link.missionId ?? metadata.missionId ?? metadata.mission_id);
|
||||
const stageId = getString(link.stageId ?? metadata.stageId ?? metadata.stage_id);
|
||||
const eventCorrelation = event.correlation ?? {};
|
||||
const explicitTaskId = getString(eventCorrelation.task_id ?? eventCorrelation.taskId ?? eventCorrelation.curatorTaskId ?? eventCorrelation.curator_task_id);
|
||||
const correlation = {
|
||||
...(taskId && !explicitTaskId ? { task_id: taskId } : {}),
|
||||
...(missionInstanceId && !getString(eventCorrelation.missionInstanceId) ? { missionInstanceId } : {}),
|
||||
...(missionId && !getString(eventCorrelation.missionId) ? { missionId } : {}),
|
||||
...(stageId && !getString(eventCorrelation.stageId) ? { stageId } : {}),
|
||||
...eventCorrelation,
|
||||
};
|
||||
const mission = event.mission ?? (
|
||||
missionInstanceId || missionId || stageId
|
||||
? { instanceId: missionInstanceId, missionId, stageId }
|
||||
: undefined
|
||||
);
|
||||
return { ...event, correlation, mission };
|
||||
}
|
||||
|
||||
export function serviceEventExternalIds(event: GrowEventEnvelope): string[] {
|
||||
const correlation = event.correlation ?? {};
|
||||
const payload = event.payload ?? {};
|
||||
const subject = event.subject ?? {};
|
||||
return [...new Set([
|
||||
correlation.sessionId,
|
||||
correlation.session_id,
|
||||
correlation.externalId,
|
||||
correlation.external_id,
|
||||
correlation.resumeId,
|
||||
correlation.resume_id,
|
||||
correlation.courseId,
|
||||
correlation.course_id,
|
||||
correlation.lessonId,
|
||||
correlation.lesson_id,
|
||||
correlation.assessmentId,
|
||||
correlation.assessment_id,
|
||||
subject.externalId,
|
||||
payload.sessionId,
|
||||
payload.session_id,
|
||||
payload.externalId,
|
||||
payload.external_id,
|
||||
payload.resumeId,
|
||||
payload.resume_id,
|
||||
payload.courseId,
|
||||
payload.course_id,
|
||||
payload.lessonId,
|
||||
payload.lesson_id,
|
||||
payload.assessmentId,
|
||||
payload.assessment_id,
|
||||
].map(getString).filter((value): value is string => Boolean(value)))];
|
||||
}
|
||||
|
||||
|
||||
async function resolveUserId(event: GrowEventEnvelope): Promise<string | undefined> {
|
||||
if (event.userId) return event.userId;
|
||||
const sessionId = getString(event.correlation?.sessionId ?? event.correlation?.session_id);
|
||||
const externalIds = serviceEventExternalIds(event);
|
||||
const serviceId = normalizeServiceId(event.source);
|
||||
if (sessionId) {
|
||||
const [row] = await db
|
||||
.select({ userId: missionServiceSessions.userId })
|
||||
.from(missionServiceSessions)
|
||||
.where(and(eq(missionServiceSessions.serviceId, serviceId), eq(missionServiceSessions.externalId, sessionId)))
|
||||
.limit(1);
|
||||
if (row?.userId) return row.userId;
|
||||
}
|
||||
return undefined;
|
||||
if (!externalIds.length) return undefined;
|
||||
const rows = await db
|
||||
.select({ userId: missionServiceSessions.userId })
|
||||
.from(missionServiceSessions)
|
||||
.where(and(
|
||||
eq(missionServiceSessions.serviceId, serviceId),
|
||||
inArrayValues(missionServiceSessions.externalId, externalIds),
|
||||
));
|
||||
return resolveUniqueSessionUserId(rows);
|
||||
}
|
||||
|
||||
function inArrayValues(column: typeof missionServiceSessions.externalId, values: string[]) {
|
||||
return or(...values.map((value) => eq(column, value)))!;
|
||||
}
|
||||
|
||||
// External service IDs are not globally unique. Ambiguous ownership must remain
|
||||
// unresolved rather than silently attaching another user's Curator task context.
|
||||
export function resolveUniqueSessionUserId(rows: Array<{ userId: string }>): string | undefined {
|
||||
const userIds = new Set(rows.map((row) => row.userId));
|
||||
return userIds.size === 1 ? [...userIds][0] : undefined;
|
||||
}
|
||||
async function resolveServiceSessionLink(event: GrowEventEnvelope, userId: string | undefined): Promise<ServiceSessionLink | undefined> {
|
||||
if (!userId) return undefined;
|
||||
const externalIds = serviceEventExternalIds(event);
|
||||
if (!externalIds.length) return undefined;
|
||||
const serviceId = normalizeServiceId(event.source);
|
||||
const [row] = await db
|
||||
.select({
|
||||
userId: missionServiceSessions.userId,
|
||||
missionInstanceId: missionServiceSessions.missionInstanceId,
|
||||
missionId: missionServiceSessions.missionId,
|
||||
stageId: missionServiceSessions.stageId,
|
||||
metadata: missionServiceSessions.metadata,
|
||||
})
|
||||
.from(missionServiceSessions)
|
||||
.where(and(
|
||||
eq(missionServiceSessions.userId, userId),
|
||||
eq(missionServiceSessions.serviceId, serviceId),
|
||||
inArrayValues(missionServiceSessions.externalId, externalIds),
|
||||
))
|
||||
.limit(1);
|
||||
return row;
|
||||
|
||||
}
|
||||
|
||||
export function normalizeServiceId(source: string): string {
|
||||
if (source.includes("interview")) return "interview";
|
||||
if (source.includes("roleplay")) return "roleplay";
|
||||
if (source.includes("resume")) return "resume";
|
||||
if (source.includes("course")) return "course";
|
||||
if (source.includes("courses")) return "course";
|
||||
if (source.includes("qscore")) return "qscore";
|
||||
return source;
|
||||
const normalized = source.toLowerCase();
|
||||
if (normalized.includes("interview")) return "interview";
|
||||
if (normalized.includes("roleplay")) return "roleplay";
|
||||
if (normalized.includes("resume")) return "resume";
|
||||
if (normalized.includes("cover-letter") || normalized.includes("cover_letter")) return "cover_letter";
|
||||
if (normalized.includes("course")) return "course";
|
||||
if (normalized.includes("qscore") || normalized.includes("q-score")) return "qscore";
|
||||
if (normalized.includes("assessment")) return "assessment";
|
||||
if (normalized.includes("matchmaking")) return "matchmaking";
|
||||
if (normalized.includes("social") || normalized.includes("brand")) return "brand";
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export async function recordGrowEvent(input: unknown, overrides: { userId?: string; source?: string } = {}): Promise<GrowEventRow> {
|
||||
@@ -44,24 +151,26 @@ export async function recordGrowEvent(input: unknown, overrides: { userId?: stri
|
||||
export async function recordGrowEventWithResult(input: unknown, overrides: { userId?: string; source?: string } = {}): Promise<{ event: GrowEventRow; inserted: boolean }> {
|
||||
const normalized = normalizeGrowEvent(input, overrides);
|
||||
const resolvedUserId = await resolveUserId(normalized);
|
||||
const sessionLink = await resolveServiceSessionLink(normalized, resolvedUserId);
|
||||
const correlated = correlateEventWithServiceSession(normalized, resolvedUserId, sessionLink);
|
||||
if (resolvedUserId) await ensureUser(resolvedUserId);
|
||||
|
||||
const processingStatus = resolvedUserId ? "pending" : "unresolved";
|
||||
|
||||
const values = {
|
||||
id: normalized.id,
|
||||
id: correlated.id,
|
||||
userId: resolvedUserId,
|
||||
orgId: normalized.orgId,
|
||||
source: normalized.source,
|
||||
type: normalized.type,
|
||||
category: normalized.category,
|
||||
occurredAt: new Date(normalized.occurredAt),
|
||||
mission: normalized.mission as Record<string, unknown> | undefined,
|
||||
subject: normalized.subject as Record<string, unknown> | undefined,
|
||||
correlation: normalized.correlation as Record<string, unknown> | undefined,
|
||||
payload: normalized.payload,
|
||||
raw: normalized.raw ?? asRecord(input),
|
||||
dedupeKey: normalized.dedupeKey,
|
||||
orgId: correlated.orgId ?? DEFAULT_QSCORE_ORG_ID,
|
||||
source: correlated.source,
|
||||
type: correlated.type,
|
||||
category: correlated.category,
|
||||
occurredAt: new Date(correlated.occurredAt),
|
||||
mission: correlated.mission as Record<string, unknown> | undefined,
|
||||
subject: correlated.subject as Record<string, unknown> | undefined,
|
||||
correlation: correlated.correlation as Record<string, unknown> | undefined,
|
||||
payload: correlated.payload,
|
||||
raw: correlated.raw ?? asRecord(input),
|
||||
dedupeKey: correlated.dedupeKey,
|
||||
processingStatus,
|
||||
} satisfies typeof growEvents.$inferInsert;
|
||||
|
||||
@@ -79,11 +188,29 @@ export async function recordGrowEventWithResult(input: unknown, overrides: { use
|
||||
//
|
||||
// NULL dedupeKey never collides (Postgres treats NULLs as distinct under a
|
||||
// unique index), so events with no dedupe key always insert fresh.
|
||||
const insertedRows = await db
|
||||
.insert(growEvents)
|
||||
.values(values)
|
||||
.onConflictDoNothing()
|
||||
.returning();
|
||||
const canonicalEvent = canonicalGrowEventEnvelope(correlated, resolvedUserId);
|
||||
|
||||
const insertedRows = await db.transaction(async (tx) => {
|
||||
const rows = await tx
|
||||
.insert(growEvents)
|
||||
.values(values)
|
||||
.onConflictDoNothing()
|
||||
.returning();
|
||||
const inserted = rows[0];
|
||||
if (inserted?.userId) {
|
||||
await tx.insert(eventOutbox).values({
|
||||
topic: QSCORE_GROW_EVENT_TOPIC,
|
||||
aggregateId: inserted.id,
|
||||
destination: QSCORE_GROW_EVENT_DESTINATION,
|
||||
payload: canonicalEvent,
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"idempotency-key": inserted.id,
|
||||
},
|
||||
}).onConflictDoNothing();
|
||||
}
|
||||
return rows;
|
||||
});
|
||||
|
||||
const inserted = insertedRows[0];
|
||||
if (inserted) {
|
||||
@@ -120,6 +247,42 @@ export async function recordGrowEventWithResult(input: unknown, overrides: { use
|
||||
return { event: existing, inserted: false };
|
||||
}
|
||||
|
||||
export function canonicalGrowEventEnvelope(normalized: GrowEventEnvelope, resolvedUserId?: string) {
|
||||
// `normalizeGrowEvent` may translate legacy producer aliases for backend-only
|
||||
// consumers (for example curator completion matching). QScore owns signal
|
||||
// interpretation, so its generic outbox must retain the producer's event type
|
||||
// and unmodified payload instead of publishing that backend alias.
|
||||
const producer = asRecord(normalized.raw);
|
||||
const producerType = getString(producer.type ?? producer.event_type ?? producer.action);
|
||||
const producerCorrelation = asRecord(producer.correlation);
|
||||
const enrichedCorrelation = asRecord(normalized.correlation);
|
||||
const hasProducerTaskAlias = ["task_id", "taskId", "curatorTaskId", "curator_task_id"]
|
||||
.some((key) => producerCorrelation[key] !== undefined);
|
||||
const correlation = { ...producerCorrelation, ...enrichedCorrelation };
|
||||
// Avoid inventing a snake-case alias when the producer already supplied its
|
||||
// own task key. Backend-derived task_id remains when no producer task exists.
|
||||
if (hasProducerTaskAlias && producerCorrelation.task_id === undefined) delete correlation.task_id;
|
||||
return compactRecord({
|
||||
id: normalized.id,
|
||||
userId: resolvedUserId,
|
||||
orgId: normalized.orgId ?? DEFAULT_QSCORE_ORG_ID,
|
||||
source: normalized.source,
|
||||
type: producerType ?? normalized.type,
|
||||
category: normalized.category,
|
||||
occurredAt: normalized.occurredAt,
|
||||
mission: normalized.mission,
|
||||
subject: normalized.subject,
|
||||
correlation,
|
||||
payload: normalized.payload,
|
||||
raw: normalized.raw,
|
||||
dedupeKey: normalized.dedupeKey,
|
||||
});
|
||||
}
|
||||
|
||||
function compactRecord(value: Record<string, unknown>): Record<string, unknown> {
|
||||
return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== undefined));
|
||||
}
|
||||
|
||||
/**
|
||||
* Routing gate: only newly inserted, user-resolved, pending events should be
|
||||
* enqueued to the user-event actor. Replays / dedupe hits / unresolved events
|
||||
|
||||
@@ -8,15 +8,13 @@ import {
|
||||
missionArtifacts,
|
||||
missionServiceSessions,
|
||||
missionSuggestions,
|
||||
qscoreSnapshots,
|
||||
users,
|
||||
type GrowHomeNotificationRow,
|
||||
type NewGrowHomeNotification,
|
||||
} from "../db/schema.js";
|
||||
import { interviewService, resumeService, roleplayService } from "../services/product-service-clients.js";
|
||||
import { buildServiceLink } from "../services/service-registry.js";
|
||||
import { DEFAULT_QSCORE_ORG_ID, getQscoreFromService } from "../services/qscore-proxy.js";
|
||||
import { ensureOnboardingBaselineQscoreFromLedger } from "../events/onboarding-ledger.js";
|
||||
import { DEFAULT_QSCORE_ORG_ID, getQscoreFromService, getQscoreLatestSignalsFromService } from "../services/qscore-proxy.js";
|
||||
import { listAvailableMissionDefinitions } from "../missions/registry.js";
|
||||
import { listServiceCapabilities } from "../workflows/service-capabilities.js";
|
||||
import { buildCuratorSprint } from "../v1/curator/curator-store.js";
|
||||
@@ -51,7 +49,7 @@ type SeedNotification = Omit<HomeNotification, "id" | "createdAt"> & { moduleId:
|
||||
|
||||
type HomeContext = {
|
||||
user: { id: string; email: string; displayName: string | null } | undefined;
|
||||
qscore: { score: number; signalCount: number; summary: string | null; dimensions: Record<string, unknown> | null } | undefined;
|
||||
qscore: { rqScore: number; signalCount: number; summary: string | null; dimensions: Record<string, unknown> | null } | undefined;
|
||||
qscoreSignals: Array<{ signalId: string; score: number; source: string | null; updatedAt: Date }>;
|
||||
activeMissions: Array<{ instanceId: string; missionId: string; title: string; status: string; progressPercent: number; currentStageId: string | null; updatedAt: Date }>;
|
||||
missionSuggestions: Array<{ id: string; missionInstanceId: string; missionId: string; stageId: string | null; role: string; type: string; title: string; body: string; reason: string | null; priority: number; urgency: string; ctaLabel: string; ctaHref: string; updatedAt: Date }>;
|
||||
@@ -219,7 +217,7 @@ function buildDayOneSeeds(): SeedNotification[] {
|
||||
function buildDynamicSeeds(ctx: HomeContext): SeedNotification[] {
|
||||
const seeds = buildDayOneSeeds().filter((seed) => seed.moduleId === "pathways" || seed.moduleId === "rewards");
|
||||
const profile = profileFromPreferences(ctx.preferences);
|
||||
const qscore = ctx.qscore?.score ?? Math.round(ctx.qscoreSignals.reduce((sum, s) => sum + s.score, 0) / Math.max(ctx.qscoreSignals.length, 1));
|
||||
const qscore = ctx.qscore?.rqScore;
|
||||
const ats = latestScore(ctx.qscoreSignals, "resume.ats_compatibility");
|
||||
const interviewOverall = latestScore(ctx.qscoreSignals, "interview.overall_score");
|
||||
const roleplayComms = latestScore(ctx.qscoreSignals, "roleplay.communication_effectiveness");
|
||||
@@ -278,7 +276,7 @@ function buildDynamicSeeds(ctx: HomeContext): SeedNotification[] {
|
||||
});
|
||||
}
|
||||
|
||||
if (ctx.qscore || ctx.qscoreSignals.length) {
|
||||
if (qscore !== undefined) {
|
||||
pushSeed(seeds, {
|
||||
moduleId: "pathways",
|
||||
title: qscore >= 80 ? "Review your best job matches" : "Find better-fit job matches",
|
||||
@@ -382,16 +380,16 @@ function buildDynamicSeeds(ctx: HomeContext): SeedNotification[] {
|
||||
|
||||
async function collectContext(userId: string, input: { userProfile?: Record<string, unknown>; preferences?: Record<string, unknown> } = {}): Promise<HomeContext> {
|
||||
const [user] = await db.select({ id: users.id, email: users.email, displayName: users.displayName }).from(users).where(eq(users.id, userId)).limit(1);
|
||||
const qscoreResult = await getQscoreFromService(userId, DEFAULT_QSCORE_ORG_ID);
|
||||
const breakdown = qscoreResult?.breakdown ?? {};
|
||||
const breakdownSignals = Array.isArray(breakdown.signals) ? breakdown.signals : [];
|
||||
const qscoreSignals = breakdownSignals.map((item) => {
|
||||
const s = isRecord(item) ? item : {};
|
||||
const [qscoreResult, latestQscoreSignals] = await Promise.all([
|
||||
getQscoreFromService(userId, DEFAULT_QSCORE_ORG_ID),
|
||||
getQscoreLatestSignalsFromService(userId, DEFAULT_QSCORE_ORG_ID),
|
||||
]);
|
||||
const qscoreSignals = (latestQscoreSignals ?? []).map((s) => {
|
||||
return {
|
||||
signalId: typeof s.signalId === "string" ? s.signalId : typeof s.signal_id === "string" ? s.signal_id : "",
|
||||
score: typeof s.score === "number" ? s.score : 0,
|
||||
source: typeof s.source === "string" ? s.source : null,
|
||||
updatedAt: new Date(typeof s.updatedAt === "string" ? s.updatedAt : (qscoreResult?.calculated_at ?? new Date().toISOString())),
|
||||
signalId: s.signal_id,
|
||||
score: s.score,
|
||||
source: s.source,
|
||||
updatedAt: new Date(s.last_seen_at || s.last_occurred_at || qscoreResult?.calculated_at || 0),
|
||||
};
|
||||
});
|
||||
const activeMissions = await db
|
||||
@@ -452,10 +450,10 @@ async function collectContext(userId: string, input: { userProfile?: Record<stri
|
||||
user,
|
||||
qscore: qscoreResult
|
||||
? {
|
||||
score: qscoreResult.q_score,
|
||||
signalCount: typeof breakdown.signalCount === "number" ? breakdown.signalCount : qscoreSignals.length,
|
||||
summary: typeof breakdown.summary === "string" ? breakdown.summary : null,
|
||||
dimensions: isRecord(breakdown.dimensions) ? breakdown.dimensions : isRecord(qscoreResult.quotients) ? qscoreResult.quotients : null,
|
||||
rqScore: qscoreResult.rq_score,
|
||||
signalCount: qscoreSignals.length,
|
||||
summary: null,
|
||||
dimensions: isRecord(qscoreResult.quotients) ? qscoreResult.quotients : null,
|
||||
}
|
||||
: undefined,
|
||||
qscoreSignals,
|
||||
@@ -578,15 +576,7 @@ function buildModules(rows: GrowHomeNotificationRow[], ctx: HomeContext, mode: H
|
||||
}
|
||||
|
||||
async function buildIdentity(ctx: HomeContext) {
|
||||
const score = ctx.qscore?.score && ctx.qscore.score > 0 ? ctx.qscore.score : Math.round(ctx.qscoreSignals.reduce((sum, s) => sum + s.score, 0) / Math.max(ctx.qscoreSignals.length, 1)) || 47;
|
||||
const [baselineSnapshot] = await db
|
||||
.select({ score: qscoreSnapshots.score })
|
||||
.from(qscoreSnapshots)
|
||||
.where(and(eq(qscoreSnapshots.userId, ctx.user?.id ?? ""), eq(qscoreSnapshots.snapshotType, "baseline")))
|
||||
.orderBy(desc(qscoreSnapshots.createdAt))
|
||||
.limit(1);
|
||||
const baseline = baselineSnapshot?.score ?? Math.max(35, score - 29);
|
||||
const from = Math.max(baseline, Math.min(score, score - Math.min(17, Math.max(5, score - baseline))));
|
||||
const score = ctx.qscore?.rqScore ?? null;
|
||||
const name = ctx.user?.displayName || ctx.user?.email?.split("@")[0] || "GrowQR User";
|
||||
const completedArtifacts = ctx.artifacts.filter((a) => a.status === "ready" || a.status === "approved").length;
|
||||
|
||||
@@ -594,9 +584,8 @@ async function buildIdentity(ctx: HomeContext) {
|
||||
name,
|
||||
caption: "Your living QR · scan to view profile",
|
||||
qrSrc: undefined,
|
||||
qx: { from, to: score, baseline },
|
||||
rqScore: { from: null, to: score, baseline: null },
|
||||
glance: [
|
||||
{ value: Math.max(0, score - baseline), label: "Growth" },
|
||||
{ value: Math.max(0, ctx.qscore?.signalCount ?? ctx.qscoreSignals.length), label: "Signals" },
|
||||
{ value: completedArtifacts || ctx.events.length, label: "Done" },
|
||||
],
|
||||
@@ -604,7 +593,6 @@ async function buildIdentity(ctx: HomeContext) {
|
||||
}
|
||||
|
||||
export async function getHomeFeed(userId: string, opts: { refresh?: boolean; userProfile?: Record<string, unknown>; preferences?: Record<string, unknown> } = {}): Promise<HomeFeedResponse> {
|
||||
await ensureOnboardingBaselineQscoreFromLedger(userId);
|
||||
await buildCuratorSprint(userId);
|
||||
const ctx = await collectContext(userId, { userProfile: opts.userProfile, preferences: opts.preferences });
|
||||
const persisted = await readPersistedNotifications(userId);
|
||||
|
||||
@@ -28,7 +28,7 @@ export type HomeIdentity = {
|
||||
name: string;
|
||||
caption: string;
|
||||
qrSrc?: string;
|
||||
qx: { from: number; to: number; baseline: number };
|
||||
rqScore: { from: number | null; to: number | null; baseline: number | null };
|
||||
glance: { value: number; label: string }[];
|
||||
};
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ import { analyticsRoutes } from "./routes/analytics.js";
|
||||
import { logRoutes } from "./routes/logs.js";
|
||||
import { v1Routes } from "./v1/index.js";
|
||||
import { startGrowEventsRedisConsumer } from "./events/redis-consumer.js";
|
||||
import { startQscoreEventOutboxPublisher } from "./events/qscore-event-outbox.js";
|
||||
import { startPassiveMissionReviewLoop } from "./missions/passive-runner.js";
|
||||
import { db } from "./db/client.js";
|
||||
import { ensureRuntimeSchema } from "./db/ensure-runtime-schema.js";
|
||||
@@ -48,6 +49,7 @@ async function main() {
|
||||
|
||||
await reconcileOnBoot();
|
||||
startGrowEventsRedisConsumer().catch((err) => log.error({ err }, "failed to start grow events redis consumer"));
|
||||
startQscoreEventOutboxPublisher();
|
||||
startPassiveMissionReviewLoop();
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
@@ -36,7 +36,7 @@ function roleOf(stage: MissionStage) {
|
||||
const role = stage.role.toLowerCase();
|
||||
if (role.includes("resume")) return "Resume";
|
||||
if (role.includes("roleplay") || role.includes("communication")) return "Roleplay";
|
||||
if (role.includes("q") || role.includes("score") || role.includes("readiness")) return "Q Score";
|
||||
if (role.includes("q") || role.includes("score") || role.includes("readiness")) return "RQ Score";
|
||||
if (role.includes("interview")) return "Interview";
|
||||
return stage.role || "Mission";
|
||||
}
|
||||
@@ -103,7 +103,7 @@ function ctaFor(stage: MissionStage, snapshot: MissionSnapshot, context?: Missio
|
||||
params.set("focus", `${stage.title}: ${profile.targetRole}`);
|
||||
return { label: "Open resume", href: `/agents/resume?${params.toString()}` };
|
||||
}
|
||||
if (role === "Q Score") return { label: "View Q Score", href: `/agents/qscore?${params.toString()}` };
|
||||
if (role === "RQ Score") return { label: "View RQ Score", href: `/agents/qscore?${params.toString()}` };
|
||||
return { label: "Continue", href: `${missionDetailHref(snapshot.instanceId)}?${params.toString()}` };
|
||||
}
|
||||
|
||||
@@ -129,7 +129,7 @@ export function buildDeterministicMissionSuggestions(snapshot: MissionSnapshot,
|
||||
? `Tailor your resume toward ${profile.targetRole}${profile.targetCompany !== "target company" ? ` at ${profile.targetCompany}` : ""}, emphasizing measurable data-science impact.`
|
||||
: role === "Roleplay"
|
||||
? `Roleplay concise stakeholder communication for ${profile.targetRole} interviews, especially ${profile.weakSpots.slice(0, 2).join(" and ") || "tradeoff framing"}.`
|
||||
: role === "Q Score"
|
||||
: role === "RQ Score"
|
||||
? `Review readiness signals against your ${profile.targetRole} goal and decide the next highest-leverage action.`
|
||||
: undefined;
|
||||
|
||||
|
||||
@@ -94,7 +94,7 @@ function buildTools() {
|
||||
type: "function" as const,
|
||||
function: {
|
||||
name: "compute_qscore",
|
||||
description: "Compute the user's readiness Q Score via qscore-service.",
|
||||
description: "Compute the user's readiness RQ Score via qscore-service.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {},
|
||||
@@ -233,11 +233,11 @@ export function chatRoutes() {
|
||||
}
|
||||
case "compute_qscore": {
|
||||
toolResult = await runServiceAgentProbe(
|
||||
{ id: "qscore", name: "Q Score Agent", role: "Q Score Agent", kind: "score", description: "Readiness scoring", service: "qscore-service" },
|
||||
{ id: "qscore", name: "RQ Score Agent", role: "RQ Score Agent", kind: "score", description: "Readiness scoring", service: "qscore-service" },
|
||||
{ userId, goal: "general assessment" },
|
||||
);
|
||||
if (toolResult.status === "ok") {
|
||||
sessions.push({ moduleId: "qscore", moduleName: "Q Score Agent", status: "done", summary: toolResult.summary });
|
||||
sessions.push({ moduleId: "qscore", moduleName: "RQ Score Agent", status: "done", summary: toolResult.summary });
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -235,10 +235,10 @@ Personality & Tone:
|
||||
|
||||
How to help:
|
||||
- Just talk. Answer questions, give advice, ask follow-ups. Don't follow a rigid script.
|
||||
- Only use tools when the user clearly asks for something specific (e.g., "show me my missions", "start a mission", "what's my Q Score", "save this").
|
||||
- Only use tools when the user clearly asks for something specific (e.g., "show me my missions", "start a mission", "what's my RQ Score", "save this").
|
||||
- Don't call tools for general chitchat, emotional support, simple explanations, or when a normal text answer would do.
|
||||
- If you don't know something, say so. If a tool fails, just say it failed and move on.
|
||||
- When the user asks about interview prep, roleplay, resume, or Q Score — just answer normally. Only route to a specialist tool if they explicitly say something like "connect me to the interview specialist" or "let me talk to the roleplay agent".
|
||||
- When the user asks about interview prep, roleplay, resume, or RQ Score — just answer normally. Only route to a specialist tool if they explicitly say something like "connect me to the interview specialist" or "let me talk to the roleplay agent".
|
||||
- When the user asks to see today’s missions, streak tasks, daily tasks, missed tasks, task progress, or report readiness, call showCareerSprintTasks if CareerSprint context is available.
|
||||
- Use discoverWorkflows only for broad workflow/program discovery. When they ask about memory, use the memory tools. Otherwise, just chat.
|
||||
- Only start a mission if the user clearly says yes to starting one. Don't push.
|
||||
@@ -252,7 +252,7 @@ function safeAgentRegistry() {
|
||||
return [
|
||||
{ id: "interview", name: "Interview Agent", role: "Interview Coach", service: "interview-service", description: "Warm, direct interview practice coach for behavioral and technical interview prep.", toolNames: ["start_interview_session"] },
|
||||
{ id: "roleplay", name: "Roleplay Agent", role: "Roleplay Coach", service: "roleplay-service", description: "High-empathy roleplay partner for negotiation, recruiter, manager, and stakeholder conversations.", toolNames: ["start_roleplay_session"] },
|
||||
{ id: "qscore", name: "Q Score Agent", role: "Q Score Analyst", service: "qscore-service", description: "Analytical readiness scorer that turns profile signals into concrete score-improvement moves.", toolNames: ["compute_qscore"] },
|
||||
{ id: "qscore", name: "RQ Score Agent", role: "RQ Score Analyst", service: "qscore-service", description: "Analytical readiness scorer that turns profile signals into concrete score-improvement moves.", toolNames: ["compute_qscore"] },
|
||||
{ id: "resume", name: "Resume Agent", role: "Resume Agent", service: "resume-service", description: "ATS-aware resume specialist for sharper bullets, positioning, and role fit.", toolNames: ["analyze_resume", "tailor_resume"] },
|
||||
];
|
||||
}
|
||||
@@ -276,10 +276,10 @@ When helping:
|
||||
- Keep it practical. No fake cheerleading.
|
||||
|
||||
Return compact, dialogue-heavy markdown.`,
|
||||
qscore: `You are the Q Score Agent. You're an analytical readiness scorer. Explain the numbers plainly.
|
||||
qscore: `You are the RQ Score Agent. You're an analytical readiness scorer. Explain the numbers plainly.
|
||||
|
||||
When helping:
|
||||
- Explain what the Q Score measures and why it matters.
|
||||
- Explain what the RQ Score measures and why it matters.
|
||||
- Identify the 2-3 dimensions with the biggest improvement potential.
|
||||
- Give specific, time-bound actions for each dimension.
|
||||
- Use numbers when you have them. Don't sugarcoat or reframe weaknesses as "growth edges".
|
||||
|
||||
@@ -44,7 +44,7 @@ async function ingest(body: unknown, userId?: string, source?: string) {
|
||||
processingStatus: event.processingStatus,
|
||||
route,
|
||||
qscore: { signals: [], score: undefined, idempotent: true },
|
||||
onboarding: { qscoreBaselineSeeded: false, curatorOnboarding: { status: "already_processed" } },
|
||||
onboarding: { curatorOnboarding: { status: "already_processed" } },
|
||||
};
|
||||
}
|
||||
if (!event.userId) {
|
||||
@@ -53,7 +53,7 @@ async function ingest(body: unknown, userId?: string, source?: string) {
|
||||
processingStatus: event.processingStatus,
|
||||
route,
|
||||
qscore: { signals: [], score: undefined, skipped: "missing_user_id" },
|
||||
onboarding: { qscoreBaselineSeeded: false, curatorOnboarding: { status: "skipped", reason: "missing_user_id" } },
|
||||
onboarding: { curatorOnboarding: { status: "skipped", reason: "missing_user_id" } },
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -5,16 +5,16 @@ import { and, desc, eq } from "drizzle-orm";
|
||||
import { requireUser, type AuthContext } from "../auth/clerk.js";
|
||||
import { config } from "../config.js";
|
||||
import { listServiceCapabilities } from "../workflows/service-capabilities.js";
|
||||
import { interviewService, ProductServiceError, resumeService, roleplayService, type JsonObject } from "../services/product-service-clients.js";
|
||||
import { interviewService, ProductServiceError, resumeService, roleplayService, courseService, type JsonObject } from "../services/product-service-clients.js";
|
||||
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 { ensureOnboardingBaselineQscoreFromLedger } from "../events/onboarding-ledger.js";
|
||||
import { getRequestUserPreferences, getRequestUserProfile } from "../services/user-context.js";
|
||||
import { buildMatchmakingGatewayEvent } from "../services/matchmaking-events.js";
|
||||
import { log } from "../log.js";
|
||||
import { DEFAULT_QSCORE_ORG_ID, getQscoreFromService } from "../services/qscore-proxy.js";
|
||||
import { DEFAULT_QSCORE_ORG_ID, getQscoreFromService, getQscoreLatestSignalsFromService } from "../services/qscore-proxy.js";
|
||||
|
||||
const LANDING_AGENTS = [
|
||||
{ id: "resume", title: "Resume", agent: "Resume Strategist", description: "Resume proof, versions, parsing, analysis, and mission artifacts.", route: "/agents/resume" },
|
||||
@@ -22,19 +22,6 @@ const LANDING_AGENTS = [
|
||||
{ id: "roleplay", title: "Roleplay", agent: "Roleplay Coach", description: "Negotiation, promotion, transition, and communication drills.", route: "/agents/roleplay" },
|
||||
] as const;
|
||||
|
||||
const DEFAULT_QSCORE = {
|
||||
q_score: 76,
|
||||
profession: "Student",
|
||||
quotients: {
|
||||
SQ: 76,
|
||||
XQ: 74,
|
||||
CQm: 78,
|
||||
VQ: 72,
|
||||
DQ: 75,
|
||||
GQ: 80,
|
||||
},
|
||||
};
|
||||
|
||||
function getString(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
||||
}
|
||||
@@ -93,6 +80,15 @@ type SessionMissionContext = {
|
||||
mission?: Record<string, unknown>;
|
||||
taskId?: string;
|
||||
};
|
||||
async function assertSessionOwnership(serviceId: "interview" | "roleplay", sessionId: string, userId: string): Promise<void> {
|
||||
const [row] = await db
|
||||
.select({ userId: missionServiceSessions.userId })
|
||||
.from(missionServiceSessions)
|
||||
.where(and(eq(missionServiceSessions.serviceId, serviceId), eq(missionServiceSessions.externalId, sessionId)))
|
||||
.limit(1);
|
||||
if (!row) throw new HTTPException(404, { message: "session not found" });
|
||||
if (row.userId !== userId) throw new HTTPException(403, { message: "session ownership mismatch" });
|
||||
}
|
||||
|
||||
async function sessionMissionContext(serviceId: "interview" | "roleplay", sessionId: string): Promise<SessionMissionContext> {
|
||||
const [row] = await db
|
||||
@@ -229,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;
|
||||
@@ -251,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",
|
||||
@@ -274,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;
|
||||
}
|
||||
|
||||
@@ -372,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 };
|
||||
}
|
||||
|
||||
@@ -396,6 +444,17 @@ function parseJsonBody(body: ArrayBuffer | undefined, headers: Headers): JsonObj
|
||||
}
|
||||
}
|
||||
|
||||
function decodedProxyHeaders(headers: Headers) {
|
||||
const forwarded = new Headers(headers);
|
||||
// Node fetch transparently decodes gzip/br bodies but retains the upstream
|
||||
// encoding and byte-length headers. Forwarding those stale headers makes the
|
||||
// next proxy/browser decode an already-decoded body and fail.
|
||||
forwarded.delete("content-encoding");
|
||||
forwarded.delete("content-length");
|
||||
forwarded.delete("transfer-encoding");
|
||||
return forwarded;
|
||||
}
|
||||
|
||||
async function proxyResumeRequest(req: Request, rest: string, userId: string) {
|
||||
const incoming = new URL(req.url);
|
||||
const normalizedRest = rest
|
||||
@@ -419,6 +478,7 @@ async function proxyResumeRequest(req: Request, rest: string, userId: string) {
|
||||
headers.delete("cookie");
|
||||
headers.set("authorization", `Bearer ${config.a2aAllowedKey}`);
|
||||
headers.set("x-growqr-user", userId);
|
||||
headers.set("accept-encoding", "identity");
|
||||
|
||||
const method = req.method.toUpperCase();
|
||||
const body = ["GET", "HEAD"].includes(method) ? undefined : await req.arrayBuffer();
|
||||
@@ -436,7 +496,11 @@ async function proxyResumeRequest(req: Request, rest: string, userId: string) {
|
||||
});
|
||||
|
||||
if (method === "GET" || method === "HEAD") {
|
||||
return new Response(res.body, { status: res.status, statusText: res.statusText, headers: res.headers });
|
||||
return new Response(res.body, {
|
||||
status: res.status,
|
||||
statusText: res.statusText,
|
||||
headers: decodedProxyHeaders(res.headers),
|
||||
});
|
||||
}
|
||||
|
||||
const responseBuffer = await res.arrayBuffer();
|
||||
@@ -458,10 +522,10 @@ async function proxyResumeRequest(req: Request, rest: string, userId: string) {
|
||||
mission,
|
||||
}).catch((err) => log.warn({ err, path: normalizedRest }, "failed to record resume gateway event"));
|
||||
|
||||
return new Response(responseBuffer, {
|
||||
return new Response(res.status === 204 || res.status === 205 || res.status === 304 ? null : responseBuffer, {
|
||||
status: res.status,
|
||||
statusText: res.statusText,
|
||||
headers: res.headers,
|
||||
headers: decodedProxyHeaders(res.headers),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -519,6 +583,9 @@ async function resolveGrowUserContext(req: Request, userId: string): Promise<Rec
|
||||
if (resumeState.current_company && !userContext.current_company) userContext.current_company = resumeState.current_company;
|
||||
if (Array.isArray(resumeState.experience_history)) userContext.experience_history = resumeState.experience_history;
|
||||
if (Array.isArray(resumeState.education)) userContext.education = resumeState.education;
|
||||
userContext.resume_available = Number(resumeState.resume_count ?? 0) > 0;
|
||||
const resumeSummary = getString(resumeState.resume_summary ?? resumeState.resumeSummary);
|
||||
if (resumeSummary) userContext.resume_summary = resumeSummary;
|
||||
}
|
||||
|
||||
if (socialState) {
|
||||
@@ -534,7 +601,9 @@ async function resolveGrowUserContext(req: Request, userId: string): Promise<Rec
|
||||
|
||||
function hasResumeContext(userContext: Record<string, unknown>): boolean {
|
||||
return Boolean(
|
||||
(Array.isArray(userContext.experience_history) && userContext.experience_history.length > 0) ||
|
||||
userContext.resume_available ||
|
||||
getString(userContext.resume_summary) ||
|
||||
(Array.isArray(userContext.experience_history) && userContext.experience_history.length > 0) ||
|
||||
(Array.isArray(userContext.skills) && userContext.skills.length > 0) ||
|
||||
getString(userContext.current_role)
|
||||
);
|
||||
@@ -556,6 +625,9 @@ function composeCandidateProfile(userContext: Record<string, unknown>): string {
|
||||
if (currentRole) lines.push(`Current role: ${currentRole}`);
|
||||
if (headline && headline.toLowerCase() !== currentRole?.toLowerCase()) lines.push(`LinkedIn headline: ${headline}`);
|
||||
|
||||
const resumeSummary = getString(userContext.resume_summary);
|
||||
if (resumeSummary) lines.push(`Primary resume summary: ${resumeSummary.length > 600 ? `${resumeSummary.slice(0, 600).trimEnd()}…` : resumeSummary}`);
|
||||
|
||||
const skills = stringArray(userContext.skills);
|
||||
if (skills.length) lines.push(`Key skills: ${skills.slice(0, 12).join(", ")}`);
|
||||
|
||||
@@ -591,7 +663,7 @@ function composeCandidateProfile(userContext: Record<string, unknown>): string {
|
||||
if (education.length) lines.push(`Education: ${education.slice(0, 3).join("; ")}`);
|
||||
|
||||
const summary = getString(userContext.linkedin_summary);
|
||||
if (summary) lines.push(`Profile summary: ${summary.length > 300 ? `${summary.slice(0, 300).trimEnd()}…` : summary}`);
|
||||
if (summary && summary !== resumeSummary) lines.push(`LinkedIn summary: ${summary.length > 300 ? `${summary.slice(0, 300).trimEnd()}…` : summary}`);
|
||||
|
||||
const text = lines.join("\n");
|
||||
return text.length > 1200 ? `${text.slice(0, 1200).trimEnd()}…` : text;
|
||||
@@ -613,7 +685,8 @@ async function buildPersonalizedConfigurePayload(req: Request, body: JsonObject,
|
||||
difficulty: getString(incomingContext.difficulty) ?? "medium",
|
||||
};
|
||||
|
||||
if (incomingContext.personalize) {
|
||||
const curatorHandoff = getString(incomingContext.source) === "curator-v1" || Boolean(getString(incomingContext.curatorTaskId));
|
||||
if (incomingContext.personalize || curatorHandoff) {
|
||||
const candidateProfile = composeCandidateProfile(userContext);
|
||||
if (candidateProfile) context.candidate_profile = candidateProfile;
|
||||
}
|
||||
@@ -626,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>;
|
||||
});
|
||||
@@ -650,12 +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,
|
||||
qscore: (rest.qscore as JsonObject | undefined) ?? (isRecord(userContext.qscore) ? userContext.qscore : DEFAULT_QSCORE),
|
||||
org_id: orgId,
|
||||
...(qscore ? { qscore } : {}),
|
||||
...(isRecord(mission) ? { mission } : {}),
|
||||
user_context: userContext,
|
||||
};
|
||||
}
|
||||
@@ -730,9 +819,66 @@ async function proxySocialRequest(req: Request, rest: string, userId: string) {
|
||||
});
|
||||
}
|
||||
|
||||
export function serviceRoutes() {
|
||||
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>();
|
||||
app.use("*", requireUser);
|
||||
if (!options.skipAuth) app.use("*", requireUser);
|
||||
if (options.skipAuth) app.use("*", async (c, next) => { c.set("userId", "user_test"); c.set("userEmail", "user_test@example.com"); await next(); });
|
||||
|
||||
app.get("/catalog", (c) => c.json({ services: listServiceCapabilities({ public: true }) }));
|
||||
|
||||
@@ -782,16 +928,13 @@ export function serviceRoutes() {
|
||||
|
||||
app.get("/qscore/current", async (c) => {
|
||||
const userId = c.get("userId");
|
||||
try {
|
||||
await ensureOnboardingBaselineQscoreFromLedger(userId);
|
||||
} catch (err) {
|
||||
log.warn({ err, userId }, "failed to seed onboarding Q Score baseline before current Q Score read");
|
||||
}
|
||||
|
||||
const qscoreResult = await getQscoreFromService(userId, DEFAULT_QSCORE_ORG_ID);
|
||||
const score = qscoreResult?.q_score ?? null;
|
||||
const [qscoreResult, latestSignals] = await Promise.all([
|
||||
getQscoreFromService(userId, DEFAULT_QSCORE_ORG_ID),
|
||||
getQscoreLatestSignalsFromService(userId, DEFAULT_QSCORE_ORG_ID),
|
||||
]);
|
||||
const rqScore = qscoreResult?.rq_score ?? null;
|
||||
const breakdown = qscoreResult?.breakdown ?? {};
|
||||
const breakdownSignals = Array.isArray(breakdown.signals) ? breakdown.signals : [];
|
||||
const evidence = latestSignals ?? [];
|
||||
const quotients = qscoreResult?.quotients ?? {};
|
||||
|
||||
const dimensionFromKey = (id: string, value: unknown): {
|
||||
@@ -815,37 +958,32 @@ export function serviceRoutes() {
|
||||
.filter((d): d is { id: string; label: string; score: number; signalCount: number; sources: string[] } => d !== null);
|
||||
|
||||
const response = {
|
||||
qscore: score === null ? null : {
|
||||
score,
|
||||
qscore: rqScore === null ? null : {
|
||||
rq_score: rqScore,
|
||||
iq_score: qscoreResult?.iq_score ?? null,
|
||||
eq_score: qscoreResult?.eq_score ?? null,
|
||||
sq_score: qscoreResult?.sq_score ?? null,
|
||||
signalCount: breakdownSignals.length,
|
||||
summary: typeof breakdown.summary === "string" ? breakdown.summary : `Readiness score computed from ${breakdownSignals.length} current signal${breakdownSignals.length === 1 ? "" : "s"}.`,
|
||||
signalCount: evidence.length,
|
||||
breakdown,
|
||||
formulaVersion: qscoreResult?.formula_version ?? null,
|
||||
formulaId: qscoreResult?.formula_id ?? null,
|
||||
ledgerSeqFrom: qscoreResult?.ledger_seq_from ?? null,
|
||||
ledgerSeqTo: qscoreResult?.ledger_seq_to ?? null,
|
||||
dimensions,
|
||||
updatedAt: qscoreResult?.calculated_at ?? null,
|
||||
},
|
||||
signals: breakdownSignals.map((signal) => {
|
||||
const s = isRecord(signal) ? signal : {};
|
||||
return {
|
||||
signalId: typeof s.signalId === "string" ? s.signalId : typeof s.signal_id === "string" ? s.signal_id : "",
|
||||
score: typeof s.score === "number" ? Math.round(s.score) : 0,
|
||||
present: typeof s.present === "boolean" ? s.present : true,
|
||||
source: typeof s.source === "string" ? s.source : "",
|
||||
occurredAt: typeof s.occurredAt === "string" ? s.occurredAt : qscoreResult?.calculated_at ?? new Date().toISOString(),
|
||||
updatedAt: typeof s.updatedAt === "string" ? s.updatedAt : qscoreResult?.calculated_at ?? new Date().toISOString(),
|
||||
};
|
||||
}),
|
||||
signals: evidence.map((signal) => ({
|
||||
signalId: signal.signal_id,
|
||||
score: signal.score,
|
||||
present: signal.present,
|
||||
source: signal.source,
|
||||
raw: signal.raw,
|
||||
occurredAt: signal.last_occurred_at,
|
||||
updatedAt: signal.last_seen_at || null,
|
||||
formulaVersion: signal.formula_version,
|
||||
})),
|
||||
};
|
||||
|
||||
await recordGatewayEvent({
|
||||
userId,
|
||||
source: "qscore-service",
|
||||
type: "qscore.review.opened",
|
||||
payload: { score, signalCount: breakdownSignals.length, source: "services.qscore.current" },
|
||||
correlation: { taskId: curatorTaskIdFromRequest(c.req.raw) },
|
||||
}).catch((err) => log.warn({ err, userId }, "failed to record qscore review event"));
|
||||
|
||||
return c.json(response);
|
||||
});
|
||||
|
||||
@@ -864,12 +1002,17 @@ export function serviceRoutes() {
|
||||
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>();
|
||||
const mission = missionFromRequest(c.req.raw, body);
|
||||
const payload = await buildPersonalizedConfigurePayload(c.req.raw, body, userId);
|
||||
const result = await interviewService.configure(payload).catch(serviceErrorResponse);
|
||||
const result = await interviewService.configure(payload, userId).catch(serviceErrorResponse);
|
||||
const resultObj = result as Record<string, unknown>;
|
||||
const taskId = curatorTaskIdFromRequest(c.req.raw, body);
|
||||
const orchestration = orchestrationFromMission(mission, taskId);
|
||||
@@ -886,12 +1029,15 @@ export function serviceRoutes() {
|
||||
app.post("/interview/preview", async (c) => {
|
||||
const body = await c.req.json<JsonObject>();
|
||||
const payload = await buildPersonalizedConfigurePayload(c.req.raw, body, c.get("userId"));
|
||||
return c.json(await interviewService.preview(payload));
|
||||
return c.json(await interviewService.preview(payload, c.get("userId")));
|
||||
});
|
||||
app.post("/interview/questions", async (c) => {
|
||||
const body = await c.req.json<{ session_id: string; questions: Array<JsonObject | string> }>();
|
||||
return c.json(await interviewService.editQuestions(body, c.get("userId")));
|
||||
});
|
||||
app.post("/interview/questions", async (c) => c.json(await interviewService.editQuestions(await c.req.json())));
|
||||
app.post("/interview/approve", async (c) => {
|
||||
const body = await c.req.json<{ session_id: string }>();
|
||||
return c.json(await interviewService.approve(body.session_id));
|
||||
return c.json(await interviewService.approve(body.session_id, c.get("userId")));
|
||||
});
|
||||
app.post("/interview/assignments", async (c) => c.json(await interviewService.createAssignments(await c.req.json())));
|
||||
app.get("/interview/assignments", async (c) => c.json(await interviewService.listAssignments(c.req.query("email") ?? "", c.req.query("status") ?? "pending", Number(c.req.query("limit") ?? 20))));
|
||||
@@ -915,9 +1061,23 @@ export function serviceRoutes() {
|
||||
}).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");
|
||||
const userId = c.get("userId");
|
||||
await assertSessionOwnership("interview", sessionId, userId);
|
||||
const issued = signWsTicket({ secret: config.sessionTicketSecret, audience: "interview", subject: userId, sessionId });
|
||||
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) => {
|
||||
@@ -935,12 +1095,17 @@ export function serviceRoutes() {
|
||||
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>();
|
||||
const mission = missionFromRequest(c.req.raw, body);
|
||||
const payload = await buildPersonalizedRoleplayConfigurePayload(c.req.raw, body, userId);
|
||||
const result = await roleplayService.configure(payload).catch(serviceErrorResponse);
|
||||
const result = await roleplayService.configure(payload, userId).catch(serviceErrorResponse);
|
||||
const resultObj = result as Record<string, unknown>;
|
||||
const taskId = curatorTaskIdFromRequest(c.req.raw, body);
|
||||
const orchestration = orchestrationFromMission(mission, taskId);
|
||||
@@ -957,13 +1122,38 @@ export function serviceRoutes() {
|
||||
app.post("/roleplay/preview", async (c) => {
|
||||
const body = await c.req.json<JsonObject>();
|
||||
const payload = await buildPersonalizedRoleplayConfigurePayload(c.req.raw, body, c.get("userId"));
|
||||
return c.json(await roleplayService.preview(payload));
|
||||
return c.json(await roleplayService.preview(payload, c.get("userId")));
|
||||
});
|
||||
app.post("/roleplay/questions", async (c) => {
|
||||
const body = await c.req.json<{ session_id: string; questions: Array<JsonObject | string> }>();
|
||||
return c.json(await roleplayService.editQuestions(body, c.get("userId")));
|
||||
});
|
||||
app.post("/roleplay/questions", async (c) => c.json(await roleplayService.editQuestions(await c.req.json())));
|
||||
app.post("/roleplay/approve", async (c) => {
|
||||
const body = await c.req.json<{ session_id: string }>();
|
||||
return c.json(await roleplayService.approve(body.session_id));
|
||||
const userId = c.get("userId");
|
||||
const result = await roleplayService.approve(body.session_id, userId).catch(serviceErrorResponse);
|
||||
const resultObj = result as Record<string, unknown>;
|
||||
await recordGatewayEvent({
|
||||
userId,
|
||||
source: "roleplay-service",
|
||||
type: "roleplay.scenario.configured",
|
||||
payload: { request: body, result: resultObj },
|
||||
correlation: { sessionId: getSessionId(resultObj) ?? body.session_id },
|
||||
}).catch((err) => log.warn({ err }, "failed to record resumed roleplay configured event"));
|
||||
return c.json(result);
|
||||
});
|
||||
app.get("/roleplay/plan/:sessionId", async (c) =>
|
||||
c.json(await roleplayService.plan(c.req.param("sessionId"), c.get("userId")).catch(serviceErrorResponse)));
|
||||
app.get("/roleplay/drafts", async (c) => {
|
||||
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())));
|
||||
app.get("/roleplay/assignments", async (c) => c.json(await roleplayService.listAssignments(c.req.query("email") ?? "", c.req.query("status") ?? "pending", Number(c.req.query("limit") ?? 20))));
|
||||
app.post("/roleplay/assignments/unassign", async (c) => c.json(await roleplayService.unassign(await c.req.json())));
|
||||
@@ -986,9 +1176,23 @@ export function serviceRoutes() {
|
||||
}).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");
|
||||
const userId = c.get("userId");
|
||||
await assertSessionOwnership("roleplay", sessionId, userId);
|
||||
const issued = signWsTicket({ secret: config.sessionTicketSecret, audience: "roleplay", subject: userId, sessionId });
|
||||
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"))));
|
||||
@@ -1028,21 +1232,38 @@ export function serviceRoutes() {
|
||||
const rest = c.req.path.split("/social/")[1] ?? "";
|
||||
return proxySocialRequest(c.req.raw, rest, c.get("userId"));
|
||||
});
|
||||
app.post("/courses/a2a", async (c) => {
|
||||
const userId = c.get("userId");
|
||||
const body = await c.req.json<JsonObject>().catch((): JsonObject => ({}));
|
||||
const mission = missionFromRequest(c.req.raw, body);
|
||||
const taskId = curatorTaskIdFromRequest(c.req.raw, body);
|
||||
const missionParams = mission
|
||||
? orchestrationFromMission(mission, taskId)
|
||||
: taskId
|
||||
? { taskId, curatorTaskId: taskId }
|
||||
: {};
|
||||
const params = isRecord(body.params) ? body.params : {};
|
||||
const action = getString(body.action) ?? (body.session_start ? "session_start" : "");
|
||||
const result = await courseService.task({
|
||||
...body,
|
||||
action,
|
||||
user_id: userId,
|
||||
params: { ...missionParams, ...params },
|
||||
});
|
||||
return c.json(result);
|
||||
});
|
||||
|
||||
|
||||
app.post("/matchmaking/a2a", async (c) => {
|
||||
const userId = c.get("userId");
|
||||
const body = await c.req.json<JsonObject>().catch(() => ({}));
|
||||
const { action, result } = await callMatchmakingA2a(body, userId);
|
||||
const event = buildMatchmakingGatewayEvent({ userId, action, body, result });
|
||||
await recordGatewayEvent({
|
||||
userId,
|
||||
source: event.source,
|
||||
type: event.type,
|
||||
payload: event.payload,
|
||||
correlation: event.correlation,
|
||||
mission: event.mission ?? missionFromRequest(c.req.raw, body),
|
||||
subject: event.subject,
|
||||
}).catch((err) => log.warn({ err, userId, action }, "failed to record matchmaking workflow event"));
|
||||
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);
|
||||
});
|
||||
|
||||
|
||||
@@ -4,13 +4,22 @@ import { db } from "../db/client.js";
|
||||
import { onboarding, users, userStacks, type UserStack } from "../db/schema.js";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { log } from "../log.js";
|
||||
import { config } from "../config.js";
|
||||
import { fetchOnboardingReadProfile, fetchUserProfile, patchPreferencesRequest, userServiceTarget } from "../services/user-profile.js";
|
||||
import { serializeOnboardingState } from "../services/onboarding-serialization.js";
|
||||
import {
|
||||
onboardingCompletedAtFromPreferences,
|
||||
} from "../v1/curator/curator-onboarding-loop.js";
|
||||
import {
|
||||
getLatestValidOnboardingLedgerEvent,
|
||||
recordAndProcessOnboardingCompletion,
|
||||
extractOnboardingData,
|
||||
assertOnboardingRevision,
|
||||
mergeOnboardingPatch,
|
||||
resetOnboardingLedger,
|
||||
resetOnboardingPreferences,
|
||||
defaultOnboardingData,
|
||||
OnboardingRevisionConflict,
|
||||
type OnboardingData,
|
||||
} from "../events/onboarding-ledger.js";
|
||||
import { z } from "zod";
|
||||
|
||||
@@ -70,9 +79,6 @@ function publicStack(stack: UserStack | null | undefined) {
|
||||
return safe;
|
||||
}
|
||||
|
||||
function userServiceTarget(path: string, search = "") {
|
||||
return new URL(`/api/v1/users${path}${search}`, config.userServiceUrl.replace(/\/$/, ""));
|
||||
}
|
||||
|
||||
async function fetchUserService(req: Request, path: string) {
|
||||
const incoming = new URL(req.url);
|
||||
@@ -108,6 +114,64 @@ async function ensureUserServiceUser(req: Request) {
|
||||
return res.json() as Promise<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
|
||||
function recordPreferences(profile: Record<string, unknown>): Record<string, unknown> {
|
||||
return isPlainObject(profile.preferences) ? profile.preferences : {};
|
||||
}
|
||||
|
||||
|
||||
|
||||
type OnboardingResetResult =
|
||||
| { error: null; body: Record<string, unknown>; status: 200 }
|
||||
| { error: string; message: string; status: 502 };
|
||||
|
||||
/**
|
||||
* Reusable per-user onboarding reset. Resets preferences.onboarding to the
|
||||
* default v3 doc (preserving all unrelated preference keys, history, and
|
||||
* curator data) and deletes ONLY the user's completion-type ledger rows so the
|
||||
* onboarding gate reopens. Idempotent — a user with no onboarding state is a
|
||||
* no-op success.
|
||||
*
|
||||
* Exported so the staging bulk-reset CLI can reuse the exact same logic
|
||||
* (preferences reset + backend ledger deletion) end-to-end via the service
|
||||
* token path, instead of re-implementing it.
|
||||
*/
|
||||
export async function resetUserOnboarding(req: Request, userId: string): Promise<OnboardingResetResult> {
|
||||
const profile = await fetchUserProfile(req);
|
||||
const preferences = recordPreferences(profile);
|
||||
const nextPreferences = resetOnboardingPreferences(preferences);
|
||||
|
||||
const updateRes = await fetchUserService(patchPreferencesRequest(req, nextPreferences), "/me");
|
||||
if (!updateRes.ok) {
|
||||
const text = await updateRes.text().catch(() => "");
|
||||
log.error({ err: text, userId, status: updateRes.status }, "failed to persist reset onboarding preferences");
|
||||
return {
|
||||
error: "user_service_persist_failed",
|
||||
message: "Failed to reset onboarding preferences",
|
||||
status: 502,
|
||||
};
|
||||
}
|
||||
|
||||
const ledgerDeleted = await resetOnboardingLedger(userId);
|
||||
log.info({ userId, ledgerDeleted }, "onboarding reset complete");
|
||||
|
||||
const data = extractOnboardingData(nextPreferences.onboarding);
|
||||
return {
|
||||
error: null,
|
||||
body: {
|
||||
id: userId,
|
||||
reset: true,
|
||||
ledgerRowsDeleted: ledgerDeleted,
|
||||
data,
|
||||
},
|
||||
status: 200,
|
||||
};
|
||||
}
|
||||
|
||||
export function userRoutes() {
|
||||
const app = new Hono<AuthContext>();
|
||||
app.use("*", requireUser);
|
||||
@@ -151,90 +215,183 @@ export function userRoutes() {
|
||||
const userId = c.get("userId");
|
||||
const state = await db.query.onboarding.findFirst({ where: eq(onboarding.userId, userId) });
|
||||
const event = await getLatestValidOnboardingLedgerEvent(userId);
|
||||
const data = asRecord(state?.data);
|
||||
const completed = data.status === "completed" && typeof data.completed_at === "string";
|
||||
if (event) {
|
||||
return c.json({
|
||||
userId,
|
||||
hasOnboardingEvent: true,
|
||||
onboardingEvent: {
|
||||
id: event.id,
|
||||
type: event.type,
|
||||
occurredAt: event.occurredAt.toISOString(),
|
||||
processingStatus: event.processingStatus,
|
||||
},
|
||||
needsOnboarding: false,
|
||||
});
|
||||
}
|
||||
|
||||
// Ledger missing but canonical preferences may still show completed (the
|
||||
// completion row was lost/never written). Self-heal: re-run the existing
|
||||
// idempotent completion recording, which re-creates the ledger row via the
|
||||
// `onboarding:completed:<userId>` dedupe key. If the canonical state is NOT
|
||||
// completed, the gate stays open honestly. A heal failure must surface as a
|
||||
// service error — never as a false "needs onboarding" for an already-done
|
||||
// user, which would force them through onboarding again.
|
||||
let healed: { ok: true } | { ok: false; status: 502; error: string; message: string };
|
||||
try {
|
||||
const profile = await fetchUserProfile(c.req.raw);
|
||||
const preferences = recordPreferences(profile);
|
||||
const data = extractOnboardingData(preferences.onboarding);
|
||||
if (data.status === "completed" && data.completed_at) {
|
||||
await recordAndProcessOnboardingCompletion({
|
||||
userId,
|
||||
completedAt: data.completed_at,
|
||||
source: "onboarding-status-heal",
|
||||
context: { preferences, onboarding: data },
|
||||
});
|
||||
healed = { ok: true };
|
||||
} else {
|
||||
return c.json({
|
||||
userId,
|
||||
hasOnboardingEvent: false,
|
||||
onboardingEvent: null,
|
||||
needsOnboarding: true,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
log.error({ err, userId }, "onboarding status heal failed");
|
||||
healed = {
|
||||
ok: false,
|
||||
status: 502,
|
||||
error: "onboarding_status_heal_failed",
|
||||
message: "Onboarding status check failed; please retry",
|
||||
};
|
||||
}
|
||||
|
||||
if (!healed.ok) {
|
||||
return c.json({ error: healed.error, message: healed.message }, healed.status);
|
||||
}
|
||||
|
||||
// The heal re-ran the idempotent completion recording, so a fresh read
|
||||
// should now find the ledger row. Re-fetch to return a consistent shape
|
||||
// (onboardingEvent populated alongside hasOnboardingEvent: true) rather than
|
||||
// a contradictory true+null that downstream consumers would misread.
|
||||
const fresh = await getLatestValidOnboardingLedgerEvent(userId);
|
||||
return c.json({
|
||||
userId,
|
||||
hasOnboardingEvent: Boolean(event),
|
||||
onboardingEvent: event
|
||||
hasOnboardingEvent: true,
|
||||
onboardingEvent: fresh
|
||||
? {
|
||||
id: event.id,
|
||||
type: event.type,
|
||||
occurredAt: event.occurredAt.toISOString(),
|
||||
processingStatus: event.processingStatus,
|
||||
}
|
||||
id: fresh.id,
|
||||
type: fresh.type,
|
||||
occurredAt: fresh.occurredAt.toISOString(),
|
||||
processingStatus: fresh.processingStatus,
|
||||
}
|
||||
: null,
|
||||
needsOnboarding: !completed,
|
||||
needsOnboarding: false,
|
||||
healed: true,
|
||||
});
|
||||
});
|
||||
|
||||
// ── Onboarding preferences (canonical store: user-service preferences.onboarding) ─
|
||||
// GET returns the v3 OnboardingData (or a default v3 doc when none exists yet)
|
||||
// plus its faithful projection payload and the current revision.
|
||||
// PATCH performs optimistic-concurrency merge (expectedRevision must match),
|
||||
// preserves all unrelated preference keys, persists access_choice, validates
|
||||
// status/completed_at consistency, and fires the existing completion ledger
|
||||
// side effects when status is "completed".
|
||||
app.get("/onboarding", async (c) => {
|
||||
const userId = c.get("userId");
|
||||
const state = await db.query.onboarding.findFirst({ where: eq(onboarding.userId, userId) });
|
||||
return c.json({
|
||||
id: state?.id ?? null,
|
||||
data: state?.data ?? null,
|
||||
payload: state?.payload ?? null,
|
||||
revision: typeof asRecord(state?.data).revision === "number" ? asRecord(state?.data).revision : 0,
|
||||
updatedAt: state?.updatedAt?.toISOString() ?? null,
|
||||
});
|
||||
const profile = await fetchOnboardingReadProfile(c.req.raw, userId);
|
||||
const preferences = recordPreferences(profile);
|
||||
return c.json(serializeOnboardingState(userId, preferences.onboarding));
|
||||
});
|
||||
|
||||
app.patch("/onboarding", async (c) => {
|
||||
const userId = c.get("userId");
|
||||
const body = onboardingPatchSchema.parse(await c.req.json());
|
||||
const existing = await db.query.onboarding.findFirst({ where: eq(onboarding.userId, userId) });
|
||||
const existingData = asRecord(existing?.data);
|
||||
const currentRevision = typeof existingData.revision === "number" ? existingData.revision : 0;
|
||||
if (body.expectedRevision !== undefined && body.expectedRevision !== currentRevision) {
|
||||
return c.json({
|
||||
error: "onboarding_revision_conflict",
|
||||
expectedRevision: body.expectedRevision,
|
||||
currentRevision,
|
||||
data: existing?.data ?? null,
|
||||
payload: existing?.payload ?? null,
|
||||
}, 409);
|
||||
const body = await c.req.json().catch(() => null) as
|
||||
| { data?: unknown; expectedRevision?: unknown }
|
||||
| null;
|
||||
|
||||
if (!body || typeof body !== "object") {
|
||||
return c.json({ error: "invalid_body", message: "Expected { data, expectedRevision }" }, 400);
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const nextData: Record<string, unknown> = {
|
||||
...body.data,
|
||||
schema_version: 3,
|
||||
revision: currentRevision + 1,
|
||||
updated_at: now.toISOString(),
|
||||
};
|
||||
const payload = buildOnboardingPayload(nextData);
|
||||
const [saved] = await db.insert(onboarding).values({
|
||||
userId,
|
||||
data: nextData,
|
||||
payload,
|
||||
updatedAt: now,
|
||||
}).onConflictDoUpdate({
|
||||
target: onboarding.userId,
|
||||
set: { data: nextData, payload, updatedAt: now },
|
||||
}).returning();
|
||||
if (!saved) throw new Error("failed_to_save_onboarding");
|
||||
const incoming = extractOnboardingData(body.data);
|
||||
const expectedRevision = typeof body.expectedRevision === "number" ? body.expectedRevision : undefined;
|
||||
|
||||
const completedAt = nextData.status === "completed" && typeof nextData.completed_at === "string"
|
||||
? new Date(nextData.completed_at)
|
||||
: null;
|
||||
if (completedAt && !Number.isNaN(completedAt.getTime())) {
|
||||
await recordAndProcessOnboardingCompletion({
|
||||
userId,
|
||||
completedAt,
|
||||
source: "onboarding-data",
|
||||
context: { onboarding: nextData, payload },
|
||||
});
|
||||
// Read current profile to resolve the stored revision for the OCC check.
|
||||
const profile = await fetchUserProfile(c.req.raw);
|
||||
const preferences = recordPreferences(profile);
|
||||
const current = extractOnboardingData(preferences.onboarding);
|
||||
|
||||
try {
|
||||
assertOnboardingRevision(expectedRevision, current);
|
||||
} catch (err) {
|
||||
if (err instanceof OnboardingRevisionConflict) {
|
||||
return c.json(
|
||||
{
|
||||
error: "revision_conflict",
|
||||
message: "Onboarding was modified by another session. Reload and retry.",
|
||||
expected: err.expected,
|
||||
actual: err.actual,
|
||||
revision: err.actual,
|
||||
},
|
||||
409,
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
return c.json({
|
||||
id: saved.id,
|
||||
data: saved.data,
|
||||
payload: saved.payload,
|
||||
revision: currentRevision + 1,
|
||||
updatedAt: saved.updatedAt.toISOString(),
|
||||
});
|
||||
// Stamp the incoming revision with the authoritative stored revision so the
|
||||
// merge helper bumps from it (the client sends the revision it read).
|
||||
const incomingWithClientRev: OnboardingData = { ...incoming, revision: current.revision };
|
||||
const nextPreferences = mergeOnboardingPatch(preferences, incomingWithClientRev);
|
||||
const nextData = extractOnboardingData(nextPreferences.onboarding);
|
||||
|
||||
// Persist back to user-service via PATCH /me (only preferences is written).
|
||||
const updateRes = await fetchUserService(patchPreferencesRequest(c.req.raw, nextPreferences), "/me");
|
||||
if (!updateRes.ok) {
|
||||
const text = await updateRes.text().catch(() => "");
|
||||
log.error({ err: text, userId, status: updateRes.status }, "failed to persist onboarding preferences");
|
||||
return c.json(
|
||||
{ error: "user_service_persist_failed", message: "Failed to save onboarding preferences" },
|
||||
502,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// Completion ledger side effects (idempotent via dedupeKey onboarding:completed:<userId>).
|
||||
if (nextData.status === "completed" && nextData.completed_at) {
|
||||
try {
|
||||
await recordAndProcessOnboardingCompletion({
|
||||
userId,
|
||||
completedAt: nextData.completed_at,
|
||||
source: "onboarding-api",
|
||||
context: {
|
||||
preferences: nextPreferences,
|
||||
onboarding: nextData,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
log.warn({ err, userId }, "failed to run onboarding completion side effects");
|
||||
}
|
||||
}
|
||||
|
||||
return c.json(serializeOnboardingState(userId, nextPreferences.onboarding));
|
||||
});
|
||||
|
||||
// DELETE /onboarding — authenticated per-user reset. Reopens the onboarding
|
||||
// gate by deleting ONLY the user's completion-type ledger rows (those carrying
|
||||
// the onboarding:completed dedupe key) and resetting preferences.onboarding to
|
||||
// the default v3 doc. Preserves unrelated preferences, snapshot history,
|
||||
// missions, and curator data. Idempotent: deleting nothing is a success.
|
||||
app.delete("/onboarding", async (c) => {
|
||||
const userId = c.get("userId");
|
||||
const result = await resetUserOnboarding(c.req.raw, userId);
|
||||
if (result.error !== null) {
|
||||
return c.json({ error: result.error, message: result.message }, result.status);
|
||||
}
|
||||
return c.json(result.body, result.status);
|
||||
});
|
||||
|
||||
app.get("/me", async (c) => proxyUserService(c.req.raw, "/me"));
|
||||
|
||||
@@ -67,10 +67,10 @@ export function workflowRoutes() {
|
||||
await db.insert(workflowRunModules).values(def.modules.map((m) => ({ runId: run.id, moduleId: m.id, title: m.title, status: "idle", service: m.service })));
|
||||
await db.insert(workflowEvents).values({ runId: run.id, userId, type: "workflow.started", payload: { workflowId: def.id, goal: body.goal } });
|
||||
try {
|
||||
const baseline = await runServiceAgentProbe({ id: "qscore", name: "Q Score Agent", role: "Q Score", kind: "score", description: "Baseline Q Score", service: "qscore-service" }, { userId, goal: body.goal ?? "workflow baseline" });
|
||||
const baseline = await runServiceAgentProbe({ id: "qscore", name: "RQ Score Agent", role: "RQ Score", kind: "score", description: "Baseline RQ Score", service: "qscore-service" }, { userId, goal: body.goal ?? "workflow baseline" });
|
||||
if (baseline.status === "ok" && baseline.detail) {
|
||||
const payload = baseline.detail as Record<string, unknown>;
|
||||
await db.insert(qscoreSnapshots).values({ userId, runId: run.id, snapshotType: "baseline", score: extractQScore(payload), payload });
|
||||
await db.insert(qscoreSnapshots).values({ userId, runId: run.id, snapshotType: "baseline", rqScore: extractRqScore(payload), payload });
|
||||
await db.update(workflowRuns).set({ qscoreBefore: payload, updatedAt: new Date() }).where(eq(workflowRuns.id, run.id));
|
||||
}
|
||||
} catch {
|
||||
@@ -248,11 +248,11 @@ async function runModulesUntilGate(input: {
|
||||
}
|
||||
}
|
||||
|
||||
function extractQScore(output: Record<string, unknown>): number | undefined {
|
||||
const direct = output.q_score;
|
||||
function extractRqScore(output: Record<string, unknown>): number | undefined {
|
||||
const direct = output.rq_score;
|
||||
if (typeof direct === "number") return Math.round(direct);
|
||||
const compute = output.compute as Record<string, unknown> | undefined;
|
||||
if (typeof compute?.q_score === "number") return Math.round(compute.q_score);
|
||||
if (typeof compute?.rq_score === "number") return Math.round(compute.rq_score);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
|
||||
18
src/services/onboarding-serialization.ts
Normal file
18
src/services/onboarding-serialization.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import {
|
||||
deriveOnboardingPayload,
|
||||
extractOnboardingData,
|
||||
} from "../events/onboarding-ledger.js";
|
||||
|
||||
/** Build the authenticated GET /users/onboarding response from persisted JSON. */
|
||||
export function serializeOnboardingState(userId: string, stored: unknown) {
|
||||
const data = extractOnboardingData(stored);
|
||||
const payload = deriveOnboardingPayload(data);
|
||||
const updatedAt = data.progress.updated_at ?? data.completed_at;
|
||||
return {
|
||||
id: userId,
|
||||
data,
|
||||
payload,
|
||||
revision: data.revision,
|
||||
updatedAt,
|
||||
};
|
||||
}
|
||||
@@ -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,12 +60,18 @@ export const interviewService = {
|
||||
serviceJson(config.interviewServiceUrl, `/api/v1/interviews/page-state?${new URLSearchParams({ user_id: userId })}`, {
|
||||
headers: userHeader(userId),
|
||||
}),
|
||||
configure: (payload: JsonObject) => serviceJson(config.interviewServiceUrl, "/api/v1/configure", { body: payload, timeoutMs: INTERACTIVE_SERVICE_TIMEOUT_MS }),
|
||||
preview: (payload: JsonObject) => serviceJson(config.interviewServiceUrl, "/api/v1/configure/preview", { body: payload, timeoutMs: INTERACTIVE_SERVICE_TIMEOUT_MS }),
|
||||
editQuestions: (payload: { session_id: string; questions: Array<JsonObject | string> }) =>
|
||||
serviceJson(config.interviewServiceUrl, "/api/v1/configure/questions", { body: payload, timeoutMs: INTERACTIVE_SERVICE_TIMEOUT_MS }),
|
||||
approve: (sessionId: string) =>
|
||||
serviceJson(config.interviewServiceUrl, "/api/v1/configure/approve", { body: { session_id: sessionId }, timeoutMs: INTERACTIVE_SERVICE_TIMEOUT_MS }),
|
||||
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) =>
|
||||
serviceJson(config.interviewServiceUrl, "/api/v1/configure/preview", { body: payload, headers: userHeader(userId), timeoutMs: INTERACTIVE_SERVICE_TIMEOUT_MS }),
|
||||
editQuestions: (payload: { session_id: string; questions: Array<JsonObject | string> }, userId?: string) =>
|
||||
serviceJson(config.interviewServiceUrl, "/api/v1/configure/questions", { body: payload, headers: userHeader(userId), timeoutMs: INTERACTIVE_SERVICE_TIMEOUT_MS }),
|
||||
approve: (sessionId: string, userId?: string) =>
|
||||
serviceJson(config.interviewServiceUrl, "/api/v1/configure/approve", { body: { session_id: sessionId }, headers: userHeader(userId), timeoutMs: INTERACTIVE_SERVICE_TIMEOUT_MS }),
|
||||
createAssignments: (payload: { organization_id: string; role: string; round: "warm_up" | "behavioral" | "technical"; assignee_emails: string[] }) =>
|
||||
serviceJson(config.interviewServiceUrl, "/api/v1/interviews/assignments", { body: payload }),
|
||||
listAssignments: (email: string, status = "pending", limit = 20) =>
|
||||
@@ -78,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),
|
||||
@@ -107,12 +117,30 @@ export const roleplayService = {
|
||||
serviceJson(config.roleplayServiceUrl, `/api/v1/roleplays/page-state?${new URLSearchParams({ user_id: userId })}`, {
|
||||
headers: userHeader(userId),
|
||||
}),
|
||||
configure: (payload: JsonObject) => serviceJson(config.roleplayServiceUrl, "/api/v1/roleplays/configure", { body: payload, timeoutMs: INTERACTIVE_SERVICE_TIMEOUT_MS }),
|
||||
preview: (payload: JsonObject) => serviceJson(config.roleplayServiceUrl, "/api/v1/roleplays/configure/preview", { body: payload, timeoutMs: INTERACTIVE_SERVICE_TIMEOUT_MS }),
|
||||
editQuestions: (payload: { session_id: string; questions: Array<JsonObject | string> }) =>
|
||||
serviceJson(config.roleplayServiceUrl, "/api/v1/roleplays/configure/questions", { body: payload, timeoutMs: INTERACTIVE_SERVICE_TIMEOUT_MS }),
|
||||
approve: (sessionId: string) =>
|
||||
serviceJson(config.roleplayServiceUrl, "/api/v1/roleplays/configure/approve", { body: { session_id: sessionId }, timeoutMs: INTERACTIVE_SERVICE_TIMEOUT_MS }),
|
||||
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) =>
|
||||
serviceJson(config.roleplayServiceUrl, "/api/v1/roleplays/configure/questions", { body: payload, headers: userHeader(userId), timeoutMs: INTERACTIVE_SERVICE_TIMEOUT_MS }),
|
||||
approve: (sessionId: string, userId?: string) =>
|
||||
serviceJson(config.roleplayServiceUrl, "/api/v1/roleplays/configure/approve", { body: { session_id: sessionId }, headers: userHeader(userId), timeoutMs: INTERACTIVE_SERVICE_TIMEOUT_MS }),
|
||||
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) })}`, {
|
||||
headers: userHeader(userId),
|
||||
}),
|
||||
deleteDraft: (sessionId: string, userId?: string) =>
|
||||
serviceJson(config.roleplayServiceUrl, `/api/v1/roleplays/drafts/${encodeURIComponent(sessionId)}`, {
|
||||
method: "DELETE",
|
||||
headers: userHeader(userId),
|
||||
}),
|
||||
createAssignments: (payload: { organization_id: string; name: string; scenario: string; assignee_emails: string[] }) =>
|
||||
serviceJson(config.roleplayServiceUrl, "/api/v1/roleplays/assignments", { body: payload }),
|
||||
listAssignments: (email: string, status = "pending", limit = 20) =>
|
||||
@@ -125,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),
|
||||
@@ -148,24 +188,68 @@ export const roleplayService = {
|
||||
},
|
||||
};
|
||||
|
||||
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 = {
|
||||
health: () => serviceJson(config.resumeServiceUrl, "/health"),
|
||||
state: (clerkId: string) => serviceJson(config.resumeServiceUrl, `/api/state/${encodeURIComponent(clerkId)}`),
|
||||
state: (clerkId: string) => serviceJson(config.resumeServiceUrl, `/api/state/${encodeURIComponent(clerkId)}`, { headers: userHeader(clerkId) }),
|
||||
templates: () => serviceJson(config.resumeServiceUrl, "/api/v1/templates"),
|
||||
task: (payload: { action?: string; params?: JsonObject; user_id: string; user_context?: JsonObject; session_start?: boolean }) =>
|
||||
serviceJson(config.resumeServiceUrl, "/a2a/tasks", { body: payload }),
|
||||
listResumes: (clerkId: string) => serviceJson(config.resumeServiceUrl, `/api/v1/resumes?${new URLSearchParams({ clerk_id: clerkId })}`),
|
||||
createResume: (payload: JsonObject) => serviceJson(config.resumeServiceUrl, "/api/v1/resumes", { body: payload }),
|
||||
getResume: (resumeId: string) => serviceJson(config.resumeServiceUrl, `/api/v1/resumes/${encodeURIComponent(resumeId)}`),
|
||||
updateResume: (resumeId: string, payload: JsonObject) => serviceJson(config.resumeServiceUrl, `/api/v1/resumes/${encodeURIComponent(resumeId)}`, { method: "PUT", body: payload }),
|
||||
analyzeResume: (resumeId: string, payload: JsonObject = {}) => serviceJson(config.resumeServiceUrl, `/api/v1/ai/analyze/${encodeURIComponent(resumeId)}`, { body: payload }),
|
||||
suggestions: (resumeId: string) => serviceJson(config.resumeServiceUrl, `/api/v1/ai/suggestions/${encodeURIComponent(resumeId)}`),
|
||||
copilot: (payload: JsonObject) => serviceJson(config.resumeServiceUrl, "/api/v1/ai/copilot", { body: payload }),
|
||||
optimizeSummary: (payload: JsonObject) => serviceJson(config.resumeServiceUrl, "/api/v1/ai/optimize-summary", { body: payload }),
|
||||
optimizeExperience: (payload: JsonObject) => serviceJson(config.resumeServiceUrl, "/api/v1/ai/optimize-experience", { body: payload }),
|
||||
suggestSkills: (payload: JsonObject) => serviceJson(config.resumeServiceUrl, "/api/v1/ai/suggest-skills", { body: payload }),
|
||||
generateSummary: (payload: JsonObject) => serviceJson(config.resumeServiceUrl, "/api/v1/ai/generate-summary", { body: payload }),
|
||||
listVersions: (resumeId: string) => serviceJson(config.resumeServiceUrl, `/api/v1/resumes/${encodeURIComponent(resumeId)}/versions`),
|
||||
createVersion: (resumeId: string, payload: JsonObject) => serviceJson(config.resumeServiceUrl, `/api/v1/resumes/${encodeURIComponent(resumeId)}/versions`, { body: payload }),
|
||||
preview: (resumeId: string) => serviceJson(config.resumeServiceUrl, `/api/v1/export/resumes/${encodeURIComponent(resumeId)}/preview`),
|
||||
listResumes: (clerkId: string) => serviceJson(config.resumeServiceUrl, `/api/v1/resumes?${new URLSearchParams({ clerk_id: clerkId })}`, { headers: userHeader(clerkId) }),
|
||||
createResume: (payload: JsonObject, userId?: string) => serviceJson(config.resumeServiceUrl, "/api/v1/resumes", { body: payload, headers: userHeader(userId ?? (typeof payload.user_id === "string" ? payload.user_id : undefined)) }),
|
||||
getResume: (resumeId: string, userId?: string) => serviceJson(config.resumeServiceUrl, `/api/v1/resumes/${encodeURIComponent(resumeId)}`, { headers: userHeader(userId) }),
|
||||
updateResume: (resumeId: string, payload: JsonObject, userId?: string) => serviceJson(config.resumeServiceUrl, `/api/v1/resumes/${encodeURIComponent(resumeId)}`, { method: "PUT", body: payload, headers: userHeader(userId) }),
|
||||
analyzeResume: (resumeId: string, payload: JsonObject = {}, userId?: string) => serviceJson(config.resumeServiceUrl, `/api/v1/ai/analyze/${encodeURIComponent(resumeId)}`, { body: payload, headers: userHeader(userId) }),
|
||||
suggestions: (resumeId: string, userId?: string) => serviceJson(config.resumeServiceUrl, `/api/v1/ai/suggestions/${encodeURIComponent(resumeId)}`, { headers: userHeader(userId) }),
|
||||
copilot: (payload: JsonObject, userId?: string) => serviceJson(config.resumeServiceUrl, "/api/v1/ai/copilot", { body: payload, headers: userHeader(userId) }),
|
||||
optimizeSummary: (payload: JsonObject, userId?: string) => serviceJson(config.resumeServiceUrl, "/api/v1/ai/optimize-summary", { body: payload, headers: userHeader(userId) }),
|
||||
optimizeExperience: (payload: JsonObject, userId?: string) => serviceJson(config.resumeServiceUrl, "/api/v1/ai/optimize-experience", { body: payload, headers: userHeader(userId) }),
|
||||
suggestSkills: (payload: JsonObject, userId?: string) => serviceJson(config.resumeServiceUrl, "/api/v1/ai/suggest-skills", { body: payload, headers: userHeader(userId) }),
|
||||
generateSummary: (payload: JsonObject, userId?: string) => serviceJson(config.resumeServiceUrl, "/api/v1/ai/generate-summary", { body: payload, headers: userHeader(userId) }),
|
||||
listVersions: (resumeId: string, userId?: string) => serviceJson(config.resumeServiceUrl, `/api/v1/resumes/${encodeURIComponent(resumeId)}/versions`, { headers: userHeader(userId) }),
|
||||
createVersion: (resumeId: string, payload: JsonObject, userId?: string) => serviceJson(config.resumeServiceUrl, `/api/v1/resumes/${encodeURIComponent(resumeId)}/versions`, { body: payload, headers: userHeader(userId) }),
|
||||
preview: (resumeId: string, userId?: string) => serviceJson(config.resumeServiceUrl, `/api/v1/export/resumes/${encodeURIComponent(resumeId)}/preview`, { headers: userHeader(userId) }),
|
||||
};
|
||||
|
||||
@@ -33,11 +33,10 @@ export function toQscoreUserId(userId: string): string {
|
||||
export type QscoreServiceResult = {
|
||||
org_id: string;
|
||||
user_id: string;
|
||||
q_score: number;
|
||||
rq_score: number;
|
||||
iq_score: number | null;
|
||||
eq_score: number | null;
|
||||
sq_score: number | null;
|
||||
overall_rqscore: number | null;
|
||||
profession: string;
|
||||
formula_version: string;
|
||||
formula_id: string;
|
||||
@@ -55,6 +54,27 @@ 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;
|
||||
present: boolean;
|
||||
score: number;
|
||||
raw: unknown;
|
||||
last_occurred_at: string | null;
|
||||
last_seen_at: string;
|
||||
formula_version: string;
|
||||
};
|
||||
|
||||
const QSCORE_PROXY_TIMEOUT_MS = Number(
|
||||
process.env.QSCORE_PROXY_TIMEOUT_MS ?? 3500,
|
||||
);
|
||||
@@ -67,9 +87,9 @@ function parseResult(body: unknown): QscoreServiceResult {
|
||||
if (!isRecord(body)) {
|
||||
throw new Error("qscore_service returned non-object response");
|
||||
}
|
||||
const q = body.q_score;
|
||||
if (typeof q !== "number" || !Number.isFinite(q)) {
|
||||
throw new Error("qscore_service response missing numeric q_score");
|
||||
const rqScore = body.rq_score;
|
||||
if (typeof rqScore !== "number" || !Number.isFinite(rqScore)) {
|
||||
throw new Error("qscore_service response missing numeric rq_score");
|
||||
}
|
||||
const numOrNull = (v: unknown): number | null =>
|
||||
typeof v === "number" && Number.isFinite(v) ? v : null;
|
||||
@@ -77,11 +97,10 @@ function parseResult(body: unknown): QscoreServiceResult {
|
||||
return {
|
||||
org_id: str(body.org_id),
|
||||
user_id: str(body.user_id),
|
||||
q_score: q,
|
||||
rq_score: rqScore,
|
||||
iq_score: numOrNull(body.iq_score),
|
||||
eq_score: numOrNull(body.eq_score),
|
||||
sq_score: numOrNull(body.sq_score),
|
||||
overall_rqscore: numOrNull(body.overall_rqscore),
|
||||
profession: str(body.profession),
|
||||
formula_version: str(body.formula_version),
|
||||
formula_id: str(body.formula_id),
|
||||
@@ -94,7 +113,7 @@ function parseResult(body: unknown): QscoreServiceResult {
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the latest computed Q-Score for a user from qscore_service.
|
||||
* Fetch the latest computed RQ Score for a user from qscore_service.
|
||||
*
|
||||
* Takes a RAW growqr userId (matching the local-DB readers) and applies
|
||||
* {@link toQscoreUserId} internally before calling the service, so all callers
|
||||
@@ -108,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}`;
|
||||
@@ -121,20 +150,105 @@ 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 { 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" };
|
||||
}
|
||||
}
|
||||
|
||||
/** Fetch QScore's real latest evidence ledger; an unavailable service is null. */
|
||||
export async function getQscoreLatestSignalsFromService(
|
||||
userId: string,
|
||||
orgId: string = DEFAULT_QSCORE_ORG_ID,
|
||||
timeoutMs: number = QSCORE_PROXY_TIMEOUT_MS,
|
||||
): Promise<QscoreLatestSignal[] | null> {
|
||||
const qscoreUserId = toQscoreUserId(userId);
|
||||
const url = `${config.qscoreServiceUrl.replace(/\/$/, "")}/v1/signals/${encodeURIComponent(qscoreUserId)}?org_id=${encodeURIComponent(orgId)}`;
|
||||
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 [];
|
||||
if (!res.ok) {
|
||||
log.warn({ status: res.status, userId, orgId }, "qscore_service latest signals returned non-success status");
|
||||
return null;
|
||||
}
|
||||
const body: unknown = await res.json();
|
||||
if (!isRecord(body) || !Array.isArray(body.signals)) throw new Error("qscore_service latest signals response is invalid");
|
||||
return body.signals.flatMap((value): QscoreLatestSignal[] => {
|
||||
if (!isRecord(value) || typeof value.signal_id !== "string" || typeof value.score !== "number") return [];
|
||||
return [{
|
||||
signal_id: value.signal_id,
|
||||
source: typeof value.source === "string" ? value.source : null,
|
||||
present: typeof value.present === "boolean" ? value.present : true,
|
||||
score: value.score,
|
||||
raw: value.raw,
|
||||
last_occurred_at: typeof value.last_occurred_at === "string" ? value.last_occurred_at : null,
|
||||
last_seen_at: typeof value.last_seen_at === "string" ? value.last_seen_at : "",
|
||||
formula_version: typeof value.formula_version === "string" ? value.formula_version : "",
|
||||
}];
|
||||
});
|
||||
} catch (err) {
|
||||
log.warn({ err, userId, orgId }, "qscore_service latest signals proxy call failed");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { config } from "../config.js";
|
||||
import { createHash } from "node:crypto";
|
||||
import { buildServiceSessionPath } from "./service-registry.js";
|
||||
import type { QscoreSignal } from "../events/envelope.js";
|
||||
import { toQscoreUserId } from "./qscore-proxy.js";
|
||||
import { serviceJson as productServiceJson } from "./product-service-clients.js";
|
||||
import { getQscoreFromService, toQscoreUserId } from "./qscore-proxy.js";
|
||||
|
||||
// Lightweight agent reference (works with both old AgentProfile and new SubAgentModule).
|
||||
export type ServiceAgentRef = {
|
||||
@@ -129,6 +127,7 @@ async function runInterviewService(ctx: ServiceAgentContext): Promise<ServiceAge
|
||||
}
|
||||
|
||||
async function runRoleplayService(ctx: ServiceAgentContext): Promise<ServiceAgentResult> {
|
||||
const currentQscore = await getQscoreFromService(ctx.userId, ctx.orgId ?? "growqr");
|
||||
const payload = {
|
||||
user_id: ctx.userId,
|
||||
org_id: ctx.orgId ?? "growqr",
|
||||
@@ -141,16 +140,7 @@ async function runRoleplayService(ctx: ServiceAgentContext): Promise<ServiceAgen
|
||||
difficulty: "medium",
|
||||
source: "growqr-workflow",
|
||||
},
|
||||
qscore: {
|
||||
q_score: 78,
|
||||
profession: "student",
|
||||
formula_version: "workflow-demo",
|
||||
quotients: {
|
||||
CQm: { score: 72, active: true },
|
||||
XQ: { score: 80, active: true },
|
||||
VQ: { score: 76, active: true },
|
||||
},
|
||||
},
|
||||
...(currentQscore ? { qscore: currentQscore } : {}),
|
||||
};
|
||||
const detail = await serviceJson<Record<string, unknown>>(
|
||||
config.roleplayServiceUrl,
|
||||
@@ -174,57 +164,9 @@ async function runRoleplayService(ctx: ServiceAgentContext): Promise<ServiceAgen
|
||||
|
||||
async function runQScoreService(ctx: ServiceAgentContext): Promise<ServiceAgentResult> {
|
||||
const orgId = ctx.orgId ?? "growqr";
|
||||
const qscoreUserId = stableUuid(ctx.userId);
|
||||
const signals = [
|
||||
{
|
||||
signal_id: "resume.uploaded",
|
||||
present: true,
|
||||
score: 82,
|
||||
raw: { source: "resume-agent", workflow_goal: ctx.goal },
|
||||
},
|
||||
{
|
||||
signal_id: "resume.ats_compatibility",
|
||||
present: true,
|
||||
score: 76,
|
||||
raw: { source: "resume-agent", workflow_goal: ctx.goal },
|
||||
},
|
||||
{
|
||||
signal_id: "engagement.features_used",
|
||||
present: true,
|
||||
score: 88,
|
||||
raw: { source: "grow-agent", workflow_goal: ctx.goal },
|
||||
},
|
||||
{
|
||||
signal_id: "goals.goal_clarity",
|
||||
present: true,
|
||||
score: 90,
|
||||
raw: { source: "grow-agent", workflow_goal: ctx.goal },
|
||||
},
|
||||
];
|
||||
const qscoreUserId = toQscoreUserId(ctx.userId);
|
||||
|
||||
// Try to ingest signals (non-critical — may fail if QScore worker is down)
|
||||
let ingest: Record<string, unknown> | undefined;
|
||||
try {
|
||||
ingest = await serviceJson<Record<string, unknown>>(
|
||||
config.qscoreServiceUrl,
|
||||
"/v1/signals/ingest",
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
org_id: orgId,
|
||||
user_id: qscoreUserId,
|
||||
profession: "student",
|
||||
source: "growqr-workflow",
|
||||
signals,
|
||||
}),
|
||||
},
|
||||
);
|
||||
} catch (err) {
|
||||
// Signal ingestion is optional — compute may still work with cached signals
|
||||
ingest = { status: "skipped", reason: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
|
||||
// Try to compute Q-Score
|
||||
// Try to compute RQ Score
|
||||
let compute: Record<string, unknown> | undefined;
|
||||
try {
|
||||
compute = await serviceJson<Record<string, unknown>>(
|
||||
@@ -241,10 +183,8 @@ async function runQScoreService(ctx: ServiceAgentContext): Promise<ServiceAgentR
|
||||
} catch (err) {
|
||||
return {
|
||||
status: "unavailable",
|
||||
summary: `Q Score compute failed; no score was generated: ${err instanceof Error ? err.message : String(err)}`,
|
||||
summary: `RQ Score compute failed; no score was generated: ${err instanceof Error ? err.message : String(err)}`,
|
||||
detail: {
|
||||
ingest,
|
||||
signal_scores: signals.map(s => ({ id: s.signal_id, score: s.score })),
|
||||
compute_error: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
};
|
||||
@@ -252,50 +192,11 @@ async function runQScoreService(ctx: ServiceAgentContext): Promise<ServiceAgentR
|
||||
|
||||
return {
|
||||
status: "ok",
|
||||
summary: `Q Score computed Q Score ${compute.q_score ?? "(unknown)"} for ${ctx.goal}.`,
|
||||
detail: { ingest, compute, qscore_user_id: qscoreUserId },
|
||||
summary: `RQ Score computed ${compute.rq_score ?? "(unknown)"} for ${ctx.goal}.`,
|
||||
detail: { compute, qscore_user_id: qscoreUserId },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Forward extracted Grow signals to qscore_service for scoring.
|
||||
*
|
||||
* qscore_service OWNS all scoring; the backend only extracts signals and feeds
|
||||
* them here. The userId is hashed to the qscore UUID via toQscoreUserId (from
|
||||
* qscore-proxy.ts, the single source of truth shared with readers). The ingest
|
||||
* endpoint persists signals and marks the user dirty for async score
|
||||
* computation — it does NOT return a score, so the caller treats the result as
|
||||
* fire-and-forget.
|
||||
*/
|
||||
export async function forwardSignalsToQscoreService(params: {
|
||||
orgId: string;
|
||||
userId: string;
|
||||
profession: string;
|
||||
source: string;
|
||||
signals: QscoreSignal[];
|
||||
}): Promise<void> {
|
||||
const qscoreUserId = toQscoreUserId(params.userId);
|
||||
await productServiceJson(
|
||||
config.qscoreServiceUrl,
|
||||
"/v1/signals/ingest",
|
||||
{
|
||||
method: "POST",
|
||||
body: {
|
||||
org_id: params.orgId,
|
||||
user_id: qscoreUserId,
|
||||
profession: params.profession,
|
||||
source: params.source,
|
||||
signals: params.signals.map((s) => ({
|
||||
signal_id: s.signalId,
|
||||
present: s.present,
|
||||
score: s.score,
|
||||
raw: s.raw ?? null,
|
||||
})),
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// ── Resume Building (resume-builder service from growqr-app) ──
|
||||
|
||||
async function runResumeAnalyze(ctx: ServiceAgentContext): Promise<ServiceAgentResult> {
|
||||
@@ -412,7 +313,7 @@ export async function runServiceAgentProbe(
|
||||
case "qscore-service":
|
||||
return ctx
|
||||
? await runQScoreService(ctx)
|
||||
: healthCheck(config.qscoreServiceUrl, "Q Score Agent / qscore-service");
|
||||
: healthCheck(config.qscoreServiceUrl, "RQ Score Agent / qscore-service");
|
||||
case "resume-service":
|
||||
return ctx
|
||||
? await runResumeTailor(ctx)
|
||||
|
||||
@@ -222,7 +222,7 @@ const serviceRegistry: ServiceRecord[] = [
|
||||
abandonmentEvents: ["service.abandoned", "interview.session.abandoned"],
|
||||
qscoreSignalIds: ["interview.communication_score", "interview.confidence_score", "readiness.practice"],
|
||||
analyticsDimensions: ["interview_type", "target_role", "difficulty", "duration_minutes", "overall_score"],
|
||||
requiredCorrelationFields: ["taskId"],
|
||||
requiredCorrelationFields: ["task_id"],
|
||||
handoffParams: ["curatorTaskId", "missionInstanceId", "missionId", "stageId"],
|
||||
actionKind: "active_user_action",
|
||||
},
|
||||
@@ -284,7 +284,7 @@ const serviceRegistry: ServiceRecord[] = [
|
||||
mode: "video",
|
||||
},
|
||||
toolName: "prepare_roleplay_preview",
|
||||
completionEvents: ["roleplay.scenario.configured", "roleplay.scenario.completed", "roleplay.feedback.generated"],
|
||||
completionEvents: ["roleplay.scenario.completed", "roleplay.feedback.generated"],
|
||||
qscoreSignals: ["communication.roleplay", "networking.conversation", "readiness.practice"],
|
||||
usage: "Include role, brief, mission state, and curatorTaskId when building stateful handoffs.",
|
||||
},
|
||||
@@ -301,7 +301,7 @@ const serviceRegistry: ServiceRecord[] = [
|
||||
abandonmentEvents: ["service.abandoned", "roleplay.scenario.abandoned"],
|
||||
qscoreSignalIds: ["roleplay.negotiation_confidence", "communication.roleplay", "readiness.practice"],
|
||||
analyticsDimensions: ["roleplay_type", "target_role", "difficulty", "duration_minutes", "overall_score"],
|
||||
requiredCorrelationFields: ["taskId"],
|
||||
requiredCorrelationFields: ["task_id"],
|
||||
handoffParams: ["curatorTaskId", "missionInstanceId", "missionId", "stageId"],
|
||||
actionKind: "active_user_action",
|
||||
},
|
||||
@@ -372,7 +372,7 @@ const serviceRegistry: ServiceRecord[] = [
|
||||
tab: "resumes",
|
||||
},
|
||||
toolName: "prepare_resume_upload",
|
||||
completionEvents: ["resume.analysis.completed", "resume.parsed", "resume.updated", "resume.exported"],
|
||||
completionEvents: ["resume.uploaded", "resume.parsed", "resume.analysis.completed", "resume.updated", "resume.exported"],
|
||||
qscoreSignals: ["proof.resume", "readiness.ats", "profile.skills"],
|
||||
usage: "Include mission state and optional section when linking into resume work.",
|
||||
},
|
||||
@@ -385,11 +385,11 @@ const serviceRegistry: ServiceRecord[] = [
|
||||
openEvents: ["task.opened"],
|
||||
startedEvents: ["service.started", "resume.uploaded"],
|
||||
configuredEvents: ["service.configured", "resume.parsed"],
|
||||
completionEvents: ["service.completed", "resume.analysis.completed", "resume.updated", "resume.exported"],
|
||||
completionEvents: ["service.completed", "resume.uploaded", "resume.parsed", "resume.analysis.completed", "resume.updated", "resume.exported"],
|
||||
abandonmentEvents: ["service.abandoned"],
|
||||
qscoreSignalIds: ["resume.ats_score", "resume.profile_completeness", "proof.resume"],
|
||||
analyticsDimensions: ["resume_id", "version_id", "ats_score", "role_fit_score", "updated_sections"],
|
||||
requiredCorrelationFields: ["taskId"],
|
||||
requiredCorrelationFields: ["task_id"],
|
||||
handoffParams: ["curatorTaskId", "missionInstanceId", "missionId", "stageId"],
|
||||
actionKind: "active_user_action",
|
||||
},
|
||||
@@ -470,7 +470,7 @@ const serviceRegistry: ServiceRecord[] = [
|
||||
abandonmentEvents: ["service.abandoned"],
|
||||
qscoreSignalIds: ["proof.cover_letter", "readiness.application"],
|
||||
analyticsDimensions: ["cover_letter_id", "role_fit_score", "export_format"],
|
||||
requiredCorrelationFields: ["taskId"],
|
||||
requiredCorrelationFields: ["task_id"],
|
||||
handoffParams: ["curatorTaskId", "missionInstanceId", "missionId", "stageId"],
|
||||
actionKind: "active_user_action",
|
||||
},
|
||||
@@ -524,7 +524,7 @@ const serviceRegistry: ServiceRecord[] = [
|
||||
start: "Start course",
|
||||
},
|
||||
toolName: "prepare_course_handoff",
|
||||
completionEvents: ["course.generated", "course.started", "course.progress_recorded", "course.lesson.completed", "course.completed", "course.weakness.applied"],
|
||||
completionEvents: ["course.generated", "course.started", "course.progress.recorded", "course.completed"],
|
||||
qscoreSignals: ["courses.started", "courses.completed", "courses.completion_rate"],
|
||||
usage: "Use for skill-gap tasks that should become learning work.",
|
||||
},
|
||||
@@ -534,11 +534,11 @@ const serviceRegistry: ServiceRecord[] = [
|
||||
openEvents: ["task.opened"],
|
||||
startedEvents: ["service.started", "course.started"],
|
||||
configuredEvents: ["course.generated"],
|
||||
completionEvents: ["service.completed", "course.progress_recorded", "course.lesson.completed", "course.completed", "course.weakness.applied"],
|
||||
completionEvents: ["service.completed", "course.generated", "course.started", "course.progress.recorded", "course.completed"],
|
||||
abandonmentEvents: ["service.abandoned"],
|
||||
qscoreSignalIds: ["courses.started", "courses.completed", "courses.completion_rate", "courses.difficulty", "courses.pathway_relevance"],
|
||||
analyticsDimensions: ["course_id", "lesson_id", "progress_percentage", "weakness_tags"],
|
||||
requiredCorrelationFields: ["taskId"],
|
||||
requiredCorrelationFields: ["task_id"],
|
||||
handoffParams: ["curatorTaskId", "missionInstanceId", "missionId", "stageId"],
|
||||
actionKind: "active_user_action",
|
||||
},
|
||||
@@ -583,7 +583,7 @@ const serviceRegistry: ServiceRecord[] = [
|
||||
review: "Review assessment",
|
||||
},
|
||||
toolName: "prepare_assessment_handoff",
|
||||
completionEvents: ["assessment.started", "assessment.completed"],
|
||||
completionEvents: ["assessment.completed"],
|
||||
qscoreSignals: ["skills.assessment", "readiness.measurement"],
|
||||
usage: "Use when a task needs a scoreable assessment rather than practice.",
|
||||
},
|
||||
@@ -597,7 +597,7 @@ const serviceRegistry: ServiceRecord[] = [
|
||||
abandonmentEvents: ["service.abandoned"],
|
||||
qscoreSignalIds: ["skills.assessment", "readiness.measurement"],
|
||||
analyticsDimensions: ["assessment_id", "skill_area", "score", "weakness_tags"],
|
||||
requiredCorrelationFields: ["taskId"],
|
||||
requiredCorrelationFields: ["task_id"],
|
||||
handoffParams: ["curatorTaskId", "missionInstanceId", "missionId", "stageId"],
|
||||
actionKind: "measurement_review",
|
||||
},
|
||||
@@ -646,14 +646,12 @@ const serviceRegistry: ServiceRecord[] = [
|
||||
},
|
||||
toolName: "prepare_matchmaking_handoff",
|
||||
completionEvents: [
|
||||
"matchmaking.matches.generated",
|
||||
"matchmaking.feed.viewed",
|
||||
"matchmaking.match.viewed",
|
||||
"matchmaking.application.started",
|
||||
"matchmaking.match.saved",
|
||||
"matchmaking.match.dismissed",
|
||||
"matchmaking.matches.reviewed",
|
||||
"matchmaking.match.applied",
|
||||
"matchmaking.application.started",
|
||||
"matchmaking.application.completed",
|
||||
],
|
||||
qscoreSignals: ["matching.jobs_viewed", "matching.applications_submitted", "matching.match_rate"],
|
||||
@@ -670,10 +668,10 @@ const serviceRegistry: ServiceRecord[] = [
|
||||
configuredEvents: [],
|
||||
completionEvents: [
|
||||
"service.completed",
|
||||
"matchmaking.matches.generated",
|
||||
"matchmaking.feed.viewed",
|
||||
"matchmaking.match.viewed",
|
||||
"matchmaking.application.started",
|
||||
"matchmaking.match.saved",
|
||||
"matchmaking.match.dismissed",
|
||||
"matchmaking.matches.reviewed",
|
||||
"matchmaking.match.applied",
|
||||
"matchmaking.application.completed",
|
||||
@@ -681,14 +679,14 @@ const serviceRegistry: ServiceRecord[] = [
|
||||
abandonmentEvents: ["service.abandoned"],
|
||||
qscoreSignalIds: ["matching.jobs_viewed", "matching.applications_submitted", "matching.application_quality", "matching.match_rate"],
|
||||
analyticsDimensions: ["match_count", "role_ids", "role_fit", "saved_count", "dismissed_reason"],
|
||||
requiredCorrelationFields: ["taskId"],
|
||||
requiredCorrelationFields: ["task_id"],
|
||||
handoffParams: ["curatorTaskId", "missionInstanceId", "missionId", "stageId"],
|
||||
actionKind: "active_user_action",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "qscore-service",
|
||||
label: "QScore",
|
||||
label: "RQ Score",
|
||||
description: "Analyze readiness signals and expose score projections.",
|
||||
category: "measurement",
|
||||
enabled: Boolean(config.qscoreServiceUrl),
|
||||
@@ -701,11 +699,11 @@ const serviceRegistry: ServiceRecord[] = [
|
||||
healthPath: "/health",
|
||||
endpoints: {
|
||||
health: endpoint("GET", "/health", "Readiness probe.", "Check service availability."),
|
||||
currentGateway: endpoint("GET", "/services/qscore/current", "Backend-projected current score and latest signals.", "Dashboard QScore panel."),
|
||||
currentGateway: endpoint("GET", "/services/qscore/current", "Backend-projected current score and latest signals.", "Dashboard RQ Score panel."),
|
||||
ingest: endpoint("POST", "/api/v1/signals", "Ingests score signals when available.", "Service-to-service signal updates."),
|
||||
compute: endpoint("POST", "/api/v1/score/compute", "Computes or refreshes score when available.", "Score recalculation."),
|
||||
},
|
||||
usage: "Use backend gateway /services/qscore/current for dashboard-safe reads; direct service APIs vary by QScore deployment.",
|
||||
usage: "Use backend gateway /services/qscore/current for dashboard-safe reads; direct service APIs vary by RQ Score deployment.",
|
||||
},
|
||||
frontend: {
|
||||
baseUrl: frontendBaseUrl,
|
||||
@@ -713,29 +711,29 @@ const serviceRegistry: ServiceRecord[] = [
|
||||
dashboard: {
|
||||
path: "/agents/qscore",
|
||||
queryParams: ["source", "missionInstanceId", "missionId", "stageId", "curatorTaskId"],
|
||||
usage: "Dedicated Q Score service page with live readiness signals.",
|
||||
usage: "Dedicated RQ Score service page with live readiness signals.",
|
||||
},
|
||||
},
|
||||
usage: "Open the dedicated Q Score service page for score review.",
|
||||
usage: "Open the dedicated RQ Score service page for score review.",
|
||||
},
|
||||
curator: {
|
||||
defaultPage: "dashboard",
|
||||
defaultActionLabel: "Review QScore",
|
||||
defaultActionLabel: "Review RQ Score",
|
||||
actionLabels: {
|
||||
review: "Review QScore",
|
||||
review: "Review RQ Score",
|
||||
},
|
||||
toolName: "prepare_qscore_review",
|
||||
completionEvents: ["qscore.baseline.created", "qscore.review.opened", "qscore.weak_driver.reviewed", "qscore.signal.projected", "qscore.updated"],
|
||||
completionEvents: ["qscore.review.opened", "qscore.weak_driver.reviewed", "qscore.signal.projected", "qscore.updated"],
|
||||
qscoreSignals: ["qscore.updated", "qscore.signal.projected"],
|
||||
usage: "Use for measurement and projected readiness review tasks.",
|
||||
},
|
||||
usageDocs: ["Call buildServiceLink('qscore-service', 'dashboard', state) for QScore handoffs."],
|
||||
usageDocs: ["Call buildServiceLink('qscore-service', 'dashboard', state) for RQ Score handoffs."],
|
||||
eventContract: {
|
||||
canonicalServiceId: "qscore",
|
||||
openEvents: ["qscore.review.opened"],
|
||||
startedEvents: ["service.started"],
|
||||
configuredEvents: ["qscore.baseline.created"],
|
||||
completionEvents: ["qscore.signal.projected", "qscore.updated", "qscore.weak_driver.reviewed"],
|
||||
completionEvents: ["qscore.review.opened", "qscore.weak_driver.reviewed", "qscore.signal.projected", "qscore.updated"],
|
||||
abandonmentEvents: [],
|
||||
qscoreSignalIds: ["qscore.updated", "qscore.signal.projected"],
|
||||
analyticsDimensions: ["score_before", "score_after", "signal_id", "delta", "trend_label"],
|
||||
@@ -785,7 +783,7 @@ const serviceRegistry: ServiceRecord[] = [
|
||||
review: "Review profile",
|
||||
},
|
||||
toolName: "prepare_social_branding_handoff",
|
||||
completionEvents: ["brand.post.drafted", "brand.post.edited", "brand.post.exported", "brand.profile.updated", "brand.content.published"],
|
||||
completionEvents: ["brand.account.connected", "brand.post.drafted", "brand.profile.updated", "brand.post.exported", "brand.content.published"],
|
||||
qscoreSignals: ["profile.linkedin", "proof.visibility", "networking.brand"],
|
||||
usage: "Use for profile visibility, LinkedIn cleanup, and social proof tasks.",
|
||||
},
|
||||
@@ -795,11 +793,11 @@ const serviceRegistry: ServiceRecord[] = [
|
||||
openEvents: ["task.opened"],
|
||||
startedEvents: ["service.started"],
|
||||
configuredEvents: ["brand.post.drafted"],
|
||||
completionEvents: ["service.completed", "brand.post.edited", "brand.post.exported", "brand.profile.updated", "brand.content.published"],
|
||||
completionEvents: ["service.completed", "brand.account.connected", "brand.post.drafted", "brand.profile.updated", "brand.post.exported", "brand.content.published"],
|
||||
abandonmentEvents: ["service.abandoned"],
|
||||
qscoreSignalIds: ["brand.visibility_signal", "profile.linkedin", "proof.visibility"],
|
||||
analyticsDimensions: ["content_type", "channel", "draft_id", "topic", "profile_field_updated"],
|
||||
requiredCorrelationFields: ["taskId"],
|
||||
requiredCorrelationFields: ["task_id"],
|
||||
handoffParams: ["curatorTaskId", "missionInstanceId", "missionId", "stageId"],
|
||||
actionKind: "active_user_action",
|
||||
},
|
||||
@@ -947,6 +945,9 @@ function unavailableServiceRoute(input: CuratorRouteInput) {
|
||||
return appendQuery("/agents/service-unavailable", {
|
||||
...curatorBaseParams(input),
|
||||
serviceId: input.serviceId,
|
||||
taskTitle: input.taskTitle,
|
||||
taskSubtitle: input.taskSubtitle,
|
||||
taskType: input.taskType,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -962,11 +963,15 @@ export function buildCuratorServiceRoute(input: CuratorRouteInput) {
|
||||
if (input.taskType) state.taskType = input.taskType;
|
||||
|
||||
if (service.id === "interview-service") {
|
||||
state.role = input.targetRole?.trim() || "Product Manager";
|
||||
state.type = "behavioral";
|
||||
const taskText = `${input.taskTitle ?? ""} ${input.taskSubtitle ?? ""}`.toLowerCase();
|
||||
const isIntroductionTask = /(?:60|90)[ -]?second|\bintroduction\b|\bpitch\b|\bpresentation\b/.test(taskText);
|
||||
// Curator already resolves this from onboarding/user context. Never invent a
|
||||
// role here: the task title/subtitle carry the exercise intent separately.
|
||||
state.role = isIntroductionTask ? undefined : input.targetRole?.trim();
|
||||
state.type = isIntroductionTask ? "warm_up" : "behavioral";
|
||||
state.persona = input.personaId ?? "payal";
|
||||
state.duration = input.durationMinutes ?? 5;
|
||||
state.difficulty = input.difficulty ?? "medium";
|
||||
state.difficulty = input.difficulty ?? (isIntroductionTask ? "easy" : "medium");
|
||||
state.media = input.requestedMode ?? "video";
|
||||
}
|
||||
|
||||
@@ -996,7 +1001,7 @@ export function buildCuratorServiceRoute(input: CuratorRouteInput) {
|
||||
|
||||
const unavailableServiceLabels: Record<string, string> = {
|
||||
"social-branding-service": "Social Branding",
|
||||
"qscore-service": "Q Score",
|
||||
"qscore-service": "RQ Score",
|
||||
"assessment-service": "Assessment",
|
||||
"expert-service": "Expert",
|
||||
"events-service": "Events",
|
||||
|
||||
@@ -63,18 +63,32 @@ async function backendMirrorProfile(userId: string): Promise<UserProfileContext>
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchUserServiceJson(path: string, headers: Headers): Promise<Record<string, unknown> | null> {
|
||||
async function fetchUserServiceJson(path: string, headers: Headers, strict = false): Promise<Record<string, unknown> | null> {
|
||||
const target = new URL(path, config.userServiceUrl.replace(/\/$/, ""));
|
||||
const res = await fetch(target, { method: "GET", headers });
|
||||
if (!res.ok) return null;
|
||||
if (!res.ok) {
|
||||
if (strict) {
|
||||
const text = await res.text().catch(() => "");
|
||||
throw new Error(`user-service A2A state fetch failed: ${res.status} ${text}`);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
const json = await res.json().catch(() => null);
|
||||
return isRecord(json) ? json : null;
|
||||
if (!isRecord(json)) {
|
||||
if (strict) throw new Error("user-service A2A state fetch failed: invalid JSON");
|
||||
return null;
|
||||
}
|
||||
return json;
|
||||
}
|
||||
|
||||
async function a2aUserState(userId: string): Promise<Record<string, unknown> | null> {
|
||||
export async function fetchA2AUserState(userId: string, options: { strict?: boolean } = {}): Promise<Record<string, unknown> | null> {
|
||||
const headers = new Headers();
|
||||
headers.set("authorization", `Bearer ${config.a2aAllowedKey}`);
|
||||
return fetchUserServiceJson(`/api/state/${encodeURIComponent(userId)}`, headers);
|
||||
return fetchUserServiceJson(`/api/state/${encodeURIComponent(userId)}`, headers, options.strict === true);
|
||||
}
|
||||
|
||||
export async function fetchA2AUserStateStrict(userId: string): Promise<Record<string, unknown>> {
|
||||
return (await fetchA2AUserState(userId, { strict: true }))!;
|
||||
}
|
||||
|
||||
async function clerkUserProfile(req: Request): Promise<Record<string, unknown> | null> {
|
||||
@@ -93,7 +107,7 @@ export async function getRequestUserProfile(req: Request, userId: string): Promi
|
||||
if (profile) return mergeProfile(base, profile, userId);
|
||||
}
|
||||
|
||||
const state = await a2aUserState(userId);
|
||||
const state = await fetchA2AUserState(userId);
|
||||
return mergeProfile(base, state, userId);
|
||||
}
|
||||
|
||||
|
||||
65
src/services/user-profile.ts
Normal file
65
src/services/user-profile.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { config } from "../config.js";
|
||||
import { fetchA2AUserStateStrict } from "./user-context.js";
|
||||
|
||||
/**
|
||||
* Build the absolute user-service URL for a given sub-path.
|
||||
* Pure — no IO. Exported so callers (and tests) can resolve targets without
|
||||
* duplicating the base-URL/trim logic.
|
||||
*/
|
||||
export function userServiceTarget(path: string, search = ""): URL {
|
||||
return new URL(`/api/v1/users${path}${search}`, config.userServiceUrl.replace(/\/$/, ""));
|
||||
}
|
||||
|
||||
/** Fetch the canonical profile with an explicit GET /me using the inbound Clerk JWT. */
|
||||
export async function fetchUserProfile(req: Request): Promise<Record<string, unknown>> {
|
||||
const target = userServiceTarget("/me", new URL(req.url).search);
|
||||
const headers = new Headers(req.headers);
|
||||
headers.delete("host");
|
||||
headers.delete("cookie");
|
||||
headers.delete("content-type");
|
||||
headers.delete("content-length");
|
||||
headers.delete("transfer-encoding");
|
||||
headers.delete("x-growqr-user");
|
||||
const res = await fetch(target, { method: "GET", headers });
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => "");
|
||||
throw new Error(`user-service /me fetch failed: ${res.status} ${text}`);
|
||||
}
|
||||
return res.json() as Promise<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
/** Read onboarding through user-service A2A for backend service-auth calls. */
|
||||
export async function fetchOnboardingReadProfile(req: Request, userId: string): Promise<Record<string, unknown>> {
|
||||
const token = (req.headers.get("authorization") ?? "").replace(/^Bearer\s+/i, "").trim();
|
||||
const trusted = token === config.serviceToken || (config.nodeEnv !== "production" && token === config.a2aAllowedKey);
|
||||
if (!trusted) return fetchUserProfile(req);
|
||||
if (req.headers.get("x-growqr-user") !== userId) {
|
||||
throw new Error("trusted user-service read requires matching x-growqr-user");
|
||||
}
|
||||
return fetchA2AUserStateStrict(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a synthetic PATCH /me request carrying only `{ preferences }` so the
|
||||
* backend can persist the merged onboarding blob without disturbing other
|
||||
* profile fields. Strips host/cookie like the other proxy helpers.
|
||||
*
|
||||
* The body differs from the inbound request, so hop-by-hop and length headers
|
||||
* copied from the inbound request MUST be dropped: a stale `content-length`
|
||||
* (or `transfer-encoding`) describes the ORIGINAL body, not the synthetic one,
|
||||
* and undici closes the socket mid-write ("other side closed") when the
|
||||
* declared length disagrees with the bytes sent. The Request constructor does
|
||||
* NOT recompute content-length when an explicit body is provided alongside
|
||||
* forwarded headers, so these deletes are load-bearing.
|
||||
*/
|
||||
export function patchPreferencesRequest(req: Request, preferences: Record<string, unknown>): Request {
|
||||
const target = userServiceTarget("/me");
|
||||
const headers = new Headers(req.headers);
|
||||
headers.delete("host");
|
||||
headers.delete("cookie");
|
||||
headers.delete("content-length");
|
||||
headers.delete("transfer-encoding");
|
||||
headers.set("content-type", "application/json");
|
||||
const body = JSON.stringify({ preferences });
|
||||
return new Request(target, { method: "PATCH", headers, body });
|
||||
}
|
||||
68
src/services/ws-tickets.ts
Normal file
68
src/services/ws-tickets.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
|
||||
|
||||
export type WsTicketAudience = "interview" | "roleplay";
|
||||
|
||||
type TicketClaims = {
|
||||
aud: WsTicketAudience;
|
||||
sub: string;
|
||||
sid: string;
|
||||
iat: number;
|
||||
exp: number;
|
||||
nonce: string;
|
||||
};
|
||||
|
||||
const MAX_TTL_SECONDS = 60;
|
||||
|
||||
function b64url(value: string | Uint8Array): string {
|
||||
return Buffer.from(value).toString("base64url");
|
||||
}
|
||||
|
||||
function decodePart(value: string): string {
|
||||
return Buffer.from(value, "base64url").toString("utf8");
|
||||
}
|
||||
|
||||
export function signWsTicket(input: {
|
||||
secret: string;
|
||||
audience: WsTicketAudience;
|
||||
subject: string;
|
||||
sessionId: string;
|
||||
nowSeconds?: number;
|
||||
ttlSeconds?: number;
|
||||
}): { ticket: string; expiresAt: string; claims: TicketClaims } {
|
||||
if (!input.secret) throw new Error("SESSION_TICKET_SECRET is not configured");
|
||||
const now = input.nowSeconds ?? Math.floor(Date.now() / 1000);
|
||||
const ttl = Math.min(Math.max(Math.floor(input.ttlSeconds ?? MAX_TTL_SECONDS), 1), MAX_TTL_SECONDS);
|
||||
const claims: TicketClaims = {
|
||||
aud: input.audience,
|
||||
sub: input.subject,
|
||||
sid: input.sessionId,
|
||||
iat: now,
|
||||
exp: now + ttl,
|
||||
nonce: randomBytes(16).toString("hex"),
|
||||
};
|
||||
const encoded = b64url(JSON.stringify(claims));
|
||||
const signature = b64url(createHmac("sha256", input.secret).update(encoded).digest());
|
||||
return { ticket: `${encoded}.${signature}`, expiresAt: new Date(claims.exp * 1000).toISOString(), claims };
|
||||
}
|
||||
|
||||
export function verifyWsTicket(input: {
|
||||
secret: string;
|
||||
ticket: string;
|
||||
audience: WsTicketAudience;
|
||||
sessionId: string;
|
||||
subject?: string;
|
||||
nowSeconds?: number;
|
||||
}): TicketClaims {
|
||||
if (!input.secret) throw new Error("SESSION_TICKET_SECRET is not configured");
|
||||
const parts = input.ticket.split(".");
|
||||
if (parts.length !== 2 || !parts[0] || !parts[1]) throw new Error("invalid ticket format");
|
||||
const expected = createHmac("sha256", input.secret).update(parts[0]).digest();
|
||||
const provided = Buffer.from(parts[1], "base64url");
|
||||
if (provided.length !== expected.length || !timingSafeEqual(provided, expected)) throw new Error("invalid ticket signature");
|
||||
let claims: TicketClaims;
|
||||
try { claims = JSON.parse(decodePart(parts[0])) as TicketClaims; } catch { throw new Error("invalid ticket claims"); }
|
||||
const now = input.nowSeconds ?? Math.floor(Date.now() / 1000);
|
||||
if (claims.aud !== input.audience || claims.sid !== input.sessionId || (input.subject && claims.sub !== input.subject)) throw new Error("ticket scope mismatch");
|
||||
if (!claims.sub || !claims.nonce || !Number.isInteger(claims.iat) || !Number.isInteger(claims.exp) || claims.exp <= now || claims.exp - claims.iat > MAX_TTL_SECONDS) throw new Error("ticket expired or invalid");
|
||||
return claims;
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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 } 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,17 +60,18 @@ export function v1AnalyticsRoutes() {
|
||||
|
||||
app.get("/insight-snapshot", async (c) => {
|
||||
const userId = c.get("userId");
|
||||
const qscoreResult = await 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 breakdownSignals = Array.isArray(breakdown.signals) ? breakdown.signals : [];
|
||||
const latestSignals = breakdownSignals.map((item) => {
|
||||
const s = isRecord(item) ? item : {};
|
||||
return {
|
||||
signalId: typeof s.signalId === "string" ? s.signalId : typeof s.signal_id === "string" ? s.signal_id : "",
|
||||
score: typeof s.score === "number" ? s.score : 0,
|
||||
source: typeof s.source === "string" ? s.source : null,
|
||||
};
|
||||
});
|
||||
const latestSignals = (qscoreEvidence ?? []).map((signal) => ({
|
||||
signalId: signal.signal_id,
|
||||
score: signal.score,
|
||||
source: signal.source,
|
||||
}));
|
||||
const recentEvents = await db
|
||||
.select()
|
||||
.from(growEvents)
|
||||
@@ -76,14 +92,24 @@ export function v1AnalyticsRoutes() {
|
||||
const bucket = sourceBucket(event.source);
|
||||
serviceCounts.set(bucket, (serviceCounts.get(bucket) ?? 0) + 1);
|
||||
}
|
||||
const score = qscoreResult?.q_score ?? null;
|
||||
const score = qscoreResult?.rq_score ?? null;
|
||||
const strongestSignal = [...latestSignals].sort((a, b) => b.score - a.score)[0];
|
||||
const weakestSignal = [...latestSignals].sort((a, b) => a.score - b.score)[0];
|
||||
const readinessSummary = typeof breakdown.summary === "string" ? breakdown.summary : "No projected readiness summary is available yet.";
|
||||
const readinessSummary = null;
|
||||
const lastUpdatedAt = qscoreResult?.calculated_at ?? null;
|
||||
const signalCount = typeof breakdown.signalCount === "number" ? breakdown.signalCount : latestSignals.length;
|
||||
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",
|
||||
@@ -131,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,
|
||||
};
|
||||
}
|
||||
602
src/v1/analytics/home-insight-snapshot.ts
Normal file
602
src/v1/analytics/home-insight-snapshot.ts
Normal 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 };
|
||||
}
|
||||
@@ -35,6 +35,13 @@ function taskActionDate(input: { taskId: string; date?: string }) {
|
||||
return input.date ?? dateFromTaskId(input.taskId) ?? todayIsoDate();
|
||||
}
|
||||
|
||||
export function allowsDirectServiceCompletion(serviceId: string | undefined, reason: string) {
|
||||
return (
|
||||
(serviceId === "qscore-service" && reason === "qscore_review_opened") ||
|
||||
(serviceId === "matchmaking-service" && reason === "matchmaking_matches_reviewed")
|
||||
);
|
||||
}
|
||||
|
||||
function touch(c: { state: CuratorActorState }, input: { userId: string }) {
|
||||
if (c.state.userId && c.state.userId !== input.userId) throw new Error("curatorActor initialized for a different user");
|
||||
c.state.userId = input.userId;
|
||||
@@ -102,18 +109,8 @@ export const curatorService = {
|
||||
const date = taskActionDate(input);
|
||||
const task = (await buildCuratorTasks(input.userId, date)).find((item) => item.id === input.taskId);
|
||||
const reason = input.reason ?? "subtasks_completed";
|
||||
if (!task && reason === "matchmaking_matches_reviewed") {
|
||||
const event = await emitCuratorEvent({
|
||||
userId: input.userId,
|
||||
type: "curator.task.completed",
|
||||
payload: { taskId: input.taskId, date, reason, recoveredDeepLink: true },
|
||||
});
|
||||
return { task: { id: input.taskId, date, status: "completed" as const }, eventId: event.id };
|
||||
}
|
||||
if (!task) throw new Error("curator_task_not_found");
|
||||
const allowDirectServiceCompletion =
|
||||
(task.serviceId === "qscore-service" && reason === "qscore_review_opened") ||
|
||||
(task.serviceId === "matchmaking-service" && reason === "matchmaking_matches_reviewed");
|
||||
const allowDirectServiceCompletion = allowsDirectServiceCompletion(task.serviceId, reason);
|
||||
if (task.serviceId && !allowDirectServiceCompletion) {
|
||||
throw new Error("curator_service_task_requires_service_event");
|
||||
}
|
||||
@@ -126,11 +123,6 @@ export const curatorService = {
|
||||
return { task: { ...task, status: "completed" as const }, eventId: event.id };
|
||||
},
|
||||
|
||||
async recordServiceImpact(input: { userId: string; eventId: string }) {
|
||||
const streak = await buildCuratorStreak(input.userId);
|
||||
const sprint = await buildCuratorSprint(input.userId, todayIsoDate());
|
||||
return { matched: true, completedTasks: sprint.todayTasks, streak, sprint };
|
||||
},
|
||||
|
||||
async applyImprovementSignals(input: { userId: string; date: string; signals: CuratorImprovementSignal[] }) {
|
||||
for (const signal of input.signals) {
|
||||
@@ -185,11 +177,6 @@ export const curatorActor = actor({
|
||||
c.state.lastEventId = result.eventId;
|
||||
return result;
|
||||
},
|
||||
recordServiceImpact: async (c, input: Parameters<typeof curatorService.recordServiceImpact>[0]) => {
|
||||
touch(c, input);
|
||||
c.state.lastEventId = input.eventId;
|
||||
return curatorService.recordServiceImpact(input);
|
||||
},
|
||||
applyImprovementSignals: async (c, input: Parameters<typeof curatorService.applyImprovementSignals>[0]) => {
|
||||
touch(c, input);
|
||||
return curatorService.applyImprovementSignals(input);
|
||||
|
||||
@@ -7,13 +7,16 @@ function curatorDedupeKey(input: {
|
||||
payload?: Record<string, unknown>;
|
||||
}) {
|
||||
const payload = input.payload ?? {};
|
||||
const stableId =
|
||||
payload.taskId ??
|
||||
payload.sprintId ??
|
||||
payload.startDate ??
|
||||
payload.sourceEventId ??
|
||||
payload.eventId ??
|
||||
payload.date;
|
||||
const stableId = input.type.includes("invalidated")
|
||||
? `${payload.staleSprintEventId ?? payload.staleReadyEventId ?? payload.staleEventId ?? "unknown"}:${payload.planFingerprint ?? "legacy"}`
|
||||
: payload.planFingerprint ??
|
||||
payload.taskPlanFingerprint ??
|
||||
payload.taskId ??
|
||||
payload.sprintId ??
|
||||
payload.startDate ??
|
||||
payload.sourceEventId ??
|
||||
payload.eventId ??
|
||||
payload.date;
|
||||
|
||||
return `${input.userId}:${input.type}:${stableId ?? Date.now()}`;
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { asRecord, getString } from "../../events/envelope.js";
|
||||
import { recordGrowEvent } from "../../events/record-grow-event.js";
|
||||
import { log } from "../../log.js";
|
||||
import { config } from "../../config.js";
|
||||
import { buildCuratorSprint, todayIsoDate } from "./curator-store.js";
|
||||
import { reconcileCuratorSprint, todayIsoDate } from "./curator-store.js";
|
||||
import { emitCuratorEvent } from "./curator-events.js";
|
||||
import type { CuratorSprintResponse } from "./curator-types.js";
|
||||
|
||||
@@ -259,11 +259,6 @@ export async function runCuratorOnboardingLoop(input: OnboardingLoopInput): Prom
|
||||
const userId = input.userId.trim();
|
||||
if (!userId) return { status: "skipped", reason: "missing_user_id" };
|
||||
|
||||
const existing = await findExistingReadyEvent(userId);
|
||||
if (existing) {
|
||||
return { status: "already_ready", readyEventId: existing.id };
|
||||
}
|
||||
|
||||
const startDate = isoDateFrom(input.completedAt);
|
||||
const context = input.context ?? await findLatestOnboardingContext(userId) ?? await fetchUserServiceContext(userId);
|
||||
await recordOnboardingContextSnapshot({
|
||||
@@ -274,9 +269,57 @@ export async function runCuratorOnboardingLoop(input: OnboardingLoopInput): Prom
|
||||
sourceEventId: input.sourceEventId,
|
||||
context,
|
||||
});
|
||||
const sprint = await buildCuratorSprint(userId, startDate);
|
||||
// Always build/reconcile before honoring an existing ready event. This is
|
||||
// required when onboarding is reset and completed again with a new choice.
|
||||
const sprint = await reconcileCuratorSprint(userId, startDate, context ?? {});
|
||||
await upsertPlanReadyNotification(userId, sprint);
|
||||
|
||||
const existing = await findExistingReadyEvent(userId);
|
||||
if (existing) {
|
||||
const existingFingerprint = typeof existing.payload?.planFingerprint === "string" ? existing.payload.planFingerprint : undefined;
|
||||
const existingIcpId = typeof existing.payload?.icpId === "string"
|
||||
? existing.payload.icpId
|
||||
: typeof existing.payload?.variantId === "string" ? existing.payload.variantId : undefined;
|
||||
const compatible = existingFingerprint === sprint.plan.planFingerprint && existingIcpId === sprint.plan.icpId;
|
||||
if (compatible) return { status: "already_ready", readyEventId: existing.id, sprint };
|
||||
|
||||
const invalidations = await db
|
||||
.select({ payload: growEvents.payload })
|
||||
.from(growEvents)
|
||||
.where(and(
|
||||
eq(growEvents.userId, userId),
|
||||
eq(growEvents.source, CURATOR_SOURCE),
|
||||
eq(growEvents.type, "curator.onboarding_plan.invalidated"),
|
||||
))
|
||||
.orderBy(desc(growEvents.occurredAt))
|
||||
.limit(20);
|
||||
const alreadyInvalidated = invalidations.some((row) => (
|
||||
row.payload?.staleReadyEventId === existing.id
|
||||
&& row.payload?.planFingerprint === sprint.plan.planFingerprint
|
||||
));
|
||||
if (!alreadyInvalidated) {
|
||||
await emitCuratorEvent({
|
||||
userId,
|
||||
type: "curator.onboarding_plan.invalidated",
|
||||
payload: {
|
||||
sprintId: sprint.sprintId,
|
||||
staleReadyEventId: existing.id,
|
||||
previousIcpId: existingIcpId,
|
||||
previousDurationDays: existing.payload?.durationDays,
|
||||
previousPlanFingerprint: existingFingerprint,
|
||||
icpId: sprint.plan.icpId,
|
||||
variantId: sprint.plan.icpId,
|
||||
access: sprint.plan.access,
|
||||
durationDays: sprint.plan.durationDays,
|
||||
planFingerprint: sprint.plan.planFingerprint,
|
||||
taskPlan: sprint.plan.taskPlan,
|
||||
dayOneTasks: dayOneTasks(sprint),
|
||||
reason: "onboarding_plan_changed",
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const event = await emitCuratorEvent({
|
||||
userId,
|
||||
type: ONBOARDING_READY_EVENT,
|
||||
@@ -289,10 +332,16 @@ export async function runCuratorOnboardingLoop(input: OnboardingLoopInput): Prom
|
||||
sprintId: sprint.sprintId,
|
||||
planId: sprint.plan.id,
|
||||
durationDays: sprint.plan.durationDays,
|
||||
access: sprint.plan.access,
|
||||
icpId: sprint.plan.icpId,
|
||||
variantId: sprint.plan.icpId,
|
||||
planFingerprint: sprint.plan.planFingerprint,
|
||||
taskPlan: sprint.plan.taskPlan,
|
||||
weekCount: sprint.plan.weeks.length,
|
||||
dayCount: sprint.plan.days.length,
|
||||
activeDayIndex: sprint.activeDayIndex,
|
||||
weeklyThemes: weeklyThemes(sprint),
|
||||
// Keep the future-day visibility contract: this is Day 1 only.
|
||||
dayOneTasks: dayOneTasks(sprint),
|
||||
notificationId: `curator:onboarding-plan-ready:${userId}`,
|
||||
},
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import { Hono } from "hono";
|
||||
import { z } from "zod";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { createClient, type Client } from "rivetkit/client";
|
||||
import { requireUser, type AuthContext } from "../../auth/clerk.js";
|
||||
import { config } from "../../config.js";
|
||||
import { db } from "../../db/client.js";
|
||||
import { onboarding } from "../../db/schema.js";
|
||||
import { fetchOnboardingReadProfile } from "../../services/user-profile.js";
|
||||
import type { Registry } from "../../actors/registry.js";
|
||||
import { buildCuratorSprint, buildCuratorSprintReport, buildCuratorTasks } from "./curator-store.js";
|
||||
|
||||
@@ -38,13 +36,19 @@ export function v1CuratorRoutes() {
|
||||
|
||||
app.get("/sprint", async (c) => {
|
||||
const userId = c.get("userId");
|
||||
const savedOnboarding = await db.query.onboarding.findFirst({
|
||||
where: eq(onboarding.userId, userId),
|
||||
});
|
||||
if (savedOnboarding?.data?.status !== "completed") {
|
||||
const profile = await fetchOnboardingReadProfile(c.req.raw, userId);
|
||||
const preferences = profile.preferences && typeof profile.preferences === "object" && !Array.isArray(profile.preferences)
|
||||
? profile.preferences as Record<string, unknown>
|
||||
: {};
|
||||
const onboarding = preferences.onboarding && typeof preferences.onboarding === "object" && !Array.isArray(preferences.onboarding)
|
||||
? preferences.onboarding as Record<string, unknown>
|
||||
: {};
|
||||
if (onboarding.status !== "completed") {
|
||||
return c.json({ error: "onboarding_incomplete" }, 409);
|
||||
}
|
||||
return c.json(await buildCuratorSprint(userId, c.req.query("date")));
|
||||
return c.json(await buildCuratorSprint(userId, c.req.query("date"), {
|
||||
onboardingContext: { preferences, onboarding },
|
||||
}));
|
||||
});
|
||||
|
||||
app.get("/sprint/report", async (c) => {
|
||||
@@ -81,11 +85,6 @@ export function v1CuratorRoutes() {
|
||||
return c.json(await getCuratorActor(userId).completeTask({ userId, taskId: c.req.param("taskId"), date: c.req.query("date"), reason: body.reason }));
|
||||
});
|
||||
|
||||
app.post("/events/service-impact", async (c) => {
|
||||
const userId = c.get("userId");
|
||||
const body = z.object({ eventId: z.string() }).parse(await c.req.json());
|
||||
return c.json(await getCuratorActor(userId).recordServiceImpact({ userId, eventId: body.eventId }));
|
||||
});
|
||||
|
||||
app.get("/state", async (c) => {
|
||||
const userId = c.get("userId");
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { and, desc, eq, gte, inArray, sql } from "drizzle-orm";
|
||||
import { db } from "../../db/client.js";
|
||||
import { growEvents, missionArtifacts, onboarding, users } from "../../db/schema.js";
|
||||
import { growEvents, missionArtifacts, onboarding, users, type GrowEventRow } from "../../db/schema.js";
|
||||
import { listMissionDefinitions } from "../../missions/registry.js";
|
||||
import { listServiceCapabilities } from "../../workflows/service-capabilities.js";
|
||||
import { DEFAULT_QSCORE_ORG_ID, getQscoreFromService } from "../../services/qscore-proxy.js";
|
||||
import { emitCuratorEvent } from "./curator-events.js";
|
||||
import { buildCuratorUserContext } from "./curator-user-context.js";
|
||||
import { buildCuratorUserContext, type CuratorUserContext } from "./curator-user-context.js";
|
||||
import { isCuratorIcpId, type CuratorIcpId } from "./icp-registry.js";
|
||||
import type {
|
||||
CuratorPlan,
|
||||
@@ -34,41 +34,140 @@ import {
|
||||
} from "./task-registry.js";
|
||||
import { kpiDefinitionsForIcp } from "./kpi-registry.js";
|
||||
import { outcomeDefinitionsForIcp } from "./outcome-report-registry.js";
|
||||
import { addDaysIso, computeStreakFromDays, 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.
|
||||
const VALID_COMPLETION_TYPES = [
|
||||
"service.completed",
|
||||
"resume.analysis.completed",
|
||||
"resume.uploaded",
|
||||
"resume.parsed",
|
||||
"resume.analysis.completed",
|
||||
"resume.updated",
|
||||
"resume.exported",
|
||||
"interview.session.completed",
|
||||
"interview.feedback.generated",
|
||||
"roleplay.scenario.configured",
|
||||
"roleplay.scenario.completed",
|
||||
"roleplay.feedback.generated",
|
||||
"assessment.completed",
|
||||
"course.progress_recorded",
|
||||
"course.generated",
|
||||
"course.started",
|
||||
"course.progress.recorded",
|
||||
"course.completed",
|
||||
"course.lesson.completed",
|
||||
"brand.account.connected",
|
||||
"brand.post.drafted",
|
||||
"brand.post.exported",
|
||||
"brand.profile.updated",
|
||||
"matchmaking.matches.generated",
|
||||
"brand.content.published",
|
||||
"matchmaking.feed.viewed",
|
||||
"matchmaking.match.viewed",
|
||||
"matchmaking.application.started",
|
||||
"matchmaking.match.saved",
|
||||
"matchmaking.matches.reviewed",
|
||||
"matchmaking.match.applied",
|
||||
"matchmaking.application.completed",
|
||||
"qscore.review.opened",
|
||||
"qscore.weak_driver.reviewed",
|
||||
"qscore.updated",
|
||||
"qscore.signal.projected",
|
||||
"curator.task.completed",
|
||||
] as const;
|
||||
|
||||
export function isStreakCompletionType(type: string) {
|
||||
return (VALID_COMPLETION_TYPES as readonly string[]).includes(type);
|
||||
}
|
||||
|
||||
const CURATOR_SOURCE = "curator-v1";
|
||||
const CURATOR_PLAN_VERSION = "icp-v10-static";
|
||||
const FULL_SPRINT_DURATION_DAYS = 7;
|
||||
const TRIAL_SPRINT_DURATION_DAYS = 2;
|
||||
const DAYS_PER_WEEK = 7;
|
||||
|
||||
export type CuratorSprintAccess = {
|
||||
access: "trial" | "full";
|
||||
durationDays: 2 | 7;
|
||||
};
|
||||
|
||||
export type CuratorSprintCompatibility = CuratorSprintAccess & {
|
||||
variantId?: CuratorIcpId;
|
||||
planFingerprint?: string;
|
||||
};
|
||||
|
||||
type CuratorSprintEntitlementInput = {
|
||||
plan?: unknown;
|
||||
accessChoice?: unknown;
|
||||
};
|
||||
|
||||
/** Resolve persisted onboarding choice against the mirrored product entitlement. */
|
||||
export function resolveCuratorSprintAccess(input: CuratorSprintEntitlementInput): CuratorSprintAccess {
|
||||
const plan = typeof input.plan === "string" ? input.plan.toLowerCase() : "free";
|
||||
const fullEntitled = plan === "pro" || plan === "enterprise";
|
||||
if (input.accessChoice === "trial") return { access: "trial", durationDays: TRIAL_SPRINT_DURATION_DAYS };
|
||||
if (input.accessChoice === "full") {
|
||||
return fullEntitled
|
||||
? { access: "full", durationDays: FULL_SPRINT_DURATION_DAYS }
|
||||
: { access: "trial", durationDays: TRIAL_SPRINT_DURATION_DAYS };
|
||||
}
|
||||
return fullEntitled
|
||||
? { access: "full", durationDays: FULL_SPRINT_DURATION_DAYS }
|
||||
: { access: "trial", durationDays: TRIAL_SPRINT_DURATION_DAYS };
|
||||
}
|
||||
|
||||
export function shouldReconcileCuratorSprint(
|
||||
existing: CuratorSprintCompatibility | undefined,
|
||||
desired: CuratorSprintCompatibility,
|
||||
) {
|
||||
if (!existing || existing.access !== desired.access || existing.durationDays !== desired.durationDays) return true;
|
||||
if (desired.variantId !== undefined && existing.variantId !== desired.variantId) return true;
|
||||
if (desired.planFingerprint !== undefined && existing.planFingerprint !== desired.planFingerprint) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
export type CuratorSprintReconciliation =
|
||||
| ({ action: "reuse" } & CuratorSprintAccess & Partial<Pick<CuratorSprintCompatibility, "variantId" | "planFingerprint">>)
|
||||
| ({ action: "invalidateAndRebuild" } & CuratorSprintAccess & Partial<Pick<CuratorSprintCompatibility, "variantId" | "planFingerprint">>);
|
||||
|
||||
export function decideCuratorSprintReconciliation(
|
||||
existing: CuratorSprintCompatibility | undefined,
|
||||
desired: CuratorSprintCompatibility,
|
||||
): CuratorSprintReconciliation {
|
||||
const action = shouldReconcileCuratorSprint(existing, desired) ? "invalidateAndRebuild" : "reuse";
|
||||
return {
|
||||
action,
|
||||
access: desired.access,
|
||||
durationDays: desired.durationDays,
|
||||
...(desired.variantId === undefined ? {} : { variantId: desired.variantId }),
|
||||
...(desired.planFingerprint === undefined ? {} : { planFingerprint: desired.planFingerprint }),
|
||||
};
|
||||
}
|
||||
|
||||
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>
|
||||
: undefined;
|
||||
const onboardingContext = context && context.onboarding && typeof context.onboarding === "object" && !Array.isArray(context.onboarding)
|
||||
? context.onboarding as Record<string, unknown>
|
||||
: undefined;
|
||||
const candidates = [context?.access_choice, onboardingContext?.access_choice, preferences?.access_choice,
|
||||
preferences?.onboarding && typeof preferences.onboarding === "object" && !Array.isArray(preferences.onboarding)
|
||||
? (preferences.onboarding as Record<string, unknown>).access_choice
|
||||
: undefined];
|
||||
return candidates.find((value): value is "trial" | "full" => value === "trial" || value === "full");
|
||||
}
|
||||
const MAX_PLAN_WEEK_COUNT = 6;
|
||||
|
||||
type PlannedTask = TaskSeed & {
|
||||
@@ -111,7 +210,9 @@ type ServiceCurationPreviewInput = {
|
||||
userContext?: Partial<Awaited<ReturnType<typeof buildCuratorUserContext>>>;
|
||||
};
|
||||
|
||||
|
||||
function planWeekCount(durationDays: number) {
|
||||
return Math.ceil(durationDays / DAYS_PER_WEEK);
|
||||
}
|
||||
function calendarDiffDays(startDate: string, endDate: string) {
|
||||
const start = new Date(`${startDate}T00:00:00.000Z`).getTime();
|
||||
const end = new Date(`${endDate}T00:00:00.000Z`).getTime();
|
||||
@@ -122,6 +223,7 @@ function clamp(value: number, min: number, max: number) {
|
||||
return Math.max(min, Math.min(max, value));
|
||||
}
|
||||
|
||||
|
||||
function sprintIdFor(startDate: string) {
|
||||
return `curator-sprint:${CURATOR_PLAN_VERSION}:${startDate}`;
|
||||
}
|
||||
@@ -154,9 +256,6 @@ function dayIndexInWeek(startDate: string, dayIndex: number) {
|
||||
return ((dayIndex - 1) % DAYS_PER_WEEK) + 1;
|
||||
}
|
||||
|
||||
function planWeekCount(durationDays: number) {
|
||||
return Math.ceil(durationDays / DAYS_PER_WEEK);
|
||||
}
|
||||
|
||||
function planFocus(weekTheme: string, seedTask: TaskSeed) {
|
||||
if (seedTask.taskType === "measurement") return `Measure today against the ${weekTheme.toLowerCase()} theme.`;
|
||||
@@ -310,7 +409,7 @@ function normalizeEventBackedTask(task: PlannedTask, planDay: PlanDaySeed, recen
|
||||
"Use analytics to anchor today’s work in a real measurable signal.",
|
||||
"5 min",
|
||||
"+5 projected",
|
||||
"Review Q Score",
|
||||
"Review RQ Score",
|
||||
["readiness signal", "analytics"],
|
||||
),
|
||||
planDay.weekTheme,
|
||||
@@ -325,13 +424,13 @@ function fallbackCarryForwardWeek(templateSet: CuratorIcpTemplateSet): WeekTempl
|
||||
summary: `Use the strongest signals from ${lastWeek.theme.toLowerCase()} to lock in momentum and define the next move.`,
|
||||
days: [
|
||||
{
|
||||
measurement: seed("measurement", "qscore-service", "Review your 30-day movement", "Check which readiness signals moved most over the sprint and what still needs work.", "5 min", "+5 projected", "Review Q Score", ["30 day review", "signal movement"]),
|
||||
measurement: seed("measurement", "qscore-service", "Review your 30-day movement", "Check which readiness signals moved most over the sprint and what still needs work.", "5 min", "+5 projected", "Review RQ Score", ["30 day review", "signal movement"]),
|
||||
proof: seed("proof", "resume-service", "Package your strongest proof into one asset", "Turn the best sprint outcome into a reusable bullet, story, or artifact.", "10 min", "+7 projected", "Open resume workspace", ["proof packaging", "story asset"]),
|
||||
practice: seed("practice", "interview-service", "Run a final closeout interview rep", "Use one realistic question to prove the sprint improved your clarity and confidence.", "10 min", "+10 projected", "Open interview preview", ["closeout rep", "confidence"]),
|
||||
roleplay: seed("practice", "roleplay-service", "Run a final conversation rep", "Use one realistic conversation to prove the sprint improved your confidence under pressure.", "10 min", "+9 projected", "Open roleplay preview", ["closeout roleplay", "confidence"]),
|
||||
},
|
||||
{
|
||||
measurement: seed("measurement", "qscore-service", "Choose the next readiness driver", "Pick the one signal that should define the next 30-day push.", "5 min", "+5 projected", "Review Q Score", ["next sprint", "readiness driver"]),
|
||||
measurement: seed("measurement", "qscore-service", "Choose the next readiness driver", "Pick the one signal that should define the next 30-day push.", "5 min", "+5 projected", "Review RQ Score", ["next sprint", "readiness driver"]),
|
||||
proof: seed("proof", "social-branding-service", "Turn sprint movement into a visible update", "Package your strongest improvement into a public-safe profile or positioning update.", "10 min", "+6 projected", "Open social profile flow", ["visibility update", "carry forward"]),
|
||||
practice: seed("practice", "matchmaking-service", "Review the next opportunities to act on", "Use the stronger profile and practice signals to shortlist the next roles or pathways.", "10 min", "+5 projected", "Open pathways", ["next opportunities", "carry forward"]),
|
||||
roleplay: seed("practice", "roleplay-service", "Practice the next high-value conversation", "Rehearse the conversation most likely to unlock the next sprint move.", "10 min", "+9 projected", "Open roleplay preview", ["next conversation", "carry forward"]),
|
||||
@@ -366,7 +465,12 @@ function trialRecoveryTaskSeed(variantId: CuratorIcpId, missedDayIndex: number):
|
||||
);
|
||||
}
|
||||
|
||||
function planSeedsForVariant(templateSet: CuratorIcpTemplateSet, startDate: string, durationDays: 2 | 7): PlanDaySeed[] {
|
||||
export function planSeedsForVariant(
|
||||
templateSet: CuratorIcpTemplateSet,
|
||||
startDate: string,
|
||||
durationDays: 2 | 7,
|
||||
access: "trial" | "full" = durationDays === TRIAL_SPRINT_DURATION_DAYS ? "trial" : "full",
|
||||
): PlanDaySeed[] {
|
||||
const carryForwardWeek = fallbackCarryForwardWeek(templateSet);
|
||||
const weekTemplates = Array.from({ length: MAX_PLAN_WEEK_COUNT }, (_, index) => (
|
||||
templateSet.weeks[index % templateSet.weeks.length] ?? carryForwardWeek
|
||||
@@ -379,7 +483,7 @@ function planSeedsForVariant(templateSet: CuratorIcpTemplateSet, startDate: stri
|
||||
const dayOfWeek = (index % DAYS_PER_WEEK) + 1;
|
||||
const weekTemplate = weekTemplates[weekIndex - 1] ?? weekTemplates[0]!;
|
||||
const dayTemplate = weekTemplate.days[dayOfWeek - 1] ?? weekTemplate.days[0]!;
|
||||
const trialTemplate = dayIndex === 1 || dayIndex === 2 ? trialDaysFor(templateSet.id)[dayIndex - 1] : undefined;
|
||||
const trialTemplate = access === "trial" && (dayIndex === 1 || dayIndex === 2) ? trialDaysFor(templateSet.id)[dayIndex - 1] : undefined;
|
||||
const isTrialDay = Boolean(trialTemplate);
|
||||
const plannedTasks = trialTemplate
|
||||
? trialTaskSeedsForVariant(templateSet.id, dayIndex as 1 | 2).map((task) => makeTrialTask(task, weekTemplate.theme, weekTemplate.summary))
|
||||
@@ -410,6 +514,47 @@ function planSeedsForVariant(templateSet: CuratorIcpTemplateSet, startDate: stri
|
||||
return planDays;
|
||||
}
|
||||
|
||||
export type CuratorSprintTaskPlan = {
|
||||
dayIndex: number;
|
||||
tasks: Array<{ id: string; title: string }>;
|
||||
};
|
||||
|
||||
function taskPlanForSeeds(startDate: string, planDays: PlanDaySeed[]): CuratorSprintTaskPlan[] {
|
||||
return planDays.map((day) => ({
|
||||
dayIndex: day.dayIndex,
|
||||
tasks: day.plannedTasks.map((task) => ({
|
||||
id: taskIdFor(startDate, day.dayIndex, task.taskType, task.serviceId),
|
||||
title: task.title,
|
||||
})),
|
||||
}));
|
||||
}
|
||||
|
||||
export function curatorSprintTaskPlan(input: {
|
||||
startDate: string;
|
||||
variantId: CuratorIcpId;
|
||||
durationDays: 2 | 7;
|
||||
access: "trial" | "full";
|
||||
}): CuratorSprintTaskPlan[] {
|
||||
return taskPlanForSeeds(
|
||||
input.startDate,
|
||||
planSeedsForVariant(templateSetFor(input.variantId), input.startDate, input.durationDays, input.access),
|
||||
);
|
||||
}
|
||||
|
||||
/** Stable compatibility key for the complete static sprint task plan. */
|
||||
export function curatorSprintPlanFingerprint(input: {
|
||||
startDate: string;
|
||||
variantId: CuratorIcpId;
|
||||
durationDays: 2 | 7;
|
||||
access: "trial" | "full";
|
||||
}): string {
|
||||
const taskPlan = curatorSprintTaskPlan(input);
|
||||
return JSON.stringify({
|
||||
variantId: input.variantId,
|
||||
taskIds: taskPlan.flatMap((day) => day.tasks.map((task) => task.id)),
|
||||
});
|
||||
}
|
||||
|
||||
function performanceLabel(percent: number): CuratorWeek["performance"] {
|
||||
if (percent >= 75) return "Excelling";
|
||||
if (percent >= 50) return "Avg";
|
||||
@@ -470,10 +615,61 @@ function eventText(value: unknown): string {
|
||||
return "";
|
||||
}
|
||||
|
||||
async function inferIcpVariant(userId: string): Promise<CuratorIcpId> {
|
||||
const CURATOR_ICP_BY_ONBOARDING_ICP: Record<string, CuratorIcpId> = {
|
||||
student: "student_recent_grad",
|
||||
intern: "intern",
|
||||
fresher: "fresher_early_professional",
|
||||
experienced: "experienced_professional",
|
||||
freelancer: "freelancer",
|
||||
founder: "founder",
|
||||
};
|
||||
|
||||
function curatorIcpFromContext(context: Record<string, unknown> | undefined): CuratorIcpId | undefined {
|
||||
if (!context) return undefined;
|
||||
const preferences = context.preferences && typeof context.preferences === "object" && !Array.isArray(context.preferences)
|
||||
? context.preferences as Record<string, unknown>
|
||||
: undefined;
|
||||
const onboardingContext = context.onboarding && typeof context.onboarding === "object" && !Array.isArray(context.onboarding)
|
||||
? context.onboarding as Record<string, unknown>
|
||||
: undefined;
|
||||
const preferenceOnboarding = preferences?.onboarding && typeof preferences.onboarding === "object" && !Array.isArray(preferences.onboarding)
|
||||
? preferences.onboarding as Record<string, unknown>
|
||||
: undefined;
|
||||
const profile = onboardingContext?.profile && typeof onboardingContext.profile === "object" && !Array.isArray(onboardingContext.profile)
|
||||
? onboardingContext.profile as Record<string, unknown>
|
||||
: preferenceOnboarding?.profile && typeof preferenceOnboarding.profile === "object" && !Array.isArray(preferenceOnboarding.profile)
|
||||
? preferenceOnboarding.profile as Record<string, unknown>
|
||||
: undefined;
|
||||
const candidates = [
|
||||
context.curator_registry_icp,
|
||||
onboardingContext?.curator_registry_icp,
|
||||
preferences?.curator_registry_icp,
|
||||
context.onboarding_icp,
|
||||
onboardingContext?.onboarding_icp,
|
||||
profile?.icp,
|
||||
];
|
||||
for (const candidate of candidates) {
|
||||
if (typeof candidate !== "string") continue;
|
||||
if (isCuratorIcpId(candidate)) return candidate;
|
||||
const mapped = CURATOR_ICP_BY_ONBOARDING_ICP[candidate.toLowerCase()];
|
||||
if (mapped) return mapped;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function inferIcpVariant(userId: string, context?: Record<string, unknown>, preferContext = false): Promise<CuratorIcpId> {
|
||||
const fromContext = curatorIcpFromContext(context);
|
||||
const saved = await db.query.onboarding.findFirst({ where: eq(onboarding.userId, userId) });
|
||||
const canonical = saved?.payload && typeof saved.payload === "object" ? saved.payload.curator_registry_icp : undefined;
|
||||
if (typeof canonical === "string" && isCuratorIcpId(canonical)) return canonical;
|
||||
if (preferContext && fromContext) return fromContext;
|
||||
const savedPayload = saved?.payload && typeof saved.payload === "object" ? saved.payload : undefined;
|
||||
const savedData = saved?.data && typeof saved.data === "object" ? saved.data : undefined;
|
||||
const fromSaved = curatorIcpFromContext({
|
||||
curator_registry_icp: savedPayload?.curator_registry_icp,
|
||||
onboarding_icp: savedPayload?.onboarding_icp,
|
||||
onboarding: savedData,
|
||||
});
|
||||
if (fromSaved) return fromSaved;
|
||||
if (fromContext) return fromContext;
|
||||
|
||||
const rows = await db
|
||||
.select({ type: growEvents.type, payload: growEvents.payload })
|
||||
@@ -495,6 +691,27 @@ async function inferIcpVariant(userId: string): Promise<CuratorIcpId> {
|
||||
return "fresher_early_professional";
|
||||
}
|
||||
|
||||
async function latestOnboardingContextForUser(userId: string): Promise<Record<string, unknown> | undefined> {
|
||||
const rows = await db
|
||||
.select({ payload: growEvents.payload })
|
||||
.from(growEvents)
|
||||
.where(and(
|
||||
eq(growEvents.userId, userId),
|
||||
inArray(growEvents.type, [
|
||||
"onboarding.completed",
|
||||
"user.onboarding.completed",
|
||||
"profile.onboarding.completed",
|
||||
"onboarding_completed",
|
||||
"user_onboarding_completed",
|
||||
"profile_onboarding_completed",
|
||||
]),
|
||||
))
|
||||
.orderBy(desc(growEvents.occurredAt))
|
||||
.limit(1);
|
||||
const payload = rows[0]?.payload;
|
||||
return payload && typeof payload === "object" && !Array.isArray(payload) ? payload as Record<string, unknown> : undefined;
|
||||
}
|
||||
|
||||
|
||||
function clonePlanDay(day: PlanDaySeed): PlanDaySeed {
|
||||
return {
|
||||
@@ -503,16 +720,33 @@ function clonePlanDay(day: PlanDaySeed): PlanDaySeed {
|
||||
};
|
||||
}
|
||||
|
||||
async function loadSprintState(userId: string, todayDate = todayIso()): Promise<SprintState> {
|
||||
async function loadSprintState(
|
||||
userId: string,
|
||||
todayDate = curatorLogicalDate(),
|
||||
onboardingContextOverride?: Record<string, unknown>,
|
||||
): Promise<SprintState> {
|
||||
const savedOnboarding = await db.query.onboarding.findFirst({
|
||||
where: eq(onboarding.userId, userId),
|
||||
});
|
||||
if (savedOnboarding?.data?.status !== "completed") {
|
||||
throw new Error("onboarding_incomplete");
|
||||
}
|
||||
const persistedContext = onboardingContextOverride ?? await latestOnboardingContextForUser(userId);
|
||||
const dbData = savedOnboarding?.data && typeof savedOnboarding.data === "object" ? savedOnboarding.data : undefined;
|
||||
const dbCompleted = dbData?.status === "completed";
|
||||
const contextOnboarding = persistedContext?.onboarding && typeof persistedContext.onboarding === "object" && !Array.isArray(persistedContext.onboarding)
|
||||
? persistedContext.onboarding as Record<string, unknown>
|
||||
: undefined;
|
||||
const contextCompleted = contextOnboarding?.status === "completed" || typeof contextOnboarding?.completed_at === "string";
|
||||
if (!dbCompleted && !contextCompleted) throw new Error("onboarding_incomplete");
|
||||
|
||||
const user = await db.query.users.findFirst({ where: eq(users.id, userId) });
|
||||
const dbChoice = dbData?.access_choice;
|
||||
const accessChoice = onboardingContextOverride
|
||||
? accessChoiceFromContext(persistedContext) ?? dbChoice
|
||||
: dbChoice ?? accessChoiceFromContext(persistedContext);
|
||||
const desired = resolveCuratorSprintAccess({ plan: user?.plan, accessChoice });
|
||||
const desiredVariant = await inferIcpVariant(userId, persistedContext, Boolean(onboardingContextOverride));
|
||||
|
||||
const latest = await db
|
||||
.select({ payload: growEvents.payload })
|
||||
.select({ id: growEvents.id, payload: growEvents.payload })
|
||||
.from(growEvents)
|
||||
.where(and(
|
||||
eq(growEvents.userId, userId),
|
||||
@@ -522,49 +756,132 @@ async function loadSprintState(userId: string, todayDate = todayIso()): Promise<
|
||||
.orderBy(desc(growEvents.occurredAt))
|
||||
.limit(1);
|
||||
|
||||
const existingStartDate = typeof latest[0]?.payload?.startDate === "string" ? latest[0].payload.startDate : undefined;
|
||||
const existingVersion = typeof latest[0]?.payload?.version === "string" ? latest[0].payload.version : undefined;
|
||||
const existingVariant = typeof latest[0]?.payload?.variantId === "string" ? latest[0].payload.variantId : undefined;
|
||||
const existingDuration = latest[0]?.payload?.durationDays === 7 ? 7 : latest[0]?.payload?.durationDays === 2 ? 2 : undefined;
|
||||
const existingAccess = latest[0]?.payload?.access === "full" ? "full" : "trial";
|
||||
if (
|
||||
existingStartDate &&
|
||||
existingVersion === CURATOR_PLAN_VERSION &&
|
||||
existingVariant &&
|
||||
isCuratorIcpId(existingVariant) &&
|
||||
existingDuration &&
|
||||
calendarDiffDays(existingStartDate, todayDate) < existingDuration
|
||||
) {
|
||||
const templateSet = templateSetFor(existingVariant as CuratorIcpId);
|
||||
return {
|
||||
const latestPayload = latest[0]?.payload;
|
||||
const existingStartDate = typeof latestPayload?.startDate === "string" ? latestPayload.startDate : undefined;
|
||||
const existingVersion = typeof latestPayload?.version === "string" ? latestPayload.version : undefined;
|
||||
const existingVariant = typeof latestPayload?.variantId === "string" && isCuratorIcpId(latestPayload.variantId)
|
||||
? latestPayload.variantId
|
||||
: undefined;
|
||||
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 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 },
|
||||
desired,
|
||||
today: todayDate,
|
||||
planVersion: CURATOR_PLAN_VERSION,
|
||||
})
|
||||
: undefined;
|
||||
const activeExisting = Boolean(
|
||||
!rebaseExistingForCompletion && existingStartDate && existingVersion === CURATOR_PLAN_VERSION && existingVariant
|
||||
&& existingDuration && existingAccess && reuseDecision?.action === "reuse",
|
||||
);
|
||||
|
||||
let replacementStartDate = todayDate;
|
||||
if (activeExisting && existingStartDate && existingVariant && existingDuration && existingAccess) {
|
||||
const desiredFingerprint = curatorSprintPlanFingerprint({
|
||||
startDate: existingStartDate,
|
||||
variantId: existingVariant as CuratorIcpId,
|
||||
durationDays: existingDuration,
|
||||
access: existingAccess,
|
||||
planDays: planSeedsForVariant(templateSet, existingStartDate, existingDuration),
|
||||
};
|
||||
variantId: desiredVariant,
|
||||
durationDays: desired.durationDays,
|
||||
access: desired.access,
|
||||
});
|
||||
const reconciliation = decideCuratorSprintReconciliation(
|
||||
{ access: existingAccess, durationDays: existingDuration, variantId: existingVariant, planFingerprint: existingFingerprint },
|
||||
{ ...desired, variantId: desiredVariant, planFingerprint: desiredFingerprint },
|
||||
);
|
||||
if (reconciliation.action === "reuse") {
|
||||
return {
|
||||
startDate: existingStartDate,
|
||||
variantId: existingVariant,
|
||||
durationDays: desired.durationDays,
|
||||
access: desired.access,
|
||||
planDays: planSeedsForVariant(templateSetFor(existingVariant), existingStartDate, desired.durationDays, desired.access),
|
||||
};
|
||||
}
|
||||
|
||||
const invalidations = await db
|
||||
.select({ payload: growEvents.payload })
|
||||
.from(growEvents)
|
||||
.where(and(
|
||||
eq(growEvents.userId, userId),
|
||||
eq(growEvents.source, CURATOR_SOURCE),
|
||||
eq(growEvents.type, "curator.sprint.invalidated"),
|
||||
))
|
||||
.orderBy(desc(growEvents.occurredAt))
|
||||
.limit(20);
|
||||
const alreadyInvalidated = invalidations.some((row) => (
|
||||
row.payload?.staleSprintEventId === latest[0]?.id
|
||||
&& row.payload?.planFingerprint === desiredFingerprint
|
||||
));
|
||||
if (!alreadyInvalidated) {
|
||||
await emitCuratorEvent({
|
||||
userId,
|
||||
type: "curator.sprint.invalidated",
|
||||
payload: {
|
||||
sprintId: sprintIdFor(existingStartDate),
|
||||
staleSprintEventId: latest[0]?.id,
|
||||
staleSprintId: sprintIdFor(existingStartDate),
|
||||
startDate: existingStartDate,
|
||||
reason: "onboarding_plan_changed",
|
||||
previousVariantId: existingVariant,
|
||||
previousAccess: existingAccess,
|
||||
previousDurationDays: existingDuration,
|
||||
previousPlanFingerprint: existingFingerprint,
|
||||
variantId: desiredVariant,
|
||||
access: desired.access,
|
||||
durationDays: desired.durationDays,
|
||||
planFingerprint: desiredFingerprint,
|
||||
version: CURATOR_PLAN_VERSION,
|
||||
},
|
||||
});
|
||||
}
|
||||
// Keep the same calendar sprint identity while replacing its incompatible lineage.
|
||||
replacementStartDate = existingStartDate;
|
||||
}
|
||||
|
||||
const variantId = await inferIcpVariant(userId);
|
||||
const variantId = desiredVariant;
|
||||
const templateSet = templateSetFor(variantId);
|
||||
const user = await db.query.users.findFirst({ where: eq(users.id, userId) });
|
||||
const access = user?.plan === "pro" || user?.plan === "enterprise" ? "full" as const : "trial" as const;
|
||||
const durationDays = access === "full" ? FULL_SPRINT_DURATION_DAYS : TRIAL_SPRINT_DURATION_DAYS;
|
||||
const planDays = planSeedsForVariant(templateSet, todayDate, durationDays);
|
||||
const planDays = planSeedsForVariant(templateSet, replacementStartDate, desired.durationDays, desired.access);
|
||||
const planFingerprint = curatorSprintPlanFingerprint({
|
||||
startDate: replacementStartDate,
|
||||
variantId,
|
||||
durationDays: desired.durationDays,
|
||||
access: desired.access,
|
||||
});
|
||||
await emitCuratorEvent({
|
||||
userId,
|
||||
type: "curator.sprint.started",
|
||||
payload: {
|
||||
startDate: todayDate,
|
||||
sprintId: sprintIdFor(todayDate),
|
||||
durationDays,
|
||||
access,
|
||||
startDate: replacementStartDate,
|
||||
sprintId: sprintIdFor(replacementStartDate),
|
||||
durationDays: desired.durationDays,
|
||||
access: desired.access,
|
||||
version: CURATOR_PLAN_VERSION,
|
||||
variantId,
|
||||
icpId: variantId,
|
||||
icpLabel: templateSet.label,
|
||||
planFingerprint,
|
||||
taskPlan: taskPlanForSeeds(replacementStartDate, planDays),
|
||||
},
|
||||
});
|
||||
return { startDate: todayDate, variantId, durationDays, access, planDays: planDays.map(clonePlanDay) };
|
||||
return {
|
||||
startDate: replacementStartDate,
|
||||
variantId,
|
||||
durationDays: desired.durationDays,
|
||||
access: desired.access,
|
||||
planDays: planDays.map(clonePlanDay),
|
||||
};
|
||||
}
|
||||
|
||||
async function loadRecentContextRows(userId: string, sinceDate: string) {
|
||||
@@ -584,7 +901,18 @@ async function loadRecentContextRows(userId: string, sinceDate: string) {
|
||||
|
||||
async function loadCurrentQScore(userId: string) {
|
||||
const result = await getQscoreFromService(userId, DEFAULT_QSCORE_ORG_ID);
|
||||
return result?.q_score ?? null;
|
||||
return result?.rq_score ?? null;
|
||||
}
|
||||
|
||||
export type CuratorCompletionEvent = Pick<GrowEventRow, "type" | "payload" | "correlation">;
|
||||
|
||||
export function isTaskCompletionEvent(taskId: string, completionEvents: string[], row: CuratorCompletionEvent) {
|
||||
const eventTaskId = row.correlation?.task_id;
|
||||
return eventTaskId === taskId && (
|
||||
row.type === "curator.task.completed" ||
|
||||
row.type === "service.completed" ||
|
||||
completionEvents.includes(row.type)
|
||||
);
|
||||
}
|
||||
|
||||
async function loadCompletionRows(userId: string, sinceDate: string) {
|
||||
@@ -599,23 +927,8 @@ async function loadCompletionRows(userId: string, sinceDate: string) {
|
||||
.orderBy(desc(growEvents.occurredAt));
|
||||
}
|
||||
|
||||
function taskCompletedByTaskId(taskId: string, completionEvents: string[], rows: Awaited<ReturnType<typeof loadCompletionRows>>) {
|
||||
return rows.some((row) => {
|
||||
const payload = row.payload ?? {};
|
||||
const correlation = row.correlation ?? {};
|
||||
const eventTaskId =
|
||||
payload.taskId ??
|
||||
payload.curatorTaskId ??
|
||||
payload.curator_task_id ??
|
||||
correlation.taskId ??
|
||||
correlation.curatorTaskId ??
|
||||
correlation.curator_task_id;
|
||||
return eventTaskId === taskId && (
|
||||
row.type === "curator.task.completed" ||
|
||||
row.type === "service.completed" ||
|
||||
completionEvents.includes(row.type)
|
||||
);
|
||||
});
|
||||
function taskCompletedByTaskId(taskId: string, completionEvents: string[], rows: CuratorCompletionEvent[]) {
|
||||
return rows.some((row) => isTaskCompletionEvent(taskId, completionEvents, row));
|
||||
}
|
||||
|
||||
function taskCompletedByEvents(task: CuratorTask, rows: Awaited<ReturnType<typeof loadCompletionRows>>) {
|
||||
@@ -626,16 +939,8 @@ function hasRecentServiceSignal(rows: Awaited<ReturnType<typeof loadRecentContex
|
||||
return rows.some((row) => pattern.test(`${row.source} ${row.type} ${eventText(row.payload)}`.toLowerCase()));
|
||||
}
|
||||
|
||||
function taskEventId(row: Pick<Awaited<ReturnType<typeof loadRecentContextRows>>[number], "payload" | "correlation">) {
|
||||
const payload = row.payload ?? {};
|
||||
const correlation = row.correlation ?? {};
|
||||
const value =
|
||||
payload.taskId ??
|
||||
payload.curatorTaskId ??
|
||||
payload.curator_task_id ??
|
||||
correlation.taskId ??
|
||||
correlation.curatorTaskId ??
|
||||
correlation.curator_task_id;
|
||||
function taskEventId(row: Pick<Awaited<ReturnType<typeof loadRecentContextRows>>[number], "correlation">) {
|
||||
const value = row.correlation?.task_id;
|
||||
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
@@ -720,10 +1025,7 @@ function trialRecoveryPlanDay(
|
||||
...current,
|
||||
focus: recovery.focus,
|
||||
outcome: recovery.outcome,
|
||||
plannedTasks: [
|
||||
recoveryTask,
|
||||
...current.plannedTasks.filter((task) => !task.isRecoveryPractice).slice(0, 3),
|
||||
],
|
||||
plannedTasks: composeTrialDay2(variantId, 0, current.plannedTasks.filter((task) => !task.isRecoveryPractice), recoveryTask),
|
||||
generationStatus: "adapted",
|
||||
adaptationReason: `trial day ${missedDayIndex} missed; assigned ${recovery.serviceId} practice recovery`,
|
||||
hasRecoveryPractice: true,
|
||||
@@ -818,7 +1120,7 @@ function adaptCurrentDayPlan(
|
||||
const openedIncompleteCount = previousClassifications.filter((item) => item.opened && !item.completed).length;
|
||||
const skippedCount = previousClassifications.filter((item) => !item.opened && !item.completed).length;
|
||||
current.plannedTasks[0] = makePlannedTask(
|
||||
seed("measurement", "qscore-service", `Review what blocked Day ${previous.dayIndex}`, "Check the strongest blocker from yesterday before generating more blind work.", "5 min", "+5 projected", "Review Q Score", ["blocker review", "momentum"]),
|
||||
seed("measurement", "qscore-service", `Review what blocked Day ${previous.dayIndex}`, "Check the strongest blocker from yesterday before generating more blind work.", "5 min", "+5 projected", "Review RQ Score", ["blocker review", "momentum"]),
|
||||
current.weekTheme,
|
||||
current.weekSummary,
|
||||
);
|
||||
@@ -837,7 +1139,7 @@ function adaptCurrentDayPlan(
|
||||
|
||||
if ((currentQScore ?? 0) > 0 && (currentQScore ?? 0) < 55) {
|
||||
current.plannedTasks[0] = makePlannedTask(
|
||||
seed("measurement", "qscore-service", "Review the weakest readiness driver", "Use analytics to focus today on the signal that is still pulling the score down most.", "5 min", "+5 projected", "Review Q Score", ["weakest driver", "analytics"]),
|
||||
seed("measurement", "qscore-service", "Review the weakest readiness driver", "Use analytics to focus today on the signal that is still pulling the score down most.", "5 min", "+5 projected", "Review RQ Score", ["weakest driver", "analytics"]),
|
||||
current.weekTheme,
|
||||
current.weekSummary,
|
||||
);
|
||||
@@ -883,8 +1185,9 @@ function buildTask(
|
||||
recentRows: Awaited<ReturnType<typeof loadRecentContextRows>>,
|
||||
focusDate: string,
|
||||
durationDays: number,
|
||||
targetRole?: string,
|
||||
userContext: CuratorUserContext,
|
||||
): CuratorTask {
|
||||
const targetRole = userContext.targetRole ?? undefined;
|
||||
seedTask = {
|
||||
...normalizeActiveCuratorTaskSeed(seedTask),
|
||||
isTrialTask: seedTask.isTrialTask,
|
||||
@@ -897,6 +1200,7 @@ function buildTask(
|
||||
const id = taskIdFor(sprintStartDate, dayIndex, seedTask.taskType, seedTask.serviceId);
|
||||
const missionInstanceId = sprintIdFor(sprintStartDate);
|
||||
const stageId = stageIdFor(dayIndex, weekIndex, seedTask.taskType, seedTask.serviceId);
|
||||
const isProfileTask = /complete (?:your )?(?:growqr |professional |executive |freelancer |founder |employer )?profile/i.test(`${seedTask.title} ${seedTask.subtitle}`);
|
||||
const task: CuratorTask = {
|
||||
id,
|
||||
date,
|
||||
@@ -928,6 +1232,10 @@ function buildTask(
|
||||
{ label: "Task type", value: seedTask.taskType },
|
||||
{ label: "Service handoff", value: serviceName(seedTask.serviceId) },
|
||||
...(targetRole ? [{ label: "Target role", value: targetRole }] : [{ label: "Target role", value: "Complete onboarding to set your role" }]),
|
||||
...(isProfileTask ? [
|
||||
{ label: "Resume", value: userContext.profile.resumeAvailable ? "Added" : "Missing" },
|
||||
{ label: "LinkedIn", value: userContext.profile.linkedinAvailable ? "Connected" : "Missing" },
|
||||
] : []),
|
||||
{ label: "Sprint day", value: `Day ${dayIndex}/${durationDays}` },
|
||||
],
|
||||
contextNarrative: contextNarrative(seedTask, weekTheme, weekSummary),
|
||||
@@ -939,10 +1247,16 @@ function buildTask(
|
||||
isPaidTask: seedTask.isPaidTask !== false,
|
||||
isRecoveryPractice: Boolean(seedTask.isRecoveryPractice),
|
||||
};
|
||||
const profilePolicy = isProfileTask
|
||||
? resolveProfileTaskPolicy(userContext.profile)
|
||||
: undefined;
|
||||
const routeTask = profilePolicy
|
||||
? { ...task, serviceId: profilePolicy.serviceId }
|
||||
: task;
|
||||
const taskWithRoute = {
|
||||
...task,
|
||||
route: buildCuratorTaskDeepLink(task, targetRole),
|
||||
status: classifyTaskStatus({ task, focusDate, completionRows, recentRows }),
|
||||
route: buildCuratorTaskDeepLink(routeTask, targetRole),
|
||||
status: profilePolicy?.complete ? "completed" as const : classifyTaskStatus({ task, focusDate, completionRows, recentRows }),
|
||||
} satisfies CuratorTask;
|
||||
return taskWithRoute;
|
||||
}
|
||||
@@ -955,7 +1269,7 @@ function buildTasksForPlanDay(
|
||||
recentRows: Awaited<ReturnType<typeof loadRecentContextRows>>,
|
||||
focusDate: string,
|
||||
durationDays: number,
|
||||
targetRole?: string,
|
||||
userContext: CuratorUserContext,
|
||||
) {
|
||||
return planDay.plannedTasks.map((task) => (
|
||||
buildTask(
|
||||
@@ -970,15 +1284,15 @@ function buildTasksForPlanDay(
|
||||
recentRows,
|
||||
focusDate,
|
||||
durationDays,
|
||||
targetRole,
|
||||
userContext,
|
||||
)
|
||||
));
|
||||
}
|
||||
|
||||
export async function buildCuratorTasks(userId: string, date = todayIso()): Promise<CuratorTask[]> {
|
||||
export async function buildCuratorTasks(userId: string, date = curatorLogicalDate()): Promise<CuratorTask[]> {
|
||||
const sprintState = await loadSprintState(userId, date);
|
||||
const sprintStartDate = sprintState.startDate;
|
||||
const dayIndex = clamp(calendarDiffDays(sprintStartDate, date) + 1, 1, sprintState.durationDays);
|
||||
const dayIndex = activeCuratorDayIndex(sprintStartDate, date, sprintState.durationDays);
|
||||
const completionRows = await loadCompletionRows(userId, sprintStartDate);
|
||||
const recentRows = await loadRecentContextRows(userId, sprintStartDate);
|
||||
const currentQScore = await loadCurrentQScore(userId);
|
||||
@@ -994,7 +1308,7 @@ export async function buildCuratorTasks(userId: string, date = todayIso()): Prom
|
||||
);
|
||||
const planDay = adaptedPlanDays[dayIndex - 1];
|
||||
if (!planDay) return [];
|
||||
return buildTasksForPlanDay(sprintStartDate, date, planDay, completionRows, recentRows, date, sprintState.durationDays, userContext.targetRole ?? undefined);
|
||||
return buildTasksForPlanDay(sprintStartDate, date, planDay, completionRows, recentRows, date, sprintState.durationDays, userContext);
|
||||
}
|
||||
|
||||
export async function buildCuratorStreak(userId: string): Promise<CuratorStreak> {
|
||||
@@ -1003,7 +1317,11 @@ export async function buildCuratorStreak(userId: string): Promise<CuratorStreak>
|
||||
day: sql<string>`to_char(${growEvents.occurredAt} AT TIME ZONE 'Asia/Kolkata', 'YYYY-MM-DD')`,
|
||||
})
|
||||
.from(growEvents)
|
||||
.where(and(eq(growEvents.userId, userId), inArray(growEvents.type, [...VALID_COMPLETION_TYPES])))
|
||||
.where(and(
|
||||
eq(growEvents.userId, userId),
|
||||
inArray(growEvents.type, [...VALID_COMPLETION_TYPES]),
|
||||
sql`${growEvents.correlation}->>'task_id' is not null`,
|
||||
))
|
||||
.groupBy(sql`to_char(${growEvents.occurredAt} AT TIME ZONE 'Asia/Kolkata', 'YYYY-MM-DD')`)
|
||||
.orderBy(sql`to_char(${growEvents.occurredAt} AT TIME ZONE 'Asia/Kolkata', 'YYYY-MM-DD') desc`)
|
||||
.limit(90);
|
||||
@@ -1027,8 +1345,16 @@ const TERMINAL_REPORT_STATUSES: CuratorTask["status"][] = [
|
||||
"abandoned",
|
||||
];
|
||||
|
||||
async function buildCuratorSprintInternal(userId: string, focusDate = todayIso()): Promise<CuratorSprintResponse> {
|
||||
const sprintState = await loadSprintState(userId, focusDate);
|
||||
type CuratorSprintBuildOptions = {
|
||||
onboardingContext?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
async function buildCuratorSprintInternal(
|
||||
userId: string,
|
||||
focusDate = curatorLogicalDate(),
|
||||
options: CuratorSprintBuildOptions = {},
|
||||
): Promise<CuratorSprintResponse> {
|
||||
const sprintState = await loadSprintState(userId, focusDate, options.onboardingContext);
|
||||
const sprintStartDate = sprintState.startDate;
|
||||
const templateSet = templateSetFor(sprintState.variantId);
|
||||
const streak = await buildCuratorStreak(userId);
|
||||
@@ -1036,7 +1362,7 @@ async function buildCuratorSprintInternal(userId: string, focusDate = todayIso()
|
||||
const recentRows = await loadRecentContextRows(userId, sprintStartDate);
|
||||
const currentQScore = await loadCurrentQScore(userId);
|
||||
const userContext = await buildCuratorUserContext(userId);
|
||||
const activeDayIndex = clamp(calendarDiffDays(sprintStartDate, focusDate) + 1, 1, sprintState.durationDays);
|
||||
const activeDayIndex = activeCuratorDayIndex(sprintStartDate, focusDate, sprintState.durationDays);
|
||||
const planDays = adaptCurrentDayPlan(
|
||||
sprintStartDate,
|
||||
sprintState.variantId,
|
||||
@@ -1053,7 +1379,7 @@ async function buildCuratorSprintInternal(userId: string, focusDate = todayIso()
|
||||
const date = addDaysIso(sprintStartDate, index);
|
||||
const planDay = planDays[index]!;
|
||||
const tasks = date <= focusDate
|
||||
? buildTasksForPlanDay(sprintStartDate, date, planDay, completionRows, recentRows, focusDate, sprintState.durationDays, userContext.targetRole ?? undefined)
|
||||
? buildTasksForPlanDay(sprintStartDate, date, planDay, completionRows, recentRows, focusDate, sprintState.durationDays, userContext)
|
||||
: [];
|
||||
const completedCount = tasks.filter((task) => task.status === "completed").length;
|
||||
const unlockState = focusDate > date ? "completed" : focusDate === date ? "active" : "upcoming";
|
||||
@@ -1117,6 +1443,20 @@ async function buildCuratorSprintInternal(userId: string, focusDate = todayIso()
|
||||
goals: [templateSet.sprintTheme, templateSet.goal],
|
||||
generatedAt: new Date().toISOString(),
|
||||
durationDays: sprintState.durationDays,
|
||||
access: sprintState.access,
|
||||
icpId: sprintState.variantId,
|
||||
planFingerprint: curatorSprintPlanFingerprint({
|
||||
startDate: sprintStartDate,
|
||||
variantId: sprintState.variantId,
|
||||
durationDays: sprintState.durationDays,
|
||||
access: sprintState.access,
|
||||
}),
|
||||
taskPlan: curatorSprintTaskPlan({
|
||||
startDate: sprintStartDate,
|
||||
variantId: sprintState.variantId,
|
||||
durationDays: sprintState.durationDays,
|
||||
access: sprintState.access,
|
||||
}),
|
||||
weeks,
|
||||
days,
|
||||
streak,
|
||||
@@ -1148,8 +1488,20 @@ export async function buildCuratorPlan(userId: string, input: { startDate: strin
|
||||
return sprint.plan;
|
||||
}
|
||||
|
||||
export async function buildCuratorSprint(userId: string, date = todayIso()): Promise<CuratorSprintResponse> {
|
||||
return buildCuratorSprintInternal(userId, date);
|
||||
export async function buildCuratorSprint(
|
||||
userId: string,
|
||||
date = curatorLogicalDate(),
|
||||
options: CuratorSprintBuildOptions = {},
|
||||
): Promise<CuratorSprintResponse> {
|
||||
return buildCuratorSprintInternal(userId, date, options);
|
||||
}
|
||||
|
||||
export async function reconcileCuratorSprint(
|
||||
userId: string,
|
||||
date: string,
|
||||
onboardingContext: Record<string, unknown>,
|
||||
): Promise<CuratorSprintResponse> {
|
||||
return buildCuratorSprint(userId, date, { onboardingContext });
|
||||
}
|
||||
|
||||
function taskIsTerminalForReport(task: CuratorTask) {
|
||||
@@ -1253,7 +1605,7 @@ async function saveSprintReport(report: CuratorSprintReport) {
|
||||
|
||||
export async function buildCuratorSprintReport(
|
||||
userId: string,
|
||||
date = todayIso(),
|
||||
date = curatorLogicalDate(),
|
||||
options: CuratorSprintReportBuildOptions = {},
|
||||
): Promise<CuratorSprintReport> {
|
||||
const sprintState = await loadSprintState(userId, date);
|
||||
@@ -1296,7 +1648,7 @@ export async function buildCuratorSprintReport(
|
||||
}
|
||||
|
||||
export async function buildServiceCurationPreview(input: ServiceCurationPreviewInput) {
|
||||
const startDate = input.startDate ?? todayIso();
|
||||
const startDate = input.startDate ?? curatorLogicalDate();
|
||||
const inferredIcpId = input.icpId ?? await inferIcpVariant(input.userId);
|
||||
const templateSet = templateSetFor(inferredIcpId);
|
||||
const userContext = {
|
||||
@@ -1310,7 +1662,7 @@ export async function buildServiceCurationPreview(input: ServiceCurationPreviewI
|
||||
return {
|
||||
...planDay,
|
||||
date,
|
||||
tasks: buildTasksForPlanDay(startDate, date, planDay, completionRows, [], date, FULL_SPRINT_DURATION_DAYS, userContext.targetRole ?? undefined),
|
||||
tasks: buildTasksForPlanDay(startDate, date, planDay, completionRows, [], date, FULL_SPRINT_DURATION_DAYS, userContext as CuratorUserContext),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -1352,5 +1704,5 @@ export async function listCuratorRegistryCapabilities() {
|
||||
}
|
||||
|
||||
export function todayIsoDate() {
|
||||
return todayIso();
|
||||
return curatorLogicalDate();
|
||||
}
|
||||
|
||||
@@ -120,6 +120,13 @@ export const curatorPlanSchema = z.object({
|
||||
goals: z.array(z.string()),
|
||||
generatedAt: z.string(),
|
||||
durationDays: z.union([z.literal(2), z.literal(7)]),
|
||||
access: z.enum(["trial", "full"]),
|
||||
icpId: z.string(),
|
||||
planFingerprint: z.string(),
|
||||
taskPlan: z.array(z.object({
|
||||
dayIndex: z.number().int().min(1),
|
||||
tasks: z.array(z.object({ id: z.string(), title: z.string() })),
|
||||
})),
|
||||
weeks: z.array(curatorWeekSchema).min(1).max(1),
|
||||
days: z.array(curatorPlanDaySchema).min(2).max(7),
|
||||
streak: curatorStreakSchema,
|
||||
|
||||
@@ -2,7 +2,8 @@ import { and, desc, eq } from "drizzle-orm";
|
||||
import { db } from "../../db/client.js";
|
||||
import { growEvents, onboarding } from "../../db/schema.js";
|
||||
import { asRecord, getString } from "../../events/envelope.js";
|
||||
import { DEFAULT_QSCORE_ORG_ID, getQscoreFromService } from "../../services/qscore-proxy.js";
|
||||
import { DEFAULT_QSCORE_ORG_ID, getQscoreFromService, getQscoreLatestSignalsFromService } from "../../services/qscore-proxy.js";
|
||||
import { config } from "../../config.js";
|
||||
import type { CuratorTask } from "./curator-types.js";
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
@@ -26,6 +27,10 @@ export type CuratorUserContext = {
|
||||
latestSummary?: string;
|
||||
skills: string[];
|
||||
};
|
||||
profile: {
|
||||
resumeAvailable: boolean;
|
||||
linkedinAvailable: boolean;
|
||||
};
|
||||
goals: string[];
|
||||
pastActivity: {
|
||||
recentEventCount: number;
|
||||
@@ -33,7 +38,7 @@ export type CuratorUserContext = {
|
||||
latestEvents: Array<{ type: string; source: string; occurredAt: string; summary?: string }>;
|
||||
};
|
||||
qscore: {
|
||||
score: number | null;
|
||||
rq_score: number | null;
|
||||
signalCount: number;
|
||||
summary: string | null;
|
||||
dimensions: Record<string, unknown> | null;
|
||||
@@ -173,9 +178,13 @@ export async function buildCuratorUserContext(userId: string): Promise<CuratorUs
|
||||
.orderBy(desc(growEvents.occurredAt))
|
||||
.limit(80);
|
||||
|
||||
const qscoreResult = await getQscoreFromService(userId, DEFAULT_QSCORE_ORG_ID);
|
||||
const [qscoreResult, qscoreEvidence, resumeState, socialState] = await Promise.all([
|
||||
getQscoreFromService(userId, DEFAULT_QSCORE_ORG_ID),
|
||||
getQscoreLatestSignalsFromService(userId, DEFAULT_QSCORE_ORG_ID),
|
||||
fetchProfileState(config.resumeServiceUrl, userId),
|
||||
fetchProfileState(config.socialBrandingServiceUrl, userId),
|
||||
]);
|
||||
const breakdown = qscoreResult?.breakdown ?? {};
|
||||
const breakdownSignals = Array.isArray(breakdown.signals) ? breakdown.signals : [];
|
||||
|
||||
const targetRole = fallbackCuratorRole(await resolveCuratorTargetRole({ userId })) ?? null;
|
||||
const corpus = rows.map((row) => `${row.type} ${row.source} ${payloadText(row.payload)}`).join(" ").toLowerCase();
|
||||
@@ -184,19 +193,25 @@ export async function buildCuratorUserContext(userId: string): Promise<CuratorUs
|
||||
...rows.flatMap((row) => goalsFromPayload(row.payload)),
|
||||
]);
|
||||
const skills = uniqueStrings(rows.flatMap((row) => stringArray(asRecord(row.payload).skills))).slice(0, 12);
|
||||
const latestResume = rows
|
||||
const latestResume = getString(resumeState.resume_summary ?? resumeState.resumeSummary) ?? rows
|
||||
.map((row) => resumeSummaryFromPayload(row.payload))
|
||||
.find(Boolean);
|
||||
const resumeAvailable = Number(resumeState.resume_count ?? 0) > 0 || Boolean(latestResume);
|
||||
const linkedinAvailable = Boolean(
|
||||
socialState.linkedin_connected ||
|
||||
getString(socialState.linkedin_profile_id ?? socialState.profile_url ?? socialState.headline ?? socialState.summary),
|
||||
);
|
||||
|
||||
return {
|
||||
userId,
|
||||
targetRole,
|
||||
experienceLevel: experienceLevelFromOnboarding(onboardingPayload) ?? inferExperienceLevel(corpus),
|
||||
resume: {
|
||||
available: Boolean(latestResume || /\bresume|cv|linkedin\b/i.test(corpus)),
|
||||
available: resumeAvailable,
|
||||
latestSummary: latestResume,
|
||||
skills,
|
||||
},
|
||||
profile: { resumeAvailable, linkedinAvailable },
|
||||
goals: goals.length ? goals.slice(0, 8) : targetRole ? [targetRole] : [],
|
||||
pastActivity: {
|
||||
recentEventCount: rows.length,
|
||||
@@ -209,14 +224,29 @@ export async function buildCuratorUserContext(userId: string): Promise<CuratorUs
|
||||
})),
|
||||
},
|
||||
qscore: {
|
||||
score: qscoreResult?.q_score ?? null,
|
||||
signalCount: typeof breakdown.signalCount === "number" ? breakdown.signalCount : breakdownSignals.length,
|
||||
rq_score: qscoreResult?.rq_score ?? null,
|
||||
signalCount: qscoreEvidence?.length ?? 0,
|
||||
summary: typeof breakdown.summary === "string" ? breakdown.summary : null,
|
||||
dimensions: isRecord(breakdown.dimensions) ? breakdown.dimensions : (qscoreResult ? qscoreResult.quotients : null),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchProfileState(baseUrl: string, userId: string): Promise<Record<string, unknown>> {
|
||||
if (!baseUrl) return {};
|
||||
try {
|
||||
const response = await fetch(new URL(`/api/state/${encodeURIComponent(userId)}`, baseUrl), {
|
||||
headers: config.a2aAllowedKey ? { authorization: `Bearer ${config.a2aAllowedKey}` } : undefined,
|
||||
signal: AbortSignal.timeout(2_500),
|
||||
});
|
||||
if (!response.ok) return {};
|
||||
const value: unknown = await response.json();
|
||||
return isRecord(value) ? value : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function experienceLevelFromOnboarding(payload: Record<string, unknown>): CuratorUserContext["experienceLevel"] | null {
|
||||
const icp = getString(payload.onboarding_icp);
|
||||
if (icp === "student") return "student";
|
||||
|
||||
@@ -63,7 +63,7 @@ export const CURATOR_ICP_PLAYBOOKS: Record<CuratorIcpId, CuratorIcpPlaybook> = {
|
||||
goal: "Improve callback conversion, sharpen proof, and build stronger interview confidence across the sprint.",
|
||||
stageLabels: ["Baseline + First Proof", "Fix Obvious Gaps", "Build Proof Momentum", "Market-Ready Practice", "Closeout + Next Sprint"],
|
||||
serviceActions: [
|
||||
play("measurement", "qscore-service", "readiness baseline", "Anchor the sprint in current QScore and missing signals.", "analytics", ["qscore", "readiness"]),
|
||||
play("measurement", "qscore-service", "readiness baseline", "Anchor the sprint in the current RQ Score and missing signals.", "analytics", ["qscore", "readiness"]),
|
||||
play("proof", "resume-service", "role-fit proof", "Tailor resume proof to target roles and outcomes.", "resume workspace", ["resume proof", "role fit"]),
|
||||
play("proof", "social-branding-service", "credibility signal", "Create visible credibility updates from real work.", "social profile flow", ["credibility", "visibility"]),
|
||||
play("practice", "interview-service", "callback conversion", "Run focused interview reps for weak question types.", "interview preview", ["interview practice", "callback"]),
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user