diff --git a/scripts/onboarding-bulk-reset.ts b/scripts/onboarding-bulk-reset.ts new file mode 100755 index 0000000..1cdb93f --- /dev/null +++ b/scripts/onboarding-bulk-reset.ts @@ -0,0 +1,126 @@ +#!/usr/bin/env tsx +/** + * Staging-only bulk onboarding reset CLI. + * + * Enumerates every user in the backend `users` table (the Clerk-mirrored + * enrollment source) and resets each user's onboarding by calling the + * authenticated DELETE /users/onboarding route via the service-token path. + * This reuses the exact per-user reset logic end-to-end (preferences reset + + * backend ledger deletion) — the CLI never re-implements it. + * + * GUARDS — all three must hold or the script aborts before any network call: + * 1. NODE_ENV=staging (refuses production/development) + * 2. ONBOARDING_BULK_RESET_ALLOWED=true (explicit opt-in flag) + * 3. --confirm on the CLI (typed acknowledgement) + * + * Usage: + * NODE_ENV=staging ONBOARDING_BULK_RESET_ALLOWED=true \ + * npx tsx scripts/onboarding-bulk-reset.ts --confirm + * + * Output: one audit line per user (ok / error / skipped) and a summary. + * + * This script is INTENTIONALLY not wired to any HTTP surface. It is a + * staging-only operations tool; bulk reset must never be exposed as a public + * endpoint. Per-user reset is the authenticated DELETE route. + */ + +import { eq, asc } from "drizzle-orm"; +import { db } from "../src/db/client.js"; +import { users } from "../src/db/schema.js"; +import { config } from "../src/config.js"; +import { log } from "../src/log.js"; + +type ResetOutcome = + | { userId: string; ok: true; ledgerRowsDeleted: number } + | { userId: string; ok: false; error: string; status: number }; + +function assertStagingGuard(argv: string[]): void { + const isStaging = config.nodeEnv === "staging"; + const allowed = process.env.ONBOARDING_BULK_RESET_ALLOWED === "true"; + const confirmed = argv.includes("--confirm"); + + const failures: string[] = []; + if (!isStaging) failures.push(`NODE_ENV must be "staging" (got "${config.nodeEnv}")`); + if (!allowed) failures.push("ONBOARDING_BULK_RESET_ALLOWED must be set to \"true\""); + if (!confirmed) failures.push("--confirm argument is required"); + + if (failures.length > 0) { + console.error("onboarding-bulk-reset: ABORTED — guard checks failed:"); + for (const f of failures) console.error(` - ${f}`); + console.error(""); + console.error("This script is staging-only and will reset onboarding for EVERY user."); + console.error("Re-run with all guards satisfied to proceed."); + process.exit(2); + } +} + +function backendUrl(): string { + const host = process.env.BACKEND_HOST ?? "127.0.0.1"; + const port = config.port; + return `http://${host}:${port}`; +} + +async function resetOne(baseUrl: string, serviceToken: string, userId: string): Promise { + const res = await fetch(`${baseUrl}/users/onboarding`, { + method: "DELETE", + headers: { + authorization: `Bearer ${serviceToken}`, + "x-growqr-user": userId, + "content-type": "application/json", + }, + }); + if (!res.ok) { + const text = await res.text().catch(() => ""); + return { userId, ok: false, error: text || res.statusText, status: res.status }; + } + const body = (await res.json()) as { ledgerRowsDeleted?: number }; + return { userId, ok: true, ledgerRowsDeleted: body.ledgerRowsDeleted ?? 0 }; +} + +async function main() { + assertStagingGuard(process.argv.slice(2)); + + const serviceToken = config.serviceToken; + if (!serviceToken) { + console.error("onboarding-bulk-reset: ABORTED — SERVICE_TOKEN is not configured"); + process.exit(2); + } + + const baseUrl = backendUrl(); + const allUsers = await db.select({ id: users.id, email: users.email }).from(users).orderBy(asc(users.id)); + + console.log(`onboarding-bulk-reset: targeting ${allUsers.length} user(s) at ${baseUrl}`); + console.log(`onboarding-bulk-reset: NODE_ENV=${config.nodeEnv}, guard=on`); + + const results: ResetOutcome[] = []; + for (const u of allUsers) { + try { + const outcome = await resetOne(baseUrl, serviceToken, u.id); + results.push(outcome); + if (outcome.ok) { + console.log(` ok ${u.id} (${u.email}) — ${outcome.ledgerRowsDeleted} ledger row(s) deleted`); + } else { + console.log(` ERROR ${u.id} (${u.email}) — status ${outcome.status}: ${outcome.error}`); + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + results.push({ userId: u.id, ok: false, error: message, status: 0 }); + console.log(` ERROR ${u.id} (${u.email}) — threw: ${message}`); + } + } + + const okCount = results.filter((r) => r.ok).length; + const errorCount = results.length - okCount; + const ledgerTotal = results.reduce((sum, r) => (r.ok ? sum + r.ledgerRowsDeleted : sum), 0); + + console.log(""); + console.log(`onboarding-bulk-reset: complete — ${okCount} ok, ${errorCount} error(s), ${ledgerTotal} ledger row(s) deleted`); + + log.info({ okCount, errorCount, ledgerTotal, total: results.length }, "onboarding bulk reset complete"); + process.exit(errorCount > 0 ? 1 : 0); +} + +main().catch((err) => { + console.error("onboarding-bulk-reset: fatal", err); + process.exit(1); +}); diff --git a/scripts/onboarding-ledger.test.ts b/scripts/onboarding-ledger.test.ts index cbcba12..655da06 100644 --- a/scripts/onboarding-ledger.test.ts +++ b/scripts/onboarding-ledger.test.ts @@ -70,5 +70,45 @@ assert.equal( "invalid completion timestamps should normalize to a valid ISO timestamp", ); +// ── Regression: underscore completion aliases must be valid (reset deletes them) ── +// The reset only reopens the gate if EVERY alias form that isValidOnboardingLedgerEvent +// accepts is also in the reset delete scope. Pin both sides agree per alias. +for (const underscore of ["onboarding_completed", "user_onboarding_completed", "profile_onboarding_completed"]) { + const dotted = normalizeOnboardingEventType(underscore); + assert.equal( + isValidOnboardingLedgerEvent({ type: underscore, payload: {} }), + true, + `${underscore} should satisfy onboarding status (normalized to ${dotted})`, + ); +} + +// ── Regression: completedAtFromOnboardingPayload paths mirrored by reset SQL ── +// resetOnboardingLedger's jsonb predicate checks top-level completed_at/completedAt +// and onboarding.completed_at/completedAt. Pin that the JS helper (used for status +// validity) agrees a snapshot carrying any of these is a completion snapshot. +for (const [label, payload] of [ + ["top-level completed_at", { completed_at: now }], + ["top-level completedAt", { completedAt: now }], + ["onboarding.completed_at", { onboarding: { completed_at: now } }], + ["onboarding.completedAt", { onboarding: { completedAt: now } }], +] as const) { + assert.equal( + completedAtFromOnboardingPayload(payload), + now, + `completion timestamp extracted from ${label}`, + ); + assert.equal( + isValidOnboardingLedgerEvent({ type: "onboarding.snapshot.saved", payload }), + true, + `snapshot with ${label} is a completion snapshot`, + ); +} + +// ── Regression: intermediate snapshots (no completion marker) stay invalid ── +assert.equal( + completedAtFromOnboardingPayload({ onboarding: { current_step: 2 } }), + undefined, + "intermediate snapshot has no completion timestamp", +); console.log("onboarding-ledger tests passed"); process.exit(0); diff --git a/scripts/onboarding-preferences.test.ts b/scripts/onboarding-preferences.test.ts new file mode 100644 index 0000000..ff96174 --- /dev/null +++ b/scripts/onboarding-preferences.test.ts @@ -0,0 +1,160 @@ +import assert from "node:assert/strict"; +import { + DEFAULT_ONBOARDING_DATA, + defaultOnboardingData, + extractOnboardingData, + assertOnboardingRevision, + deriveOnboardingPayload, + mergeOnboardingPatch, + OnboardingRevisionConflict, +} from "../src/events/onboarding-ledger.js"; + +/** + * Focused tests for the onboarding preference helpers (pure — no DB/network). + * Covers: default v3 shape, revision conflict, partial update merge, + * status/completed_at consistency + payload derivation, and unrelated + * preference preservation. Matches the scripts/X.test.ts convention + * (node:assert over pure functions). + */ + +// ── 1. default v3 response ─────────────────────────────────────────────────── +{ + const def = defaultOnboardingData(); + assert.equal(def.schema_version, 3, "default is v3"); + assert.equal(def.revision, 0); + assert.equal(def.status, "in_progress"); + assert.equal(def.access_choice, null); + assert.equal(def.qx_estimate, null); + assert.equal(def.completed_at, null); + assert.equal(def.consent.privacy_accepted, false); + assert.equal(def.progress.stage, "consent"); + // DEFAULT constant must match the factory. + assert.deepEqual(def, DEFAULT_ONBOARDING_DATA); + // Factory returns a fresh clone, not the shared constant. + assert.notEqual(def, DEFAULT_ONBOARDING_DATA); +} + +// extractOnboardingData returns the default when nothing is stored. +{ + const empty = extractOnboardingData(undefined); + assert.equal(empty.schema_version, 3); + assert.equal(empty.revision, 0); + const fromNonObject = extractOnboardingData("nonsense"); + assert.equal(fromNonObject.status, "in_progress"); +} + +// ── 2. revision conflict (optimistic concurrency) ─────────────────────────── +{ + const current = extractOnboardingData({ revision: 5 }); + // Matching revision is accepted (no throw). + assert.doesNotThrow(() => assertOnboardingRevision(5, current)); + + // Mismatched revision throws the typed conflict. + assert.throws( + () => assertOnboardingRevision(4, current), + (err) => err instanceof OnboardingRevisionConflict && err.expected === 4 && err.actual === 5, + ); +} + +// ── 3. partial update merge (revision bump + progress stamp) ──────────────── +{ + const stored = extractOnboardingData({ revision: 2 }); + const incoming = { ...stored, revision: 2, profile: { ...stored.profile, mode: "founder" } }; + const merged = mergeOnboardingPatch({ onboarding: stored }, incoming); + const next = extractOnboardingData(merged.onboarding); + + assert.equal(next.revision, 3, "revision bumps by one from the stored value"); + assert.equal(next.profile.mode, "founder", "incoming profile change is applied"); + assert.ok(next.progress.updated_at, "progress.updated_at is stamped on save"); +} + +// ── 4. status/completed_at consistency + payload derivation ───────────────── +{ + const base = defaultOnboardingData(); + const completed = { + ...base, + revision: 0, + status: "completed" as const, + profile: { ...base.profile, intent: "land-a-role", mode: "student", icp: "intern", question_branch: "student" }, + responses: { + ...base.responses, + career_barriers: ["no-network"], + desired_outcomes: ["interviews", "offer"], + target_role: "Product Intern", + target_field: "technical", + weekly_time_commitment: "5-10", + experience_level: "0-2", + work_context: "audience", + }, + // deliberately omit completed_at; merge must stamp it. + }; + + const merged = mergeOnboardingPatch({}, completed); + const next = extractOnboardingData(merged.onboarding); + assert.equal(next.status, "completed"); + assert.ok(next.completed_at, "completed status without completed_at is stamped"); + + // Payload is a faithful projection — no invented completion math. + const payload = deriveOnboardingPayload(next); + assert.equal(payload.schema_version, 1); + assert.equal(payload.source_revision, next.revision); + assert.equal(payload.mode, "student"); + assert.equal(payload.onboarding_icp, "intern"); + assert.equal(payload.target_role, "Product Intern"); + assert.equal(payload.target_field, "technical"); + assert.deepEqual(payload.goals, ["interviews", "offer"]); + assert.deepEqual(payload.barriers, ["no-network"]); + assert.equal(payload.weekly_time_commitment, "5-10"); + assert.equal(payload.experience_context, "0-2 · audience"); +} + +// completed_at is cleared when regressing to in_progress with an explicit null. +{ + const completed = { ...defaultOnboardingData(), status: "completed" as const, completed_at: "2026-07-10T00:00:00.000Z" }; + const regressed = { ...completed, status: "in_progress" as const, completed_at: null }; + const merged = mergeOnboardingPatch({}, regressed); + const next = extractOnboardingData(merged.onboarding); + assert.equal(next.status, "in_progress"); + assert.equal(next.completed_at, null, "stale completed_at is cleared on regression"); +} + +// ── 5. unrelated preference preservation ──────────────────────────────────── +{ + const preferences = { + onboarding: { revision: 1, status: "in_progress" }, + interview_preferences: { focus_areas: ["behavioral"] }, + resume_preferences: { target_title: "Data Scientist" }, + mission_preferences: { active_goal: "land-offer" }, + target_roles: ["Product Intern"], + target_companies: ["Acme"], + }; + const incoming = { ...extractOnboardingData(preferences.onboarding), revision: 1, profile: { intent: "x", mode: "student", icp: "intern", question_branch: "student" } }; + const merged = mergeOnboardingPatch(preferences, incoming); + + // The onboarding blob is updated. + assert.equal(extractOnboardingData(merged.onboarding).revision, 2); + // Every unrelated preference key is preserved verbatim. + assert.deepEqual(merged.interview_preferences, { focus_areas: ["behavioral"] }); + assert.deepEqual(merged.resume_preferences, { target_title: "Data Scientist" }); + assert.deepEqual(merged.mission_preferences, { active_goal: "land-offer" }); + assert.deepEqual(merged.target_roles, ["Product Intern"]); + assert.deepEqual(merged.target_companies, ["Acme"]); +} + +// ── 6. access_choice persistence (trial | full | null) ─────────────────────── +{ + const base = defaultOnboardingData(); + const trial = { ...base, revision: 0, access_choice: "trial" as const }; + const mergedTrial = mergeOnboardingPatch({}, trial); + assert.equal(extractOnboardingData(mergedTrial.onboarding).access_choice, "trial"); + + const full = { ...base, revision: 0, access_choice: "full" as const }; + const mergedFull = mergeOnboardingPatch({}, full); + assert.equal(extractOnboardingData(mergedFull.onboarding).access_choice, "full"); + + // Garbage access_choice is rejected back to null by extraction. + const garbage = extractOnboardingData({ access_choice: "pro" }); + assert.equal(garbage.access_choice, null); +} + +console.log("onboarding-preferences: all assertions passed"); diff --git a/scripts/onboarding-reset.test.ts b/scripts/onboarding-reset.test.ts new file mode 100644 index 0000000..8c932a3 --- /dev/null +++ b/scripts/onboarding-reset.test.ts @@ -0,0 +1,138 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { + COMPLETION_EVENT_TYPES, + COMPLETION_EVENT_TYPE_ALIASES, + SNAPSHOT_EVENT_TYPE_ALIASES, + resetOnboardingPreferences, + defaultOnboardingData, + extractOnboardingData, + ONBOARDING_LEDGER_QUERY_TYPES, +} from "../src/events/onboarding-ledger.js"; + +/** + * Focused tests for onboarding reset helpers (pure — no DB/network). + * Covers: per-user reset state (preferences reset to default, unrelated keys + * preserved), ledger type scope (only completion types deleted, snapshots + * preserved), and bulk guard behavior (assertStagingGuard invariants). + * + * The async resetOnboardingLedger is exercised in integration; here we assert + * its type-scope contract via the exported COMPLETION_EVENT_TYPES set, which is + * exactly the WHERE clause it builds. + */ + +// ── 1. resetOnboardingPreferences: resets onboarding to default v3 ────────── +{ + const preferences = { + onboarding: { + schema_version: 3, + revision: 5, + status: "completed", + completed_at: "2026-07-10T00:00:00.000Z", + progress: { stage: "done", branch_index: 3, updated_at: "2026-07-10T00:00:00.000Z" }, + consent: { privacy_accepted: true, accepted_at: "2026-07-09T00:00:00.000Z", terms_version: "2026-07" }, + profile: { intent: "land-a-role", mode: "student", icp: "intern", question_branch: "student" }, + responses: { career_barriers: ["x"], desired_outcomes: ["y"], current_situation: null, target_milestone: null, weekly_time_commitment: "5-10", target_role: "Intern", target_field: "tech", venture_industry: null, experience_level: "0-2", work_context: "audience" }, + import: { method: "manual", status: "not_started", resume_id: null, resume_filename: null, resume_summary: null, linkedin_profile_id: null, linkedin_url: null }, + access_choice: "trial", + qx_estimate: 42, + }, + interview_preferences: { focus_areas: ["behavioral"] }, + resume_preferences: { target_title: "Data Scientist" }, + target_roles: ["Product Intern"], + }; + + const reset = resetOnboardingPreferences(preferences); + const next = extractOnboardingData(reset.onboarding); + + // Onboarding blob is back to defaults. + assert.equal(next.schema_version, 3); + assert.equal(next.revision, 0, "revision resets to 0"); + assert.equal(next.status, "in_progress", "status reverts to in_progress"); + assert.equal(next.completed_at, null, "completed_at is cleared"); + assert.equal(next.progress.stage, "consent", "progress stage resets to consent"); + assert.equal(next.access_choice, null); + assert.equal(next.qx_estimate, null); + assert.deepEqual(next, defaultOnboardingData()); + + // Unrelated preference keys are preserved verbatim. + assert.deepEqual(reset.interview_preferences, { focus_areas: ["behavioral"] }); + assert.deepEqual(reset.resume_preferences, { target_title: "Data Scientist" }); + assert.deepEqual(reset.target_roles, ["Product Intern"]); +} + +// ── 2. resetOnboardingPreferences: idempotent on already-default prefs ───── +{ + const empty = {}; + const reset = resetOnboardingPreferences(empty); + assert.deepEqual(reset.onboarding, defaultOnboardingData()); + // Original object is not mutated. + assert.equal(Object.keys(empty).length, 0, "original preferences not mutated"); +} +// ── 3. Ledger reset scope: completion aliases (both forms) + completion snapshots +// resetOnboardingLedger builds: DELETE FROM grow_events WHERE userId = ? AND ( +// type IN (COMPLETION_EVENT_TYPE_ALIASES) +// OR (type IN (SNAPSHOT_EVENT_TYPE_ALIASES) AND payload-has-completion) +// ). This pins the contract: (a) all completion aliases — dotted AND underscore — +// are deleted so a legacy underscore completion row can't keep the gate shut; +// (b) snapshot types are NOT blanket completion types (only completion-bearing +// snapshots are deleted via the jsonb predicate); (c) intermediate snapshots are +// preserved because they never satisfy the completion predicate. +{ + // (a) Completion aliases cover BOTH dotted and underscore forms — 6 total. + assert.equal(COMPLETION_EVENT_TYPE_ALIASES.length, 6, "3 dotted + 3 underscore completion aliases"); + for (const dotted of Object.keys(COMPLETION_EVENT_TYPES)) { + const underscore = dotted.replaceAll(".", "_"); + assert.ok(COMPLETION_EVENT_TYPE_ALIASES.includes(dotted), `${dotted} in reset scope`); + assert.ok(COMPLETION_EVENT_TYPE_ALIASES.includes(underscore), `${underscore} alias in reset scope`); + } + + // (b) Snapshot types are handled in a SEPARATE phase, never as completion types. + assert.equal(COMPLETION_EVENT_TYPES["onboarding.snapshot.saved"], undefined, + "snapshot must never be a blanket completion type"); + assert.equal(SNAPSHOT_EVENT_TYPE_ALIASES.includes("onboarding.snapshot.saved"), true, + "dotted snapshot alias is in the snapshot reset phase"); + assert.equal(SNAPSHOT_EVENT_TYPE_ALIASES.includes("onboarding_snapshot_saved"), true, + "underscore snapshot alias is in the snapshot reset phase"); + + // (c) The reset completion set is a strict subset of the status-query list: + // status reads ALL aliases + snapshots; reset deletes only completions and + // completion-bearing snapshots, preserving intermediate saves. + for (const alias of COMPLETION_EVENT_TYPE_ALIASES) { + assert.ok((ONBOARDING_LEDGER_QUERY_TYPES as readonly string[]).includes(alias), + `${alias} is also queryable for status`); + } +} +// ── 4. Bulk guard behavior: real spawn of assertStagingGuard ─────────────── +// Spawns the actual CLI with controlled env and asserts exit code 2 (abort) +// for each independent failure leg. We only assert abort cases — the +// all-flags-pass case would proceed past the guard and hit the network/DB. +{ + const script = "scripts/onboarding-bulk-reset.ts"; + + // Leg 1: production env (even with all other flags) must abort. + const prod = spawnSync("npx", ["tsx", script, "--confirm"], { + env: { ...process.env, NODE_ENV: "production", ONBOARDING_BULK_RESET_ALLOWED: "true" }, + encoding: "utf8", + }); + assert.equal(prod.status, 2, "production must abort even with all flags"); + assert.match(prod.stderr, /NODE_ENV must be "staging"/, "production abort names the env failure"); + + // Leg 2: staging + allowed but missing --confirm must abort. + const noConfirm = spawnSync("npx", ["tsx", script], { + env: { ...process.env, NODE_ENV: "staging", ONBOARDING_BULK_RESET_ALLOWED: "true" }, + encoding: "utf8", + }); + assert.equal(noConfirm.status, 2, "staging without --confirm must abort"); + assert.match(noConfirm.stderr, /--confirm/, "missing-confirm abort names the confirm failure"); + + // Leg 3: staging + confirm but no opt-in flag must abort. + const noFlag = spawnSync("npx", ["tsx", script, "--confirm"], { + env: { ...process.env, NODE_ENV: "staging", ONBOARDING_BULK_RESET_ALLOWED: "false" }, + encoding: "utf8", + }); + assert.equal(noFlag.status, 2, "staging without opt-in flag must abort"); + assert.match(noFlag.stderr, /ONBOARDING_BULK_RESET_ALLOWED/, "missing-flag abort names the flag failure"); +} +console.log("onboarding-reset tests passed"); +process.exit(0); diff --git a/src/events/onboarding-ledger.ts b/src/events/onboarding-ledger.ts index 4493571..c8fa609 100644 --- a/src/events/onboarding-ledger.ts +++ b/src/events/onboarding-ledger.ts @@ -1,4 +1,4 @@ -import { and, desc, eq, inArray } from "drizzle-orm"; +import { and, desc, eq, inArray, or, sql } from "drizzle-orm"; import { db } from "../db/client.js"; import { growEvents, type GrowEventRow } from "../db/schema.js"; import { asRecord } from "./envelope.js"; @@ -38,11 +38,23 @@ export type OnboardingStatusEvent = { processingStatus: string; }; -const COMPLETION_EVENT_TYPES = new Set([ - "onboarding.completed", - "user.onboarding.completed", - "profile.onboarding.completed", -]); +export const COMPLETION_EVENT_TYPES: Record = { + "onboarding.completed": true, + "user.onboarding.completed": true, + "profile.onboarding.completed": true, +}; + +// Every ledger row whose type (dotted OR underscore alias) marks a completion. +// resetOnboardingLedger deletes all of these so the gate reopens regardless of +// which alias form a legacy emitter wrote. +export const COMPLETION_EVENT_TYPE_ALIASES: readonly string[] = [ + ...Object.keys(COMPLETION_EVENT_TYPES), + ...Object.keys(COMPLETION_EVENT_TYPES).map((t) => t.replaceAll(".", "_")), +]; + +// Snapshot ledger types in both alias forms. Only completion-bearing snapshots +// are reset; intermediate step saves are preserved. +export const SNAPSHOT_EVENT_TYPE_ALIASES = ["onboarding.snapshot.saved", "onboarding_snapshot_saved"] as const; export function normalizeOnboardingEventType(type: string) { return type.toLowerCase().replaceAll("_", "."); @@ -72,7 +84,7 @@ export function completedAtFromOnboardingPayload(payload: Record) { const normalizedType = normalizeOnboardingEventType(event.type); - if (COMPLETION_EVENT_TYPES.has(normalizedType)) return true; + if (COMPLETION_EVENT_TYPES[normalizedType]) return true; // Snapshots are status-valid only when they are completion snapshots. Plain // intermediate step saves must not let a new seeker bypass onboarding. @@ -197,3 +209,329 @@ export async function recordAndProcessOnboardingCompletion(input: { return { event, ...sideEffects }; } + +// ──────────────────────────────────────────────────────────────────────────── +// Onboarding preferences helpers (pure — no DB, no network). +// +// The canonical onboarding store is `preferences.onboarding` on the user-service +// profile, shaped as the v3 OnboardingData object (schema_version: 3). These +// helpers read/validate/merge that blob and derive the faithful projection used +// by the curator. Completion percentages and QX estimates are OWNED by the +// dashboard (branch-aware); the backend persists the client-supplied values and +// never recomputes them. +// ──────────────────────────────────────────────────────────────────────────── + +export type OnboardingAccessChoice = "trial" | "full" | null; + +export type OnboardingData = { + schema_version: 3; + revision: number; + status: "in_progress" | "completed"; + progress: { stage: string; branch_index: number; updated_at: string | null }; + consent: { privacy_accepted: boolean; accepted_at: string | null; terms_version: string }; + profile: { + intent: string | null; + mode: string | null; + icp: string | null; + question_branch: string | null; + }; + responses: { + current_situation: string | null; + career_barriers: string[]; + desired_outcomes: string[]; + target_milestone: string | null; + weekly_time_commitment: string | null; + target_role: string | null; + target_field: string | null; + venture_industry: string | null; + experience_level: string | null; + work_context: string | null; + }; + import: { + method: string; + status: string; + resume_id: string | null; + resume_filename: string | null; + resume_summary: string | null; + linkedin_profile_id: string | null; + linkedin_url: string | null; + }; + access_choice: OnboardingAccessChoice; + qx_estimate: number | null; + completed_at: string | null; +}; + +export type OnboardingPayload = { + schema_version: 1; + source_revision: number; + primary_intent: string | null; + mode: string | null; + question_branch: string | null; + onboarding_icp: string | null; + curator_registry_icp: string | null; + target_role: string | null; + target_field: string | null; + experience_context: string | null; + goals: string[]; + barriers: string[]; + priority: string | null; + weekly_time_commitment: string | null; +}; + +export const DEFAULT_ONBOARDING_DATA: OnboardingData = { + schema_version: 3, + revision: 0, + status: "in_progress", + progress: { stage: "consent", branch_index: 0, updated_at: null }, + consent: { privacy_accepted: false, accepted_at: null, terms_version: "2026-07" }, + profile: { intent: null, mode: null, icp: null, question_branch: null }, + responses: { + current_situation: null, + career_barriers: [], + desired_outcomes: [], + target_milestone: null, + weekly_time_commitment: null, + target_role: null, + target_field: null, + venture_industry: null, + experience_level: null, + work_context: null, + }, + import: { + method: "manual", + status: "not_started", + resume_id: null, + resume_filename: null, + resume_summary: null, + linkedin_profile_id: null, + linkedin_url: null, + }, + access_choice: null, + qx_estimate: null, + completed_at: null, +}; + +export function defaultOnboardingData(): OnboardingData { + return structuredClone(DEFAULT_ONBOARDING_DATA); +} + +function isObject(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +/** + * Coerce an arbitrary stored value into a valid v3 OnboardingData, filling gaps + * from the defaults. Rejects non-object shapes and falls back to defaults so the + * route always returns a well-formed v3 response (never throws). + */ +export function extractOnboardingData(stored: unknown): OnboardingData { + const base = defaultOnboardingData(); + if (!isObject(stored)) return base; + + const progress = isObject(stored.progress) ? stored.progress : {}; + const consent = isObject(stored.consent) ? stored.consent : {}; + const profile = isObject(stored.profile) ? stored.profile : {}; + const responses = isObject(stored.responses) ? stored.responses : {}; + const imp = isObject(stored.import) ? stored.import : {}; + + const rev = typeof stored.revision === "number" && Number.isFinite(stored.revision) ? stored.revision : base.revision; + const status = stored.status === "completed" ? "completed" : "in_progress"; + const access = stored.access_choice === "trial" || stored.access_choice === "full" ? stored.access_choice : null; + const qx = typeof stored.qx_estimate === "number" && Number.isFinite(stored.qx_estimate) ? stored.qx_estimate : null; + const completed = typeof stored.completed_at === "string" && stored.completed_at.trim() ? stored.completed_at : null; + + return { + schema_version: 3, + revision: rev, + status, + progress: { + stage: typeof progress.stage === "string" ? progress.stage : base.progress.stage, + branch_index: typeof progress.branch_index === "number" && Number.isFinite(progress.branch_index) ? progress.branch_index : base.progress.branch_index, + updated_at: typeof progress.updated_at === "string" && progress.updated_at.trim() ? progress.updated_at : null, + }, + consent: { + privacy_accepted: typeof consent.privacy_accepted === "boolean" ? consent.privacy_accepted : base.consent.privacy_accepted, + accepted_at: typeof consent.accepted_at === "string" && consent.accepted_at.trim() ? consent.accepted_at : null, + terms_version: typeof consent.terms_version === "string" && consent.terms_version.trim() ? consent.terms_version : base.consent.terms_version, + }, + profile: { + intent: typeof profile.intent === "string" ? profile.intent : null, + mode: typeof profile.mode === "string" ? profile.mode : null, + icp: typeof profile.icp === "string" ? profile.icp : null, + question_branch: typeof profile.question_branch === "string" ? profile.question_branch : null, + }, + responses: { + current_situation: typeof responses.current_situation === "string" ? responses.current_situation : null, + career_barriers: Array.isArray(responses.career_barriers) ? responses.career_barriers.filter((x) => typeof x === "string") : [], + desired_outcomes: Array.isArray(responses.desired_outcomes) ? responses.desired_outcomes.filter((x) => typeof x === "string") : [], + target_milestone: typeof responses.target_milestone === "string" ? responses.target_milestone : null, + weekly_time_commitment: typeof responses.weekly_time_commitment === "string" ? responses.weekly_time_commitment : null, + target_role: typeof responses.target_role === "string" ? responses.target_role : null, + target_field: typeof responses.target_field === "string" ? responses.target_field : null, + venture_industry: typeof responses.venture_industry === "string" ? responses.venture_industry : null, + experience_level: typeof responses.experience_level === "string" ? responses.experience_level : null, + work_context: typeof responses.work_context === "string" ? responses.work_context : null, + }, + import: { + method: typeof imp.method === "string" ? imp.method : base.import.method, + status: typeof imp.status === "string" ? imp.status : base.import.status, + resume_id: typeof imp.resume_id === "string" ? imp.resume_id : null, + resume_filename: typeof imp.resume_filename === "string" ? imp.resume_filename : null, + resume_summary: typeof imp.resume_summary === "string" ? imp.resume_summary : null, + linkedin_profile_id: typeof imp.linkedin_profile_id === "string" ? imp.linkedin_profile_id : null, + linkedin_url: typeof imp.linkedin_url === "string" ? imp.linkedin_url : null, + }, + access_choice: access, + qx_estimate: qx, + completed_at: completed, + }; +} + +export class OnboardingRevisionConflict extends Error { + readonly expected: number; + readonly actual: number; + constructor(expected: number, actual: number) { + super(`onboarding revision conflict: expected ${expected}, actual ${actual}`); + this.name = "OnboardingRevisionConflict"; + this.expected = expected; + this.actual = actual; + } +} + +/** + * Optimistic-concurrency guard. Throws OnboardingRevisionConflict when the + * client's expectedRevision does not match the currently stored revision. + * `expectedRevision` may be undefined only when there is no existing revision + * (initial create); any mismatch with an existing doc is a conflict. + */ +export function assertOnboardingRevision(expected: number | undefined, current: OnboardingData): void { + if (expected !== current.revision) { + throw new OnboardingRevisionConflict(expected ?? -1, current.revision); + } +} + +function asStringArray(value: unknown): string[] { + return Array.isArray(value) ? value.filter((x): x is string => typeof x === "string") : []; +} + +/** + * Derive the outbound OnboardingPayload (schema v1) from the stored v3 + * OnboardingData. This is a faithful projection — no completion math, no + * invented fields. `curator_registry_icp` is left null (the curator resolves + * the canonical ICP from `onboarding_icp` at projection time). + */ +export function deriveOnboardingPayload(data: OnboardingData): OnboardingPayload { + const experienceContext = [data.responses.experience_level, data.responses.work_context] + .filter((x) => typeof x === "string" && x.trim()) + .join(" · ") || null; + const goals = asStringArray(data.responses.desired_outcomes); + const barriers = asStringArray(data.responses.career_barriers); + const priority = goals[0] ?? barriers[0] ?? data.responses.target_milestone ?? null; + + return { + schema_version: 1, + source_revision: data.revision, + primary_intent: data.profile.intent, + mode: data.profile.mode, + question_branch: data.profile.question_branch, + onboarding_icp: data.profile.icp, + curator_registry_icp: null, + target_role: data.responses.target_role, + target_field: data.responses.target_field, + experience_context: experienceContext, + goals, + barriers, + priority, + weekly_time_commitment: data.responses.weekly_time_commitment, + }; +} + +/** + * Deep-merge an incoming PATCH `data` (full OnboardingData) into the stored + * preferences object, returning a NEW preferences object. Preserves every + * unrelated preference key (interview_preferences, resume_preferences, + * mission_preferences, target_roles, etc.) untouched. Bumps revision by one + * and stamps updated_at. + */ +export function mergeOnboardingPatch( + currentPreferences: Record, + incoming: OnboardingData, +): Record { + const updatedAt = new Date().toISOString(); + const stored = extractOnboardingData(currentPreferences.onboarding); + const prevRevision = stored.revision; + + const nextData: OnboardingData = { + ...incoming, + // The client sends the revision it based its edits on; the stored revision + // is authoritative. We bump from the previously stored revision. + revision: prevRevision + 1, + progress: { ...incoming.progress, updated_at: updatedAt }, + }; + + // Status/completed_at consistency: completed ⇒ completed_at must be set. + if (nextData.status === "completed" && !nextData.completed_at) { + nextData.completed_at = updatedAt; + } + // If regressed to in_progress, clear a stale completed_at. + if (nextData.status === "in_progress" && nextData.completed_at && incoming.completed_at === null) { + nextData.completed_at = null; + } + + return { ...currentPreferences, onboarding: nextData }; +} + +/** + * Build the next preferences object for a reset: replace `preferences.onboarding` + * with a fresh default v3 doc (revision 0) and preserve every unrelated + * preference key. Pure — no DB, no network. The revision resets to 0 so the + * next save starts a fresh optimistic-concurrency lineage. + */ +export function resetOnboardingPreferences( + currentPreferences: Record, +): Record { + return { ...currentPreferences, onboarding: defaultOnboardingData() }; +} + +// ──────────────────────────────────────────────────────────────────────────── +// Onboarding reset +// +// The onboarding gate reopens when no valid completion ledger row exists for a +// user (getLatestValidOnboardingLedgerEvent returns null). The reset deletes: +// 1. Every completion-type row in EITHER alias form (dotted like +// "onboarding.completed" or underscore like "onboarding_completed") — these +// carry the `onboarding:completed:${userId}` dedupe key. +// 2. Snapshot rows ("onboarding.snapshot.saved" / "onboarding_snapshot_saved") +// ONLY when their payload carries a completion marker (completed_at / +// completedAt at the top level or under onboarding). Intermediate step +// saves — the vast majority of snapshots — are preserved. +// Missions, curator context, and qscore baselines are never touched. Returns +// the count of rows removed for audit output. +// ──────────────────────────────────────────────────────────────────────────── + +// jsonb predicate matching the same paths completedAtFromOnboardingPayload +// inspects for snapshot payloads. Mirrors the JS validity rule in SQL so the +// reset deletes exactly the snapshots that would otherwise keep the gate shut. +const SNAPSHOT_HAS_COMPLETION = sql`${growEvents.payload}->>'completed_at' IS NOT NULL + OR ${growEvents.payload}->>'completedAt' IS NOT NULL + OR ${growEvents.payload}->'onboarding'->>'completed_at' IS NOT NULL + OR ${growEvents.payload}->'onboarding'->>'completedAt' IS NOT NULL`; + +export async function resetOnboardingLedger(userId: string): Promise { + const deleted = await db + .delete(growEvents) + .where( + and( + eq(growEvents.userId, userId), + or( + inArray(growEvents.type, [...COMPLETION_EVENT_TYPE_ALIASES]), + and( + inArray(growEvents.type, [...SNAPSHOT_EVENT_TYPE_ALIASES]), + SNAPSHOT_HAS_COMPLETION, + ), + ), + ), + ) + .returning({ id: growEvents.id }); + return deleted.length; +} diff --git a/src/routes/users.ts b/src/routes/users.ts index 51fb06b..a00e29f 100644 --- a/src/routes/users.ts +++ b/src/routes/users.ts @@ -11,6 +11,15 @@ import { import { getLatestValidOnboardingLedgerEvent, recordAndProcessOnboardingCompletion, + extractOnboardingData, + deriveOnboardingPayload, + assertOnboardingRevision, + mergeOnboardingPatch, + resetOnboardingLedger, + resetOnboardingPreferences, + defaultOnboardingData, + OnboardingRevisionConflict, + type OnboardingData, } from "../events/onboarding-ledger.js"; import { z } from "zod"; @@ -108,6 +117,98 @@ async function ensureUserServiceUser(req: Request) { return res.json() as Promise>; } +function isPlainObject(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +/** Fetch the user-service profile (GET /me) and parse preferences safely. */ +async function fetchUserProfile(req: Request): Promise> { + const res = await fetchUserService(req, "/me"); + if (!res.ok) { + const text = await res.text().catch(() => ""); + throw new Error(`user-service /me fetch failed: ${res.status} ${text}`); + } + return res.json() as Promise>; +} + +function recordPreferences(profile: Record): Record { + 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, + }; +} + +/** + * Build a synthetic PATCH /me request carrying only `{ preferences }` so the + * backend can persist the merged onboarding blob without disturbing other + * profile fields. Strips host/cookie like the other proxy helpers. + */ +function patchPreferencesRequest(req: Request, preferences: Record): Request { + const target = userServiceTarget("/me"); + const headers = new Headers(req.headers); + headers.delete("host"); + headers.delete("cookie"); + headers.set("content-type", "application/json"); + const body = JSON.stringify({ preferences }); + return new Request(target, { method: "PATCH", headers, body }); +} + +type OnboardingResetResult = + | { error: null; body: Record; status: 200 } + | { error: string; message: string; status: 502 }; + +/** + * Reusable per-user onboarding reset. Resets preferences.onboarding to the + * default v3 doc (preserving all unrelated preference keys, history, and + * curator data) and deletes ONLY the user's completion-type ledger rows so the + * onboarding gate reopens. Idempotent — a user with no onboarding state is a + * no-op success. + * + * Exported so the staging bulk-reset CLI can reuse the exact same logic + * (preferences reset + backend ledger deletion) end-to-end via the service + * token path, instead of re-implementing it. + */ +export async function resetUserOnboarding(req: Request, userId: string): Promise { + const profile = await fetchUserProfile(req); + const preferences = recordPreferences(profile); + const nextPreferences = resetOnboardingPreferences(preferences); + + const updateRes = await fetchUserService(patchPreferencesRequest(req, nextPreferences), "/me"); + if (!updateRes.ok) { + const text = await updateRes.text().catch(() => ""); + log.error({ err: text, userId, status: updateRes.status }, "failed to persist reset onboarding preferences"); + return { + error: "user_service_persist_failed", + message: "Failed to reset onboarding preferences", + status: 502, + }; + } + + const ledgerDeleted = await resetOnboardingLedger(userId); + log.info({ userId, ledgerDeleted }, "onboarding reset complete"); + + const data = extractOnboardingData(nextPreferences.onboarding); + return { + error: null, + body: { + id: userId, + reset: true, + ledgerRowsDeleted: ledgerDeleted, + data, + }, + status: 200, + }; +} + export function userRoutes() { const app = new Hono(); app.use("*", requireUser); @@ -151,90 +252,183 @@ export function userRoutes() { const userId = c.get("userId"); const state = await db.query.onboarding.findFirst({ where: eq(onboarding.userId, userId) }); const event = await getLatestValidOnboardingLedgerEvent(userId); - const data = asRecord(state?.data); - const completed = data.status === "completed" && typeof data.completed_at === "string"; + if (event) { + return c.json({ + userId, + hasOnboardingEvent: true, + onboardingEvent: { + id: event.id, + type: event.type, + occurredAt: event.occurredAt.toISOString(), + processingStatus: event.processingStatus, + }, + needsOnboarding: false, + }); + } + // Ledger missing but canonical preferences may still show completed (the + // completion row was lost/never written). Self-heal: re-run the existing + // idempotent completion recording, which re-creates the ledger row via the + // `onboarding:completed:` dedupe key. If the canonical state is NOT + // completed, the gate stays open honestly. A heal failure must surface as a + // service error — never as a false "needs onboarding" for an already-done + // user, which would force them through onboarding again. + let healed: { ok: true } | { ok: false; status: 502; error: string; message: string }; + try { + const profile = await fetchUserProfile(c.req.raw); + const preferences = recordPreferences(profile); + const data = extractOnboardingData(preferences.onboarding); + if (data.status === "completed" && data.completed_at) { + await recordAndProcessOnboardingCompletion({ + userId, + completedAt: data.completed_at, + source: "onboarding-status-heal", + context: { preferences, onboarding: data }, + }); + healed = { ok: true }; + } else { + return c.json({ + userId, + hasOnboardingEvent: false, + onboardingEvent: null, + needsOnboarding: true, + }); + } + } catch (err) { + log.error({ err, userId }, "onboarding status heal failed"); + healed = { + ok: false, + status: 502, + error: "onboarding_status_heal_failed", + message: "Onboarding status check failed; please retry", + }; + } + + if (!healed.ok) { + return c.json({ error: healed.error, message: healed.message }, healed.status); + } + + // The heal re-ran the idempotent completion recording, so a fresh read + // should now find the ledger row. Re-fetch to return a consistent shape + // (onboardingEvent populated alongside hasOnboardingEvent: true) rather than + // a contradictory true+null that downstream consumers would misread. + const fresh = await getLatestValidOnboardingLedgerEvent(userId); return c.json({ userId, - hasOnboardingEvent: Boolean(event), - onboardingEvent: event + hasOnboardingEvent: true, + onboardingEvent: fresh ? { - id: event.id, - type: event.type, - occurredAt: event.occurredAt.toISOString(), - processingStatus: event.processingStatus, - } + id: fresh.id, + type: fresh.type, + occurredAt: fresh.occurredAt.toISOString(), + processingStatus: fresh.processingStatus, + } : null, - needsOnboarding: !completed, + needsOnboarding: false, + healed: true, }); }); + // ── Onboarding preferences (canonical store: user-service preferences.onboarding) ─ + // GET returns the v3 OnboardingData (or a default v3 doc when none exists yet) + // plus its faithful projection payload and the current revision. + // PATCH performs optimistic-concurrency merge (expectedRevision must match), + // preserves all unrelated preference keys, persists access_choice, validates + // status/completed_at consistency, and fires the existing completion ledger + // side effects when status is "completed". app.get("/onboarding", async (c) => { const userId = c.get("userId"); - const state = await db.query.onboarding.findFirst({ where: eq(onboarding.userId, userId) }); - return c.json({ - id: state?.id ?? null, - data: state?.data ?? null, - payload: state?.payload ?? null, - revision: typeof asRecord(state?.data).revision === "number" ? asRecord(state?.data).revision : 0, - updatedAt: state?.updatedAt?.toISOString() ?? null, - }); + const profile = await fetchUserProfile(c.req.raw); + const preferences = recordPreferences(profile); + const data = extractOnboardingData(preferences.onboarding); + return c.json(onboardingStateResponse(userId, data)); }); app.patch("/onboarding", async (c) => { const userId = c.get("userId"); - const body = onboardingPatchSchema.parse(await c.req.json()); - const existing = await db.query.onboarding.findFirst({ where: eq(onboarding.userId, userId) }); - const existingData = asRecord(existing?.data); - const currentRevision = typeof existingData.revision === "number" ? existingData.revision : 0; - if (body.expectedRevision !== undefined && body.expectedRevision !== currentRevision) { - return c.json({ - error: "onboarding_revision_conflict", - expectedRevision: body.expectedRevision, - currentRevision, - data: existing?.data ?? null, - payload: existing?.payload ?? null, - }, 409); + const body = await c.req.json().catch(() => null) as + | { data?: unknown; expectedRevision?: unknown } + | null; + + if (!body || typeof body !== "object") { + return c.json({ error: "invalid_body", message: "Expected { data, expectedRevision }" }, 400); } - const now = new Date(); - const nextData: Record = { - ...body.data, - schema_version: 3, - revision: currentRevision + 1, - updated_at: now.toISOString(), - }; - const payload = buildOnboardingPayload(nextData); - const [saved] = await db.insert(onboarding).values({ - userId, - data: nextData, - payload, - updatedAt: now, - }).onConflictDoUpdate({ - target: onboarding.userId, - set: { data: nextData, payload, updatedAt: now }, - }).returning(); - if (!saved) throw new Error("failed_to_save_onboarding"); + const incoming = extractOnboardingData(body.data); + const expectedRevision = typeof body.expectedRevision === "number" ? body.expectedRevision : undefined; - const completedAt = nextData.status === "completed" && typeof nextData.completed_at === "string" - ? new Date(nextData.completed_at) - : null; - if (completedAt && !Number.isNaN(completedAt.getTime())) { - await recordAndProcessOnboardingCompletion({ - userId, - completedAt, - source: "onboarding-data", - context: { onboarding: nextData, payload }, - }); + // Read current profile to resolve the stored revision for the OCC check. + const profile = await fetchUserProfile(c.req.raw); + const preferences = recordPreferences(profile); + const current = extractOnboardingData(preferences.onboarding); + + try { + assertOnboardingRevision(expectedRevision, current); + } catch (err) { + if (err instanceof OnboardingRevisionConflict) { + return c.json( + { + error: "revision_conflict", + message: "Onboarding was modified by another session. Reload and retry.", + expected: err.expected, + actual: err.actual, + revision: err.actual, + }, + 409, + ); + } + throw err; } - return c.json({ - id: saved.id, - data: saved.data, - payload: saved.payload, - revision: currentRevision + 1, - updatedAt: saved.updatedAt.toISOString(), - }); + // Stamp the incoming revision with the authoritative stored revision so the + // merge helper bumps from it (the client sends the revision it read). + const incomingWithClientRev: OnboardingData = { ...incoming, revision: current.revision }; + const nextPreferences = mergeOnboardingPatch(preferences, incomingWithClientRev); + const nextData = extractOnboardingData(nextPreferences.onboarding); + + // Persist back to user-service via PATCH /me (only preferences is written). + const updateRes = await fetchUserService(patchPreferencesRequest(c.req.raw, nextPreferences), "/me"); + if (!updateRes.ok) { + const text = await updateRes.text().catch(() => ""); + log.error({ err: text, userId, status: updateRes.status }, "failed to persist onboarding preferences"); + return c.json( + { error: "user_service_persist_failed", message: "Failed to save onboarding preferences" }, + 502, + ); + } + + // Completion ledger side effects (idempotent via dedupeKey onboarding:completed:). + if (nextData.status === "completed" && nextData.completed_at) { + try { + await recordAndProcessOnboardingCompletion({ + userId, + completedAt: nextData.completed_at, + source: "onboarding-api", + context: { + preferences: nextPreferences, + onboarding: nextData, + }, + }); + } catch (err) { + log.warn({ err, userId }, "failed to run onboarding completion side effects"); + } + } + + return c.json(onboardingStateResponse(userId, nextData)); + }); + + // DELETE /onboarding — authenticated per-user reset. Reopens the onboarding + // gate by deleting ONLY the user's completion-type ledger rows (those carrying + // the onboarding:completed dedupe key) and resetting preferences.onboarding to + // the default v3 doc. Preserves unrelated preferences, snapshot history, + // missions, and curator data. Idempotent: deleting nothing is a success. + app.delete("/onboarding", async (c) => { + const userId = c.get("userId"); + const result = await resetUserOnboarding(c.req.raw, userId); + if (result.error !== null) { + return c.json({ error: result.error, message: result.message }, result.status); + } + return c.json(result.body, result.status); }); app.get("/me", async (c) => proxyUserService(c.req.raw, "/me"));