Compare commits

...

81 Commits

Author SHA1 Message Date
Sai-karthik
ea8f4c3df3 surface matchmaking agent failures 2026-07-17 07:50:25 +00:00
Sai-karthik
6d2eb6e22a fix matchmaking gateway failures 2026-07-17 07:40:07 +00:00
-Puter
35e29316df fix artifact mime parsing build 2026-07-16 13:25:39 +05:30
-Puter
5a519f6b52 fix artifact download response contract 2026-07-16 13:19:06 +05:30
-Puter
794fe29d8d add interview and roleplay parity adapters 2026-07-16 11:52:00 +05:30
-Puter
0bade06255 fix service gateway deadlines and roleplay controls 2026-07-16 10:00:45 +05:30
ce5be53b96 Merge pull request 'feat: expose live Career Report data contract' (#20) from feat/career-report-data into staging 2026-07-15 20:07:07 +00:00
Sai-karthik
fcbb93ca6a fix: distinguish missing career display contract 2026-07-15 19:40:50 +00:00
Sai-karthik
461b893fdc fix: harden career report producer contracts 2026-07-15 19:37:44 +00:00
Sai-karthik
712a4e57f3 feat: expose live career report data 2026-07-15 19:27:02 +00:00
d857b60111 Merge pull request 'feat: integrate live Home Insight snapshots' (#19) from feat/home-insight-live-aggregate into staging 2026-07-15 17:57:08 +00:00
Sai-karthik
171495f1e1 fix: preserve insight producer outages 2026-07-15 17:30:47 +00:00
Sai-karthik
7caf05f4f5 feat: expose producer-owned audience reach 2026-07-15 17:23:56 +00:00
Sai-karthik
15eb7e033c feat: aggregate live home insight snapshots 2026-07-15 17:22:02 +00:00
-Puter
63c9c18494 fix(roleplay): forward curator orchestration 2026-07-15 16:30:39 +05:30
-Puter
d4332f9f8b fix(curator): preserve active sprint on reads 2026-07-15 16:07:46 +05:30
-Puter
e8c6665ca0 fix(curator): gate profile and day progression 2026-07-15 15:36:15 +05:30
-Puter
d6f583f238 fix(roleplay): hydrate qscore with canonical identity 2026-07-14 22:41:32 +05:30
-Puter
d2353273d5 fix(interview): dedupe streak sessions by session id 2026-07-14 21:20:57 +05:30
-Puter
13ca12588b fix(websocket): project session ownership before tickets 2026-07-14 20:47:44 +05:30
6d2379ce61 Merge pull request 'Publish raw GrowEvents to RQ Score without backend evaluation' (#18) from refactor/qscore-single-owner-volatility into staging 2026-07-14 14:13:46 +00:00
-Puter
27a65dd218 fix: canonicalize course start events 2026-07-14 19:14:27 +05:30
-Puter
29c3db33d4 fix: retain enriched event correlation for qscore 2026-07-14 18:43:46 +05:30
-Puter
4e7ed3c280 fix: use strict rq_score in roleplay contract 2026-07-14 18:15:16 +05:30
-Puter
453d9b5191 fix: preserve raw event correlation and merged migrations 2026-07-14 18:13:18 +05:30
-Puter
bbeaae5684 Merge commit 'd4090f560763af57875f8a37696ad2597a45c2d9' into review/pr18
# Conflicts:
#	drizzle/meta/_journal.json
2026-07-14 18:07:44 +05:30
-Puter
d4090f5607 fix(curator): stabilize two-day trial progression 2026-07-14 17:11:57 +05:30
sai karthik
1d37456084 docs: finish RQ Score agent rename 2026-07-14 15:32:42 +05:30
sai karthik
340557dd56 test: enforce RQ Score API contract 2026-07-14 15:31:59 +05:30
sai karthik
d8b11edcb6 feat: expose RQ Score contract 2026-07-14 15:30:29 +05:30
sai karthik
34920f6a09 Merge staging into QScore raw-event refactor 2026-07-14 14:20:00 +05:30
-Puter
7b35d1e0c5 fix(curator): enforce correlated task completion 2026-07-14 10:20:29 +05:30
-Puter
41f9f0dfec fix(events): correlate service completions to curator tasks 2026-07-14 09:55:52 +05:30
-Puter
8d43bea58e fix(resume): proxy empty success responses 2026-07-14 02:22:32 +05:30
Sai-karthik
c1d2acd96d refactor: deliver raw GrowEvents to QScore 2026-07-13 20:14:14 +00:00
-Puter
a2292dd155 fix(roleplay): register resumed sessions for tickets 2026-07-14 01:38:40 +05:30
-Puter
4985605638 fix(roleplay): expose saved draft controls 2026-07-14 01:25:25 +05:30
-Puter
626684ea7e fix: forward user identity to resume service 2026-07-13 22:49:59 +05:30
-Puter
5eaf4ae1ea fix backend service user identity forwarding 2026-07-13 21:19:55 +05:30
-Puter
a56172c7ac fix: enforce websocket ticket session ownership 2026-07-13 20:20:12 +05:30
-Puter
eafca3aafb feat: issue scoped websocket session tickets 2026-07-13 20:19:11 +05:30
-Puter
8c0f8e1d57 Make Course A2A gateway transport-only 2026-07-13 20:13:57 +05:30
-Puter
4c59151e37 Avoid duplicate Course scoring events 2026-07-13 20:13:53 +05:30
-Puter
a3c4a91cbe Project Course swipe gateway events 2026-07-13 20:13:53 +05:30
-Puter
4fa4a8bbd1 test: remove tautological service token assertion 2026-07-13 20:12:59 +05:30
-Puter
d71b7ede08 test: cover service token config resolution 2026-07-13 20:12:59 +05:30
-Puter
1f778fe3ea Prevent Course gateway score double counting 2026-07-13 20:12:59 +05:30
-Puter
fc06317832 fix(auth): remove compose service token fallback 2026-07-13 20:12:59 +05:30
-Puter
a1e05b0337 Add focused Course and Matchmaking route tests 2026-07-13 20:12:59 +05:30
-Puter
7a0c762fde Add Course A2A gateway and prevent matchmaking duplicates 2026-07-13 20:12:59 +05:30
-Puter
70c3634178 Remove backend dev A2A auth fallback 2026-07-13 20:12:59 +05:30
-Puter
26e0e54488 fix(staging): pin verified models and resume route 2026-07-13 18:04:49 +05:30
38939c2fd0 Merge curator task handoff fixes into staging 2026-07-13 10:11:42 +00:00
Sai-karthik
54b546bba3 Fix compressed resume proxy responses 2026-07-13 10:11:27 +00:00
Sai-karthik
3a7a6c46a8 Cover task-aware curator handoffs 2026-07-13 10:11:27 +00:00
Sai-karthik
25d92f67be Preserve task details on unavailable handoffs 2026-07-13 10:11:27 +00:00
Sai-karthik
51c9eb0167 Keep presentation handoffs task-focused 2026-07-13 10:11:27 +00:00
Sai-karthik
59482d4747 Expose profile asset state in Day 1 tasks 2026-07-13 10:11:27 +00:00
Sai-karthik
6b3c6d0b0c Remove invented interview role fallback 2026-07-13 10:11:27 +00:00
Sai-karthik
301dba8c50 Fix curator task handoffs and profile state 2026-07-13 10:11:27 +00:00
Sai-karthik
e444d1be15 Fix compressed resume proxy responses 2026-07-13 06:52:59 +00:00
Sai-karthik
fdf4f6468d Cover task-aware curator handoffs 2026-07-13 06:10:15 +00:00
Sai-karthik
8617be7e7c Preserve task details on unavailable handoffs 2026-07-13 01:07:43 +00:00
Sai-karthik
8cb5413766 Keep presentation handoffs task-focused 2026-07-13 00:21:57 +00:00
Sai-karthik
2410a4800b Expose profile asset state in Day 1 tasks 2026-07-13 00:10:38 +00:00
Sai-karthik
f848f45429 Remove invented interview role fallback 2026-07-12 23:51:44 +00:00
-Puter
9cb1027f4c fix(staging): make compose guard host-portable 2026-07-13 05:12:20 +05:30
-Puter
d2e3f49519 chore(staging): guard backend compose topology 2026-07-13 05:10:54 +05:30
-Puter
80884fc0b6 fix(staging): pin canonical service networks 2026-07-13 05:04:37 +05:30
Sai-karthik
5f5622b3a0 Fix curator task handoffs and profile state 2026-07-12 23:21:52 +00:00
-Puter
b93cc81d93 fix curator sprint to use canonical onboarding 2026-07-13 04:28:27 +05:30
-Puter
982942fa6e fix onboarding GET service auth seam 2026-07-13 04:15:01 +05:30
-Puter
fdc320b995 fix onboarding GET service network and rev10 serialization 2026-07-13 03:59:13 +05:30
-Puter
4b652358d3 fix curator sprint ICP reconciliation 2026-07-13 03:43:37 +05:30
-Puter
c7c6f1b8cc fix curator sprint ICP reconciliation 2026-07-13 03:31:02 +05:30
-Puter
e7546142b6 fix curator sprint access choice reconciliation 2026-07-13 02:39:29 +05:30
-Puter
78860ecf5e fix(staging): point Social service URL to AWS Social via gqr-staging tunnel (18015)
Live root cause: dashboard gateway reached old developer FastAPI at
social.sai-onchain.me and 404'd on the LinkedIn onboarding route.
The gqr-staging reverse SSH tunnel now exposes AWS Social on host
port 18015 (verified: healthz=200, POST route 422).

- SOCIAL_BRANDING_SERVICE_URL: http://host.docker.internal:18015 (internal tunnel)
- SOCIAL_BRANDING_PUBLIC_URL: https://social-staging.gqr.puter.wtf (public staging)
2026-07-12 21:59:31 +05:30
-Puter
e50be827ca fix(scripts): repair onboarding-bulk-reset with A2A user-service endpoint
Rewire Phase 1 to call the user-service POST /api/v1/users/onboarding-reset A2A
endpoint (targeting by clerk_id) instead of the per-user authenticated DELETE
route, which could not reach all users. A 404 from user-service means the user
has no profile (backend-only) — a clean skip that does not block Phase 2.

Phase 2 (backend ledger reset via resetOnboardingLedger) now runs ONLY after
Phase 1 succeeds or is skipped; a non-404 pref error blocks the ledger for that
user to avoid inconsistent state. Triple guard (NODE_ENV=staging,
ONBOARDING_BULK_RESET_ALLOWED=true, --confirm) and aggregate-only output are
preserved. Add source-structure assertions for the CLI contract.
2026-07-12 20:50:17 +05:30
-Puter
c54e12972c fix(onboarding): synthetic PATCH /me stale content-length socket close
patchPreferencesRequest forwarded inbound headers (including
content-length from the browser PATCH body) but set a DIFFERENT body
({ preferences }). The Request constructor does NOT recompute
content-length when an explicit body is provided alongside forwarded
headers, so user-service waited for bytes that never arrived and closed
the socket (~2.8s 'other side closed') → uncaught → 500.

Delete content-length and transfer-encoding before constructing the
synthetic request so undici recomputes them from the actual body. Apply
the same defensive deletes in fetchUserProfile.

Extract patchPreferencesRequest into the DB-free
src/services/user-profile.ts module (alongside fetchUserProfile) so the
regression test imports the REAL function and asserts content-length /
transfer-encoding / host / cookie are stripped and the body is the
synthetic { preferences } blob. tsc clean; existing onboarding tests
pass.
2026-07-12 20:29:40 +05:30
-Puter
ad1318424b fix(onboarding): PATCH /onboarding 500 — fetchUserProfile must GET /me
PATCH /onboarding read its JSON body via c.req.json() (consuming
c.req.raw), then passed c.req.raw to fetchUserProfile → fetchUserService,
which called req.arrayBuffer() on the already-consumed body:
TypeError: Body is unusable. The global onError caught it as a 500
{"error":"internal"}, so every access-choice save failed. GET
/onboarding worked only because it never reads a body.

fetchUserProfile is a read; it must never replay the inbound method or
body. Extract it (+ userServiceTarget) into a DB-free module that issues
an explicit GET /me with forwarded headers, sidestepping body reuse
entirely and fixing a latent wrong-method bug (PATCH /onboarding would
have issued a PATCH /me write for a read).

Verified against staging logs (TypeError stack at fetchUserService →
fetchUserProfile → PATCH handler). Focused regression test consumes the
body first and asserts GET + no body re-read + profile parse; tsc clean.
2026-07-12 20:14:22 +05:30
-Puter
62208d385f feat(onboarding): persist resettable user state 2026-07-12 19:23:29 +05:30
107 changed files with 7163 additions and 1759 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -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`.

View File

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

View File

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

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

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

View File

@@ -0,0 +1,2 @@
ALTER TABLE "qscore_snapshots"
RENAME COLUMN "score" TO "rq_score";

View File

@@ -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
}
]
}

View File

@@ -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": {

View File

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

View File

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

View File

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

View File

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

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

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

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

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

View 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]!,
}));
}

View File

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

View File

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

View File

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

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

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

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

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

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

View File

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

View File

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

View File

@@ -0,0 +1,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");

View File

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

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

@@ -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"] },
];
}
}

View File

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

View File

@@ -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)}`);
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -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),
}),
);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

@@ -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 }[];
};

View File

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

View File

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

View File

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

View File

@@ -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 todays 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".

View File

@@ -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" } },
};
}

View File

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

View File

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

View File

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

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

View File

@@ -21,8 +21,8 @@ export class ProductServiceError extends Error {
}
}
const DEFAULT_SERVICE_TIMEOUT_MS = Number(process.env.PRODUCT_SERVICE_TIMEOUT_MS ?? 3500);
const INTERACTIVE_SERVICE_TIMEOUT_MS = Number(process.env.PRODUCT_INTERACTIVE_SERVICE_TIMEOUT_MS ?? 120000);
export const DEFAULT_SERVICE_TIMEOUT_MS = Number(process.env.PRODUCT_SERVICE_TIMEOUT_MS ?? 3500);
export const INTERACTIVE_SERVICE_TIMEOUT_MS = Number(process.env.PRODUCT_INTERACTIVE_SERVICE_TIMEOUT_MS ?? 240000);
function userHeader(userId?: string): Record<string, string> | undefined {
return userId ? { "x-growqr-user": userId } : undefined;
@@ -60,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) }),
};

View File

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

View File

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

View File

@@ -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",

View File

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

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

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

View File

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

View File

@@ -7,7 +7,22 @@ import { growEvents } from "../../db/schema.js";
import { recordGrowEvent } from "../../events/record-grow-event.js";
import { routeGrowEventToUserActor } from "../../events/route-to-user-actor.js";
import { v1AnalyticsActor } from "./analytics-actor.js";
import { DEFAULT_QSCORE_ORG_ID, getQscoreFromService } 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)));

View File

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

View File

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

View File

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

View File

@@ -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()}`;
}

View File

@@ -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}`,
},

View File

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

View File

@@ -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 todays 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();
}

View File

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

View File

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

View File

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