Compare commits
16 Commits
fix/curato
...
staging
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
26e0e54488 | ||
| 38939c2fd0 | |||
|
|
54b546bba3 | ||
|
|
3a7a6c46a8 | ||
|
|
25d92f67be | ||
|
|
51c9eb0167 | ||
|
|
59482d4747 | ||
|
|
6b3c6d0b0c | ||
|
|
301dba8c50 | ||
|
|
9cb1027f4c | ||
|
|
d2e3f49519 | ||
|
|
80884fc0b6 | ||
|
|
b93cc81d93 | ||
|
|
982942fa6e | ||
|
|
fdc320b995 | ||
|
|
4b652358d3 |
@@ -16,15 +16,16 @@ services:
|
||||
user-service-staging:
|
||||
environment:
|
||||
DATABASE_URL: postgres://growqr:growqr@growqr-postgres:5432/growqr
|
||||
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
|
||||
|
||||
@@ -10,6 +10,9 @@
|
||||
"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:onboarding-read": "tsx scripts/onboarding-rev10-read.test.ts",
|
||||
"test:user-profile": "tsx scripts/user-profile.test.ts",
|
||||
"start": "node dist/index.js",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||
"workflows:smoke": "tsx src/workflows/smoke-test.ts",
|
||||
@@ -20,8 +23,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": {
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import {
|
||||
curatorSprintPlanFingerprint,
|
||||
curatorSprintTaskPlan,
|
||||
decideCuratorSprintReconciliation,
|
||||
resolveCuratorSprintAccess,
|
||||
shouldReconcileCuratorSprint,
|
||||
} 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");
|
||||
@@ -44,4 +47,31 @@ const unauthorizedFull = resolveCuratorSprintAccess({ plan: "free", accessChoice
|
||||
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.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");
|
||||
|
||||
148
scripts/curator-persisted-reconcile.test.ts
Normal file
148
scripts/curator-persisted-reconcile.test.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
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}`,
|
||||
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]!,
|
||||
}));
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
47
scripts/fixtures/onboarding-rev10.json
Normal file
47
scripts/fixtures/onboarding-rev10.json
Normal file
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"onboarding": {
|
||||
"import": {
|
||||
"method": "manual",
|
||||
"status": "not_started",
|
||||
"resume_id": null,
|
||||
"linkedin_url": null,
|
||||
"resume_summary": null,
|
||||
"resume_filename": null,
|
||||
"linkedin_profile_id": null
|
||||
},
|
||||
"status": "completed",
|
||||
"consent": {
|
||||
"accepted_at": "2026-07-12T21:31:40.189Z",
|
||||
"terms_version": "2026-07",
|
||||
"privacy_accepted": true
|
||||
},
|
||||
"profile": {
|
||||
"icp": "experienced",
|
||||
"mode": "individual",
|
||||
"intent": "grow",
|
||||
"question_branch": "professional"
|
||||
},
|
||||
"progress": {
|
||||
"stage": "reveal",
|
||||
"updated_at": "2026-07-12T21:33:01.914Z",
|
||||
"branch_index": 4
|
||||
},
|
||||
"revision": 10,
|
||||
"responses": {
|
||||
"target_role": "Senior Software Engineer",
|
||||
"target_field": "technical",
|
||||
"work_context": "clients",
|
||||
"career_barriers": ["offers"],
|
||||
"desired_outcomes": ["role"],
|
||||
"experience_level": "5+",
|
||||
"target_milestone": null,
|
||||
"venture_industry": null,
|
||||
"current_situation": "employed",
|
||||
"weekly_time_commitment": "steady"
|
||||
},
|
||||
"qx_estimate": 100,
|
||||
"completed_at": "2026-07-12T21:31:42.957Z",
|
||||
"access_choice": "trial",
|
||||
"schema_version": 3
|
||||
}
|
||||
}
|
||||
94
scripts/onboarding-rev10-read.test.ts
Normal file
94
scripts/onboarding-rev10-read.test.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { serializeOnboardingState } from "../src/services/onboarding-serialization.js";
|
||||
|
||||
type StoredFixture = { onboarding: Record<string, unknown> };
|
||||
const fixturePath = fileURLToPath(new URL("./fixtures/onboarding-rev10.json", import.meta.url));
|
||||
const fixture = JSON.parse(readFileSync(fixturePath, "utf8")) as StoredFixture;
|
||||
const response = serializeOnboardingState("user_3EX1LG3gBk3KY6kfD9PiTkWubs4", fixture.onboarding);
|
||||
|
||||
assert.equal(response.id, "user_3EX1LG3gBk3KY6kfD9PiTkWubs4");
|
||||
assert.equal(response.revision, 10);
|
||||
assert.equal(response.data.revision, 10);
|
||||
assert.equal(response.data.status, "completed");
|
||||
assert.equal(response.data.profile.icp, "experienced");
|
||||
assert.equal(response.data.access_choice, "trial");
|
||||
assert.equal(response.data.completed_at, "2026-07-12T21:31:42.957Z");
|
||||
assert.equal(response.updatedAt, "2026-07-12T21:33:01.914Z");
|
||||
assert.deepEqual(response.payload, {
|
||||
schema_version: 1,
|
||||
source_revision: 10,
|
||||
primary_intent: "grow",
|
||||
mode: "individual",
|
||||
question_branch: "professional",
|
||||
onboarding_icp: "experienced",
|
||||
curator_registry_icp: null,
|
||||
target_role: "Senior Software Engineer",
|
||||
target_field: "technical",
|
||||
experience_context: "5+ · clients",
|
||||
goals: ["role"],
|
||||
barriers: ["offers"],
|
||||
priority: "role",
|
||||
weekly_time_commitment: "steady",
|
||||
});
|
||||
|
||||
const serialized = JSON.parse(JSON.stringify(response));
|
||||
assert.deepEqual(serialized, response, "GET response must be JSON serializable without losing rev10 state");
|
||||
|
||||
const compose = readFileSync(fileURLToPath(new URL("../docker-compose.override.yml", import.meta.url)), "utf8");
|
||||
assert.match(
|
||||
compose,
|
||||
/user-service-staging:\s+external:\s+true\s+name:\s+user-service-staging_default/s,
|
||||
"backend must join the canonical user-service staging network",
|
||||
);
|
||||
assert.doesNotMatch(compose, /name:\s+growqr-app_growqr-net/, "obsolete substitute user network must not be configured");
|
||||
assert.match(compose, /RESUME_SERVICE_URL:\s+http:\/\/growqr_resume_api:8000/, "backend must route Resume through canonical Docker DNS");
|
||||
for (const modelKey of ["LLM_MODEL", "GROW_AGENT_MODEL", "CONVERSATION_ACTOR_MODEL"]) {
|
||||
assert.match(compose, new RegExp(`${modelKey}:\\s+kimi-k2\\.6`), `${modelKey} must stay on the verified staging model`);
|
||||
}
|
||||
assert.doesNotMatch(compose, /minimax-m3/, "staging compose must not restore the broken MiniMax model pin");
|
||||
|
||||
const canonicalServiceNetworks = [
|
||||
["interview-service", "interview-service_default"],
|
||||
["roleplay-service", "roleplay-service_default"],
|
||||
["resume-builder-staging", "resume-builder-staging_default"],
|
||||
["course-service", "course-service_default"],
|
||||
["qscore-service-staging", "qscore-service-staging_default"],
|
||||
["matchmaking-v2-staging", "matchmaking-v2_default"],
|
||||
["user-service-staging", "user-service-staging_default"],
|
||||
] as const;
|
||||
|
||||
for (const [key, name] of canonicalServiceNetworks) {
|
||||
assert.match(
|
||||
compose,
|
||||
new RegExp(`${key}:\\s+external:\\s+true\\s+name:\\s+${name.replace(/[.*+?^${}()|[\\]\\]/g, "\\$&")}`, "s"),
|
||||
`backend must join canonical network ${name}`,
|
||||
);
|
||||
}
|
||||
|
||||
for (const obsoleteName of [
|
||||
"resume-builder_default",
|
||||
"courses_service_default",
|
||||
"qscore-service_default",
|
||||
"matchmaking-service_default",
|
||||
"growqr-app_growqr-net",
|
||||
]) {
|
||||
assert.doesNotMatch(compose, new RegExp(`name:\\s+${obsoleteName}`), `obsolete substitute network ${obsoleteName} must not be configured`);
|
||||
}
|
||||
|
||||
|
||||
const stagingCompose = readFileSync(fileURLToPath(new URL("./staging-compose.sh", import.meta.url)), "utf8");
|
||||
assert.match(stagingCompose, /PROJECT=growqr-backend-staging/, "staging wrapper must pin the compose project name");
|
||||
for (const [, name] of canonicalServiceNetworks) {
|
||||
assert.match(stagingCompose, new RegExp(name.replace(/[.*+?^${}()|[\\]\\]/g, "\\$&")), `staging wrapper must verify ${name}`);
|
||||
}
|
||||
|
||||
const packageJson = JSON.parse(readFileSync(fileURLToPath(new URL("../package.json", import.meta.url)), "utf8")) as {
|
||||
scripts?: Record<string, string>;
|
||||
};
|
||||
assert.equal(packageJson.scripts?.["compose:up"], "scripts/staging-compose.sh up");
|
||||
assert.equal(packageJson.scripts?.["compose:down"], "scripts/staging-compose.sh down");
|
||||
assert.equal(packageJson.scripts?.["staging:preflight"], "scripts/staging-compose.sh preflight");
|
||||
assert.equal(packageJson.scripts?.["staging:verify"], "scripts/staging-compose.sh verify");
|
||||
console.log("onboarding-rev10-read: fixture serialization and staging network assertions passed");
|
||||
64
scripts/staging-compose.sh
Executable file
64
scripts/staging-compose.sh
Executable file
@@ -0,0 +1,64 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
PROJECT=growqr-backend-staging
|
||||
COMPOSE="docker compose -p $PROJECT -f docker-compose.yml -f docker-compose.override.yml"
|
||||
REQUIRED_NETWORKS="growqr-backend-staging_default interview-service_default roleplay-service_default resume-builder-staging_default course-service_default qscore-service-staging_default matchmaking-v2_default user-service-staging_default"
|
||||
EXTERNAL_NETWORKS="interview-service_default roleplay-service_default resume-builder-staging_default course-service_default qscore-service-staging_default matchmaking-v2_default user-service-staging_default"
|
||||
|
||||
preflight() {
|
||||
for network in $EXTERNAL_NETWORKS; do
|
||||
docker network inspect "$network" >/dev/null
|
||||
done
|
||||
$COMPOSE config --quiet
|
||||
}
|
||||
|
||||
verify() {
|
||||
actual=$(docker inspect growqr-backend --format '{{range $name, $_ := .NetworkSettings.Networks}}{{$name}} {{end}}')
|
||||
for network in $REQUIRED_NETWORKS; do
|
||||
case " $actual " in
|
||||
*" $network "*) ;;
|
||||
*) echo "missing backend network: $network" >&2; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
for network in $actual; do
|
||||
case " $REQUIRED_NETWORKS " in
|
||||
*" $network "*) ;;
|
||||
*) echo "unexpected backend network: $network" >&2; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
docker exec growqr-backend node --input-type=module -e '
|
||||
const checks = [
|
||||
["user-service", "http://growqr_user_service:8003/health"],
|
||||
["qscore", "http://qscore-service-staging-api-1:8000/health"],
|
||||
["course", "http://growqr-course-service:8010/health"],
|
||||
["resume", "http://growqr_resume_api:8000/health"],
|
||||
["interview", "http://interview-service-api-1:8000/health"],
|
||||
["roleplay", "http://roleplay-service-api-1:8000/health"],
|
||||
];
|
||||
let failed = false;
|
||||
for (const [name, url] of checks) {
|
||||
try {
|
||||
const response = await fetch(url, { signal: AbortSignal.timeout(3000) });
|
||||
console.log(name, response.status);
|
||||
if (response.status !== 200) failed = true;
|
||||
} catch (error) {
|
||||
console.error(name, "ERROR", error.message);
|
||||
failed = true;
|
||||
}
|
||||
}
|
||||
if (failed) process.exit(1);
|
||||
'
|
||||
docker exec growqr-backend node --input-type=module -e "const response=await fetch('http://127.0.0.1:4000/healthz',{signal:AbortSignal.timeout(3000)});console.log('backend',response.status);if(response.status!==200)process.exit(1)"
|
||||
}
|
||||
|
||||
action=${1:-}
|
||||
[ "$#" -gt 0 ] && shift
|
||||
case "$action" in
|
||||
preflight) preflight ;;
|
||||
verify) verify ;;
|
||||
up) preflight; $COMPOSE up -d "$@"; verify ;;
|
||||
down) $COMPOSE down "$@" ;;
|
||||
*) echo "Usage: scripts/staging-compose.sh <preflight|verify|up|down> [compose args]" >&2; exit 2 ;;
|
||||
esac
|
||||
@@ -1,5 +1,6 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { fetchUserProfile, patchPreferencesRequest } from "../src/services/user-profile.js";
|
||||
import { config } from "../src/config.js";
|
||||
import { fetchOnboardingReadProfile, fetchUserProfile, patchPreferencesRequest } from "../src/services/user-profile.js";
|
||||
|
||||
/**
|
||||
* Regression test for the PATCH /onboarding 500.
|
||||
@@ -162,4 +163,124 @@ async function runFetchUserProfileAfterBodyConsumed(): Promise<{
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
console.log("user-profile: all assertions passed");
|
||||
|
||||
@@ -396,6 +396,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 +430,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 +448,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();
|
||||
@@ -461,7 +477,7 @@ async function proxyResumeRequest(req: Request, rest: string, userId: string) {
|
||||
return new Response(responseBuffer, {
|
||||
status: res.status,
|
||||
statusText: res.statusText,
|
||||
headers: res.headers,
|
||||
headers: decodedProxyHeaders(res.headers),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -519,6 +535,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 +553,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 +577,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 +615,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 +637,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;
|
||||
}
|
||||
|
||||
@@ -4,7 +4,8 @@ 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 { fetchUserProfile, patchPreferencesRequest, userServiceTarget } from "../services/user-profile.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";
|
||||
@@ -12,7 +13,6 @@ import {
|
||||
getLatestValidOnboardingLedgerEvent,
|
||||
recordAndProcessOnboardingCompletion,
|
||||
extractOnboardingData,
|
||||
deriveOnboardingPayload,
|
||||
assertOnboardingRevision,
|
||||
mergeOnboardingPatch,
|
||||
resetOnboardingLedger,
|
||||
@@ -123,17 +123,6 @@ function recordPreferences(profile: Record<string, unknown>): Record<string, unk
|
||||
return isPlainObject(profile.preferences) ? profile.preferences : {};
|
||||
}
|
||||
|
||||
/** Build the { id, data, payload, revision, updatedAt } response shape. */
|
||||
function onboardingStateResponse(userId: string, data: OnboardingData) {
|
||||
const updatedAt = data.progress.updated_at ?? data.completed_at;
|
||||
return {
|
||||
id: userId,
|
||||
data,
|
||||
payload: deriveOnboardingPayload(data),
|
||||
revision: data.revision,
|
||||
updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
type OnboardingResetResult =
|
||||
@@ -312,10 +301,9 @@ export function userRoutes() {
|
||||
// side effects when status is "completed".
|
||||
app.get("/onboarding", async (c) => {
|
||||
const userId = c.get("userId");
|
||||
const profile = await fetchUserProfile(c.req.raw);
|
||||
const profile = await fetchOnboardingReadProfile(c.req.raw, userId);
|
||||
const preferences = recordPreferences(profile);
|
||||
const data = extractOnboardingData(preferences.onboarding);
|
||||
return c.json(onboardingStateResponse(userId, data));
|
||||
return c.json(serializeOnboardingState(userId, preferences.onboarding));
|
||||
});
|
||||
|
||||
app.patch("/onboarding", async (c) => {
|
||||
@@ -389,7 +377,7 @@ export function userRoutes() {
|
||||
}
|
||||
}
|
||||
|
||||
return c.json(onboardingStateResponse(userId, nextData));
|
||||
return c.json(serializeOnboardingState(userId, nextPreferences.onboarding));
|
||||
});
|
||||
|
||||
// DELETE /onboarding — authenticated per-user reset. Reopens the onboarding
|
||||
|
||||
18
src/services/onboarding-serialization.ts
Normal file
18
src/services/onboarding-serialization.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import {
|
||||
deriveOnboardingPayload,
|
||||
extractOnboardingData,
|
||||
} from "../events/onboarding-ledger.js";
|
||||
|
||||
/** Build the authenticated GET /users/onboarding response from persisted JSON. */
|
||||
export function serializeOnboardingState(userId: string, stored: unknown) {
|
||||
const data = extractOnboardingData(stored);
|
||||
const payload = deriveOnboardingPayload(data);
|
||||
const updatedAt = data.progress.updated_at ?? data.completed_at;
|
||||
return {
|
||||
id: userId,
|
||||
data,
|
||||
payload,
|
||||
revision: data.revision,
|
||||
updatedAt,
|
||||
};
|
||||
}
|
||||
@@ -947,6 +947,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 +965,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";
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { config } from "../config.js";
|
||||
import { fetchA2AUserStateStrict } from "./user-context.js";
|
||||
|
||||
/**
|
||||
* Build the absolute user-service URL for a given sub-path.
|
||||
@@ -9,20 +10,7 @@ export function userServiceTarget(path: string, search = ""): URL {
|
||||
return new URL(`/api/v1/users${path}${search}`, config.userServiceUrl.replace(/\/$/, ""));
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the user-service profile via an explicit GET /me.
|
||||
*
|
||||
* This is a READ and must never replay the inbound request method/body. The
|
||||
* PATCH /onboarding handler parses its own JSON payload with `c.req.json()`,
|
||||
* which consumes `c.req.raw` (marks bodyUsed). Reusing that Request here would
|
||||
* (a) wrongly issue a PATCH /me write for a read, and (b) call
|
||||
* `arrayBuffer()` on an already-consumed body —
|
||||
* `TypeError: Body is unusable: Body has already been read` — which bubbles
|
||||
* out of the handler as an uncaught 500 `{"error":"internal"}`.
|
||||
*
|
||||
* Building a fresh GET sidesteps both: no body replay, and the inbound
|
||||
* Request's body is never touched.
|
||||
*/
|
||||
/** 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);
|
||||
@@ -31,6 +19,7 @@ export async function fetchUserProfile(req: Request): Promise<Record<string, unk
|
||||
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(() => "");
|
||||
@@ -39,6 +28,17 @@ export async function fetchUserProfile(req: Request): Promise<Record<string, unk
|
||||
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
|
||||
|
||||
@@ -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()}`;
|
||||
}
|
||||
|
||||
@@ -276,23 +276,48 @@ export async function runCuratorOnboardingLoop(input: OnboardingLoopInput): Prom
|
||||
|
||||
const existing = await findExistingReadyEvent(userId);
|
||||
if (existing) {
|
||||
const existingDuration = existing.payload?.durationDays === 7 ? 7 : existing.payload?.durationDays === 2 ? 2 : undefined;
|
||||
if (existingDuration !== sprint.plan.durationDays) {
|
||||
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,
|
||||
previousDurationDays: existingDuration,
|
||||
access: sprint.plan.durationDays === 7 ? "full" : "trial",
|
||||
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_access_choice_changed",
|
||||
reason: "onboarding_plan_changed",
|
||||
},
|
||||
});
|
||||
}
|
||||
return { status: "already_ready", readyEventId: existing.id, sprint };
|
||||
}
|
||||
|
||||
const event = await emitCuratorEvent({
|
||||
@@ -307,11 +332,16 @@ export async function runCuratorOnboardingLoop(input: OnboardingLoopInput): Prom
|
||||
sprintId: sprint.sprintId,
|
||||
planId: sprint.plan.id,
|
||||
durationDays: sprint.plan.durationDays,
|
||||
access: sprint.plan.durationDays === 7 ? "full" : "trial",
|
||||
access: sprint.plan.access,
|
||||
icpId: sprint.plan.icpId,
|
||||
variantId: sprint.plan.icpId,
|
||||
planFingerprint: sprint.plan.planFingerprint,
|
||||
taskPlan: sprint.plan.taskPlan,
|
||||
weekCount: sprint.plan.weeks.length,
|
||||
dayCount: sprint.plan.days.length,
|
||||
activeDayIndex: sprint.activeDayIndex,
|
||||
weeklyThemes: weeklyThemes(sprint),
|
||||
// Keep the future-day visibility contract: this is Day 1 only.
|
||||
dayOneTasks: dayOneTasks(sprint),
|
||||
notificationId: `curator:onboarding-plan-ready:${userId}`,
|
||||
},
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import { Hono } from "hono";
|
||||
import { z } from "zod";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { createClient, type Client } from "rivetkit/client";
|
||||
import { requireUser, type AuthContext } from "../../auth/clerk.js";
|
||||
import { config } from "../../config.js";
|
||||
import { db } from "../../db/client.js";
|
||||
import { onboarding } from "../../db/schema.js";
|
||||
import { fetchOnboardingReadProfile } from "../../services/user-profile.js";
|
||||
import type { Registry } from "../../actors/registry.js";
|
||||
import { buildCuratorSprint, buildCuratorSprintReport, buildCuratorTasks } from "./curator-store.js";
|
||||
|
||||
@@ -38,13 +36,19 @@ export function v1CuratorRoutes() {
|
||||
|
||||
app.get("/sprint", async (c) => {
|
||||
const userId = c.get("userId");
|
||||
const savedOnboarding = await db.query.onboarding.findFirst({
|
||||
where: eq(onboarding.userId, userId),
|
||||
});
|
||||
if (savedOnboarding?.data?.status !== "completed") {
|
||||
const profile = await fetchOnboardingReadProfile(c.req.raw, userId);
|
||||
const preferences = profile.preferences && typeof profile.preferences === "object" && !Array.isArray(profile.preferences)
|
||||
? profile.preferences as Record<string, unknown>
|
||||
: {};
|
||||
const onboarding = preferences.onboarding && typeof preferences.onboarding === "object" && !Array.isArray(preferences.onboarding)
|
||||
? preferences.onboarding as Record<string, unknown>
|
||||
: {};
|
||||
if (onboarding.status !== "completed") {
|
||||
return c.json({ error: "onboarding_incomplete" }, 409);
|
||||
}
|
||||
return c.json(await buildCuratorSprint(userId, c.req.query("date")));
|
||||
return c.json(await buildCuratorSprint(userId, c.req.query("date"), {
|
||||
onboardingContext: { preferences, onboarding },
|
||||
}));
|
||||
});
|
||||
|
||||
app.get("/sprint/report", async (c) => {
|
||||
|
||||
@@ -5,7 +5,7 @@ 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,
|
||||
@@ -75,6 +75,11 @@ export type CuratorSprintAccess = {
|
||||
durationDays: 2 | 7;
|
||||
};
|
||||
|
||||
export type CuratorSprintCompatibility = CuratorSprintAccess & {
|
||||
variantId?: CuratorIcpId;
|
||||
planFingerprint?: string;
|
||||
};
|
||||
|
||||
type CuratorSprintEntitlementInput = {
|
||||
plan?: unknown;
|
||||
accessChoice?: unknown;
|
||||
@@ -96,23 +101,31 @@ export function resolveCuratorSprintAccess(input: CuratorSprintEntitlementInput)
|
||||
}
|
||||
|
||||
export function shouldReconcileCuratorSprint(
|
||||
existing: Pick<CuratorSprintAccess, "access" | "durationDays"> | undefined,
|
||||
desired: CuratorSprintAccess,
|
||||
existing: CuratorSprintCompatibility | undefined,
|
||||
desired: CuratorSprintCompatibility,
|
||||
) {
|
||||
return !existing || existing.access !== desired.access || existing.durationDays !== desired.durationDays;
|
||||
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"; access: "trial" | "full"; durationDays: 2 | 7 }
|
||||
| { action: "invalidateAndRebuild"; access: "trial" | "full"; durationDays: 2 | 7 };
|
||||
| ({ action: "reuse" } & CuratorSprintAccess & Partial<Pick<CuratorSprintCompatibility, "variantId" | "planFingerprint">>)
|
||||
| ({ action: "invalidateAndRebuild" } & CuratorSprintAccess & Partial<Pick<CuratorSprintCompatibility, "variantId" | "planFingerprint">>);
|
||||
|
||||
export function decideCuratorSprintReconciliation(
|
||||
existing: Pick<CuratorSprintAccess, "access" | "durationDays"> | undefined,
|
||||
desired: CuratorSprintAccess,
|
||||
existing: CuratorSprintCompatibility | undefined,
|
||||
desired: CuratorSprintCompatibility,
|
||||
): CuratorSprintReconciliation {
|
||||
return shouldReconcileCuratorSprint(existing, desired)
|
||||
? { action: "invalidateAndRebuild", access: desired.access, durationDays: desired.durationDays }
|
||||
: { action: "reuse", access: desired.access, durationDays: desired.durationDays };
|
||||
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 }),
|
||||
};
|
||||
}
|
||||
|
||||
function accessChoiceFromContext(context: Record<string, unknown> | undefined): "trial" | "full" | undefined {
|
||||
@@ -473,6 +486,47 @@ export function planSeedsForVariant(
|
||||
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";
|
||||
@@ -533,10 +587,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 })
|
||||
@@ -602,14 +707,15 @@ async function loadSprintState(
|
||||
? 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");
|
||||
}
|
||||
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 = accessChoiceFromContext(persistedContext) ?? dbChoice;
|
||||
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({ id: growEvents.id, payload: growEvents.payload })
|
||||
@@ -625,79 +731,107 @@ async function loadSprintState(
|
||||
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" ? latestPayload.variantId : 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 rebaseExistingForCompletion = Boolean(onboardingContextOverride && existingStartDate && existingStartDate !== todayDate);
|
||||
const activeExisting = Boolean(
|
||||
!rebaseExistingForCompletion && existingStartDate && existingVersion === CURATOR_PLAN_VERSION && existingVariant && isCuratorIcpId(existingVariant)
|
||||
&& existingDuration && calendarDiffDays(existingStartDate, todayDate) >= 0 && calendarDiffDays(existingStartDate, todayDate) < existingDuration,
|
||||
!rebaseExistingForCompletion && existingStartDate && existingVersion === CURATOR_PLAN_VERSION && existingVariant
|
||||
&& existingDuration && existingAccess && calendarDiffDays(existingStartDate, todayDate) >= 0 && calendarDiffDays(existingStartDate, todayDate) < existingDuration,
|
||||
);
|
||||
|
||||
let replacementStartDate = todayDate;
|
||||
if (activeExisting && existingStartDate && existingVariant && existingDuration && existingAccess) {
|
||||
const templateSet = templateSetFor(existingVariant as CuratorIcpId);
|
||||
const reconciliation = decideCuratorSprintReconciliation({ access: existingAccess, durationDays: existingDuration }, desired);
|
||||
if (reconciliation.action === "invalidateAndRebuild") {
|
||||
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?.access === desired.access
|
||||
&& row.payload?.durationDays === desired.durationDays
|
||||
));
|
||||
if (!alreadyInvalidated) {
|
||||
await emitCuratorEvent({
|
||||
userId,
|
||||
type: "curator.sprint.invalidated",
|
||||
payload: {
|
||||
sprintId: sprintIdFor(existingStartDate),
|
||||
staleSprintEventId: latest[0]?.id,
|
||||
staleSprintId: sprintIdFor(existingStartDate),
|
||||
startDate: existingStartDate,
|
||||
reason: "onboarding_access_choice_changed",
|
||||
previousAccess: existingAccess,
|
||||
previousDurationDays: existingDuration,
|
||||
access: desired.access,
|
||||
durationDays: desired.durationDays,
|
||||
version: CURATOR_PLAN_VERSION,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
return {
|
||||
const desiredFingerprint = curatorSprintPlanFingerprint({
|
||||
startDate: existingStartDate,
|
||||
variantId: existingVariant as CuratorIcpId,
|
||||
variantId: desiredVariant,
|
||||
durationDays: desired.durationDays,
|
||||
access: desired.access,
|
||||
planDays: planSeedsForVariant(templateSet, existingStartDate, desired.durationDays, 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 planDays = planSeedsForVariant(templateSet, todayDate, desired.durationDays, desired.access);
|
||||
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),
|
||||
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,
|
||||
startDate: replacementStartDate,
|
||||
variantId,
|
||||
durationDays: desired.durationDays,
|
||||
access: desired.access,
|
||||
@@ -1021,8 +1155,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,
|
||||
@@ -1035,6 +1170,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,
|
||||
@@ -1066,6 +1202,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),
|
||||
@@ -1077,10 +1217,14 @@ function buildTask(
|
||||
isPaidTask: seedTask.isPaidTask !== false,
|
||||
isRecoveryPractice: Boolean(seedTask.isRecoveryPractice),
|
||||
};
|
||||
const profileComplete = isProfileTask && (userContext.profile.resumeAvailable || userContext.profile.linkedinAvailable);
|
||||
const routeTask = isProfileTask && userContext.profile.linkedinAvailable && !userContext.profile.resumeAvailable
|
||||
? { ...task, serviceId: "resume-service" as const }
|
||||
: task;
|
||||
const taskWithRoute = {
|
||||
...task,
|
||||
route: buildCuratorTaskDeepLink(task, targetRole),
|
||||
status: classifyTaskStatus({ task, focusDate, completionRows, recentRows }),
|
||||
route: buildCuratorTaskDeepLink(routeTask, targetRole),
|
||||
status: profileComplete ? "completed" as const : classifyTaskStatus({ task, focusDate, completionRows, recentRows }),
|
||||
} satisfies CuratorTask;
|
||||
return taskWithRoute;
|
||||
}
|
||||
@@ -1093,7 +1237,7 @@ function buildTasksForPlanDay(
|
||||
recentRows: Awaited<ReturnType<typeof loadRecentContextRows>>,
|
||||
focusDate: string,
|
||||
durationDays: number,
|
||||
targetRole?: string,
|
||||
userContext: CuratorUserContext,
|
||||
) {
|
||||
return planDay.plannedTasks.map((task) => (
|
||||
buildTask(
|
||||
@@ -1108,7 +1252,7 @@ function buildTasksForPlanDay(
|
||||
recentRows,
|
||||
focusDate,
|
||||
durationDays,
|
||||
targetRole,
|
||||
userContext,
|
||||
)
|
||||
));
|
||||
}
|
||||
@@ -1132,7 +1276,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> {
|
||||
@@ -1199,7 +1343,7 @@ async function buildCuratorSprintInternal(
|
||||
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";
|
||||
@@ -1263,6 +1407,20 @@ async function buildCuratorSprintInternal(
|
||||
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,
|
||||
@@ -1468,7 +1626,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),
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -3,6 +3,7 @@ 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 { 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;
|
||||
@@ -173,7 +178,11 @@ 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, resumeState, socialState] = await Promise.all([
|
||||
getQscoreFromService(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 : [];
|
||||
|
||||
@@ -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,
|
||||
@@ -217,6 +232,21 @@ export async function buildCuratorUserContext(userId: string): Promise<CuratorUs
|
||||
};
|
||||
}
|
||||
|
||||
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";
|
||||
|
||||
Reference in New Issue
Block a user