Merge staging into QScore raw-event refactor
This commit is contained in:
@@ -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.
|
||||
|
||||
+10
-13
@@ -14,18 +14,18 @@ services:
|
||||
qscore-service-staging:
|
||||
matchmaking-v2-staging:
|
||||
user-service-staging:
|
||||
social-branding-staging:
|
||||
environment:
|
||||
DATABASE_URL: postgres://growqr:growqr@growqr-postgres:5432/growqr
|
||||
SOCIAL_BRANDING_SERVICE_URL: http://social-branding-api-1:8011
|
||||
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:
|
||||
@@ -36,19 +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
|
||||
social-branding-staging:
|
||||
external: true
|
||||
name: social-branding_default
|
||||
name: user-service-staging_default
|
||||
|
||||
+3
-2
@@ -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}
|
||||
|
||||
+9
-2
@@ -12,6 +12,11 @@
|
||||
"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:onboarding-read": "tsx scripts/onboarding-rev10-read.test.ts",
|
||||
"test:interview-user-identity": "tsx scripts/interview-user-identity.test.ts",
|
||||
"test:ws-ticket": "tsx scripts/ws-ticket.test.ts",
|
||||
"test:roleplay-drafts": "tsx scripts/roleplay-drafts.test.ts",
|
||||
"test:resume-gateway": "tsx scripts/resume-gateway.test.ts",
|
||||
"start": "node dist/index.js",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||
"workflows:smoke": "tsx src/workflows/smoke-test.ts",
|
||||
@@ -22,8 +27,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": {
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { serviceRoutes } from "../src/routes/services.js";
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
const calls: Array<{ url: string; body: Record<string, unknown> }> = [];
|
||||
globalThis.fetch = (async (input, init) => {
|
||||
const body = JSON.parse(String(init?.body ?? "{}")) as Record<string, unknown>;
|
||||
calls.push({ url: String(input), body });
|
||||
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"));
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
console.log("course-matchmaking HTTP route tests passed");
|
||||
@@ -85,9 +85,24 @@ if (!databaseAvailable) {
|
||||
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), "Day 2 must expose experienced trial tasks after advance");
|
||||
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");
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { getCompletionEvents } from "../src/services/service-registry.js";
|
||||
import { 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";
|
||||
|
||||
const taskId = "curator-task-1";
|
||||
const event = (type: string, correlatedTaskId = taskId): CuratorCompletionEvent => ({
|
||||
type,
|
||||
payload: {},
|
||||
correlation: { taskId: correlatedTaskId, curatorTaskId: 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 courseEvents = getCompletionEvents("courses-service");
|
||||
assert.deepEqual(courseEvents, ["course.completed"]);
|
||||
assert.equal(isTaskCompletionEvent(taskId, courseEvents, event("course.generated")), false);
|
||||
assert.equal(isTaskCompletionEvent(taskId, courseEvents, event("course.progress.recorded")), false);
|
||||
assert.equal(isTaskCompletionEvent(taskId, courseEvents, event("course.completed")), true);
|
||||
|
||||
const socialEvents = getCompletionEvents("social-branding-service");
|
||||
assert.equal(isTaskCompletionEvent(taskId, socialEvents, event("social.post.scheduled")), true);
|
||||
assert.equal(isTaskCompletionEvent(taskId, socialEvents, event("brand.content.published")), true);
|
||||
assert.equal(isTaskCompletionEvent(taskId, socialEvents, event("brand.post.drafted")), false);
|
||||
|
||||
const resumeEvents = getCompletionEvents("resume-service");
|
||||
assert.equal(isTaskCompletionEvent(taskId, resumeEvents, event("resume.parsed")), false);
|
||||
assert.equal(isTaskCompletionEvent(taskId, resumeEvents, event("resume.analysis.completed")), true);
|
||||
|
||||
const assessmentEvents = getCompletionEvents("assessment-service");
|
||||
assert.equal(isTaskCompletionEvent(taskId, assessmentEvents, event("assessment.started")), false);
|
||||
assert.equal(isTaskCompletionEvent(taskId, assessmentEvents, event("assessment.completed")), true);
|
||||
|
||||
const matchmakingEvents = getCompletionEvents("matchmaking-service");
|
||||
assert.equal(isTaskCompletionEvent(taskId, matchmakingEvents, event("matchmaking.matches.generated")), false);
|
||||
assert.equal(isTaskCompletionEvent(taskId, matchmakingEvents, event("matchmaking.match.dismissed")), false);
|
||||
assert.equal(isTaskCompletionEvent(taskId, matchmakingEvents, event("matchmaking.matches.reviewed")), true);
|
||||
|
||||
const qscoreEvents = getCompletionEvents("qscore-service");
|
||||
assert.equal(isTaskCompletionEvent(taskId, qscoreEvents, event("qscore.review.opened")), false);
|
||||
assert.equal(isTaskCompletionEvent(taskId, qscoreEvents, event("qscore.weak_driver.reviewed")), true);
|
||||
|
||||
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");
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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");
|
||||
@@ -29,6 +29,11 @@ const input = {
|
||||
};
|
||||
|
||||
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,
|
||||
|
||||
@@ -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");
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
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("/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" });
|
||||
|
||||
assert.equal(requests.length, 4);
|
||||
assert.match(requests[0]!.url, /\/api\/v1\/roleplays\/drafts\?user_id=user_test&limit=25$/);
|
||||
assert.match(requests[1]!.url, /\/api\/v1\/roleplays\/sessions\/draft-1\/plan$/);
|
||||
assert.match(requests[2]!.url, /\/api\/v1\/roleplays\/configure\/approve$/);
|
||||
assert.equal(requests[3]!.method, "DELETE");
|
||||
assert.match(requests[3]!.url, /\/api\/v1\/roleplays\/drafts\/draft-1$/);
|
||||
for (const request of requests) {
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { correlateEventWithServiceSession, normalizeServiceId, serviceEventExternalIds } from "../src/events/record-grow-event.js";
|
||||
import { canonicalGrowEventType } from "../src/events/normalize.js";
|
||||
import type { GrowEventEnvelope } from "../src/events/envelope.js";
|
||||
|
||||
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: { taskId: "curator-task-1" },
|
||||
};
|
||||
|
||||
{
|
||||
const correlated = correlateEventWithServiceSession(event, "user-a", link);
|
||||
assert.deepEqual(correlated.correlation, {
|
||||
sessionId: "session-1",
|
||||
taskId: "curator-task-1",
|
||||
curatorTaskId: "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 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", taskId: "explicit-task" },
|
||||
}, "user-a", link);
|
||||
assert.equal(explicit.correlation?.taskId, "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("social.profile.synced"), "brand.profile.updated");
|
||||
assert.equal(canonicalGrowEventType("resume.analysis.completed"), "resume.analysis.completed");
|
||||
}
|
||||
|
||||
console.log("service session correlation tests passed");
|
||||
@@ -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");
|
||||
Executable
+64
@@ -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,8 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { fetchUserProfile, patchPreferencesRequest } from "../src/services/user-profile.js";
|
||||
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.
|
||||
@@ -162,4 +165,151 @@ 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;
|
||||
}
|
||||
}
|
||||
// ── 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");
|
||||
|
||||
@@ -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");
|
||||
+12
-6
@@ -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 &&
|
||||
|
||||
+9
-2
@@ -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
|
||||
|
||||
+18
-1
@@ -41,6 +41,23 @@ function normalizeCorrelation(value: unknown) {
|
||||
return Object.keys(correlation).length ? correlation : undefined;
|
||||
}
|
||||
|
||||
const CANONICAL_EVENT_TYPE: Record<string, string> = {
|
||||
"interview.review.completed": "interview.feedback.generated",
|
||||
"roleplay.session.completed": "roleplay.scenario.completed",
|
||||
"roleplay.review.completed": "roleplay.feedback.generated",
|
||||
"course.video.completed": "course.completed",
|
||||
"course.lesson.completed": "course.completed",
|
||||
"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",
|
||||
};
|
||||
|
||||
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);
|
||||
@@ -60,7 +77,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);
|
||||
|
||||
+184
-33
@@ -1,34 +1,176 @@
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { and, desc, eq, or, sql } from "drizzle-orm";
|
||||
import { db } from "../db/client.js";
|
||||
import { eventOutbox, growEvents, missionServiceSessions, 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
|
||||
.insert(users)
|
||||
.values({ id: userId, email: `${userId}@service.local`, displayName: userId })
|
||||
.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.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.taskId ?? eventCorrelation.curatorTaskId ?? eventCorrelation.curator_task_id);
|
||||
const correlation = {
|
||||
...(taskId && !explicitTaskId ? { taskId, curatorTaskId: 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)))];
|
||||
}
|
||||
|
||||
function eventHasExternalId(event: Pick<GrowEventRow, "correlation" | "payload" | "subject">, externalIds: Set<string>) {
|
||||
return serviceEventExternalIds({ correlation: asRecord(event.correlation), payload: asRecord(event.payload), subject: asRecord(event.subject) } as GrowEventEnvelope)
|
||||
.some((value) => externalIds.has(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 [row] = await db
|
||||
.select({ userId: missionServiceSessions.userId })
|
||||
.from(missionServiceSessions)
|
||||
.where(and(
|
||||
eq(missionServiceSessions.serviceId, serviceId),
|
||||
inArrayValues(missionServiceSessions.externalId, externalIds),
|
||||
))
|
||||
.limit(1);
|
||||
return row?.userId ?? undefined;
|
||||
}
|
||||
|
||||
function inArrayValues(column: typeof missionServiceSessions.externalId, values: string[]) {
|
||||
return or(...values.map((value) => eq(column, value)))!;
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
const rowMetadata = asRecord(row?.metadata);
|
||||
const rowTaskId = getString(rowMetadata.taskId ?? rowMetadata.curatorTaskId ?? rowMetadata.curator_task_id);
|
||||
if (rowTaskId) return row;
|
||||
|
||||
const externalIdSet = new Set(externalIds);
|
||||
const candidates = await db
|
||||
.select({ source: growEvents.source, correlation: growEvents.correlation, payload: growEvents.payload, mission: growEvents.mission, subject: growEvents.subject })
|
||||
.from(growEvents)
|
||||
.where(and(
|
||||
eq(growEvents.userId, userId),
|
||||
sql`coalesce(${growEvents.correlation}->>'taskId', ${growEvents.correlation}->>'curatorTaskId', ${growEvents.payload}->>'taskId', ${growEvents.payload}->>'curatorTaskId') is not null`,
|
||||
))
|
||||
.orderBy(desc(growEvents.occurredAt))
|
||||
.limit(100);
|
||||
const prior = candidates.find((candidate) => (
|
||||
normalizeServiceId(candidate.source) === serviceId && eventHasExternalId(candidate, externalIdSet)
|
||||
));
|
||||
if (!prior) return row;
|
||||
const priorCorrelation = asRecord(prior.correlation);
|
||||
const priorPayload = asRecord(prior.payload);
|
||||
const priorMission = asRecord(prior.mission);
|
||||
return {
|
||||
userId,
|
||||
missionInstanceId: row?.missionInstanceId ?? getString(priorMission.instanceId ?? priorCorrelation.missionInstanceId) ?? null,
|
||||
missionId: row?.missionId ?? getString(priorMission.missionId ?? priorCorrelation.missionId) ?? null,
|
||||
stageId: row?.stageId ?? getString(priorMission.stageId ?? priorCorrelation.stageId) ?? null,
|
||||
metadata: {
|
||||
...priorPayload,
|
||||
...priorCorrelation,
|
||||
...rowMetadata,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
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> {
|
||||
@@ -39,23 +181,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 ?? DEFAULT_QSCORE_ORG_ID,
|
||||
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;
|
||||
|
||||
@@ -73,7 +218,7 @@ 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 canonicalEvent = canonicalGrowEventEnvelope(normalized, resolvedUserId);
|
||||
const canonicalEvent = canonicalGrowEventEnvelope(correlated, resolvedUserId);
|
||||
|
||||
const insertedRows = await db.transaction(async (tx) => {
|
||||
const rows = await tx
|
||||
@@ -133,12 +278,18 @@ export async function recordGrowEventWithResult(input: unknown, overrides: { use
|
||||
}
|
||||
|
||||
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);
|
||||
return compactRecord({
|
||||
id: normalized.id,
|
||||
userId: resolvedUserId,
|
||||
orgId: normalized.orgId ?? DEFAULT_QSCORE_ORG_ID,
|
||||
source: normalized.source,
|
||||
type: normalized.type,
|
||||
type: producerType ?? normalized.type,
|
||||
category: normalized.category,
|
||||
occurredAt: normalized.occurredAt,
|
||||
mission: normalized.mission,
|
||||
|
||||
+88
-25
@@ -5,13 +5,13 @@ 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 { routeGrowEventToUserActor } from "../events/route-to-user-actor.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, getQscoreLatestSignalsFromService } from "../services/qscore-proxy.js";
|
||||
|
||||
@@ -79,6 +79,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
|
||||
@@ -460,7 +469,7 @@ 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: decodedProxyHeaders(res.headers),
|
||||
@@ -743,9 +752,10 @@ async function proxySocialRequest(req: Request, rest: string, userId: string) {
|
||||
});
|
||||
}
|
||||
|
||||
export function serviceRoutes() {
|
||||
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 }) }));
|
||||
|
||||
@@ -874,7 +884,7 @@ export function serviceRoutes() {
|
||||
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);
|
||||
@@ -891,12 +901,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))));
|
||||
@@ -922,6 +935,14 @@ export function serviceRoutes() {
|
||||
});
|
||||
app.get("/interview/leaderboard", async (c) => c.json(await interviewService.leaderboard()));
|
||||
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.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.post("/interview/sessions/:sessionId/video/uploaded", async (c) => c.json(await interviewService.markVideoUploaded(c.req.param("sessionId"), c.get("userId"))));
|
||||
|
||||
@@ -945,7 +966,7 @@ export function serviceRoutes() {
|
||||
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);
|
||||
@@ -962,13 +983,34 @@ 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.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())));
|
||||
@@ -993,6 +1035,14 @@ export function serviceRoutes() {
|
||||
});
|
||||
app.get("/roleplay/leaderboard", async (c) => c.json(await roleplayService.leaderboard()));
|
||||
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.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.post("/roleplay/sessions/:sessionId/video/uploaded", async (c) => c.json(await roleplayService.markVideoUploaded(c.req.param("sessionId"), c.get("userId"))));
|
||||
|
||||
@@ -1033,21 +1083,34 @@ 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);
|
||||
// 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);
|
||||
});
|
||||
|
||||
|
||||
+5
-17
@@ -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
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -60,12 +60,14 @@ 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 }),
|
||||
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) =>
|
||||
@@ -107,12 +109,25 @@ 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 }),
|
||||
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),
|
||||
}),
|
||||
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) =>
|
||||
@@ -148,24 +163,30 @@ 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 }),
|
||||
};
|
||||
|
||||
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) }),
|
||||
};
|
||||
|
||||
@@ -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.",
|
||||
},
|
||||
@@ -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.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.",
|
||||
},
|
||||
@@ -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.completed"],
|
||||
qscoreSignals: ["courses.started", "courses.completed", "courses.completion_rate"],
|
||||
usage: "Use for skill-gap tasks that should become learning work.",
|
||||
},
|
||||
@@ -534,7 +534,7 @@ 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.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"],
|
||||
@@ -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.",
|
||||
},
|
||||
@@ -646,14 +646,9 @@ const serviceRegistry: ServiceRecord[] = [
|
||||
},
|
||||
toolName: "prepare_matchmaking_handoff",
|
||||
completionEvents: [
|
||||
"matchmaking.matches.generated",
|
||||
"matchmaking.feed.viewed",
|
||||
"matchmaking.match.viewed",
|
||||
"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 +665,7 @@ const serviceRegistry: ServiceRecord[] = [
|
||||
configuredEvents: [],
|
||||
completionEvents: [
|
||||
"service.completed",
|
||||
"matchmaking.matches.generated",
|
||||
"matchmaking.match.viewed",
|
||||
"matchmaking.match.saved",
|
||||
"matchmaking.match.dismissed",
|
||||
"matchmaking.matches.reviewed",
|
||||
"matchmaking.match.applied",
|
||||
"matchmaking.application.completed",
|
||||
@@ -725,7 +717,7 @@ const serviceRegistry: ServiceRecord[] = [
|
||||
review: "Review QScore",
|
||||
},
|
||||
toolName: "prepare_qscore_review",
|
||||
completionEvents: ["qscore.baseline.created", "qscore.review.opened", "qscore.weak_driver.reviewed", "qscore.signal.projected", "qscore.updated"],
|
||||
completionEvents: ["qscore.weak_driver.reviewed", "qscore.signal.projected", "qscore.updated"],
|
||||
qscoreSignals: ["qscore.updated", "qscore.signal.projected"],
|
||||
usage: "Use for measurement and projected readiness review tasks.",
|
||||
},
|
||||
@@ -785,7 +777,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.post.exported", "brand.profile.updated", "brand.content.published", "social.profile.updated", "social.post.scheduled", "social.post.published"],
|
||||
qscoreSignals: ["profile.linkedin", "proof.visibility", "networking.brand"],
|
||||
usage: "Use for profile visibility, LinkedIn cleanup, and social proof tasks.",
|
||||
},
|
||||
@@ -795,7 +787,7 @@ 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.post.exported", "brand.profile.updated", "brand.content.published", "social.profile.updated", "social.post.scheduled", "social.post.published"],
|
||||
abandonmentEvents: ["service.abandoned"],
|
||||
qscoreSignalIds: ["brand.visibility_signal", "profile.linkedin", "proof.visibility"],
|
||||
analyticsDimensions: ["content_type", "channel", "draft_id", "topic", "profile_field_updated"],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
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";
|
||||
@@ -39,26 +39,25 @@ import { addDaysIso, computeStreakFromDays, todayIso } from "./streak-utils.js";
|
||||
const VALID_COMPLETION_TYPES = [
|
||||
"service.completed",
|
||||
"resume.analysis.completed",
|
||||
"resume.parsed",
|
||||
"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.completed",
|
||||
"course.lesson.completed",
|
||||
"brand.post.exported",
|
||||
"brand.profile.updated",
|
||||
"matchmaking.matches.generated",
|
||||
"matchmaking.feed.viewed",
|
||||
"brand.content.published",
|
||||
"social.profile.updated",
|
||||
"social.post.scheduled",
|
||||
"social.post.published",
|
||||
"matchmaking.match.saved",
|
||||
"matchmaking.matches.reviewed",
|
||||
"matchmaking.match.applied",
|
||||
"matchmaking.application.completed",
|
||||
"qscore.weak_driver.reviewed",
|
||||
"qscore.updated",
|
||||
"qscore.signal.projected",
|
||||
"curator.task.completed",
|
||||
@@ -859,6 +858,25 @@ async function loadCurrentQScore(userId: string) {
|
||||
return result?.q_score ?? null;
|
||||
}
|
||||
|
||||
export type CuratorCompletionEvent = Pick<GrowEventRow, "type" | "payload" | "correlation">;
|
||||
|
||||
export function isTaskCompletionEvent(taskId: string, completionEvents: string[], row: CuratorCompletionEvent) {
|
||||
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)
|
||||
);
|
||||
}
|
||||
|
||||
async function loadCompletionRows(userId: string, sinceDate: string) {
|
||||
return db
|
||||
.select()
|
||||
@@ -871,23 +889,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>>) {
|
||||
@@ -1285,7 +1288,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`coalesce(${growEvents.correlation}->>'taskId', ${growEvents.correlation}->>'curatorTaskId', ${growEvents.payload}->>'taskId', ${growEvents.payload}->>'curatorTaskId') 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);
|
||||
|
||||
@@ -184,6 +184,7 @@ export async function buildCuratorUserContext(userId: string): Promise<CuratorUs
|
||||
fetchProfileState(config.resumeServiceUrl, userId),
|
||||
fetchProfileState(config.socialBrandingServiceUrl, userId),
|
||||
]);
|
||||
const breakdown = qscoreResult?.breakdown ?? {};
|
||||
|
||||
const targetRole = fallbackCuratorRole(await resolveCuratorTargetRole({ userId })) ?? null;
|
||||
const corpus = rows.map((row) => `${row.type} ${row.source} ${payloadText(row.payload)}`).join(" ").toLowerCase();
|
||||
@@ -225,8 +226,8 @@ export async function buildCuratorUserContext(userId: string): Promise<CuratorUs
|
||||
qscore: {
|
||||
score: qscoreResult?.q_score ?? null,
|
||||
signalCount: qscoreEvidence?.length ?? 0,
|
||||
summary: null,
|
||||
dimensions: qscoreResult ? qscoreResult.quotients : null,
|
||||
summary: typeof breakdown.summary === "string" ? breakdown.summary : null,
|
||||
dimensions: isRecord(breakdown.dimensions) ? breakdown.dimensions : (qscoreResult ? qscoreResult.quotients : null),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user