feat(onboarding): persist resettable user state

This commit is contained in:
-Puter
2026-07-12 19:22:05 +05:30
parent a1de355ea0
commit 62208d385f
6 changed files with 1068 additions and 72 deletions

126
scripts/onboarding-bulk-reset.ts Executable file
View File

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

View File

@@ -70,5 +70,45 @@ assert.equal(
"invalid completion timestamps should normalize to a valid ISO timestamp",
);
// ── Regression: underscore completion aliases must be valid (reset deletes them) ──
// The reset only reopens the gate if EVERY alias form that isValidOnboardingLedgerEvent
// accepts is also in the reset delete scope. Pin both sides agree per alias.
for (const underscore of ["onboarding_completed", "user_onboarding_completed", "profile_onboarding_completed"]) {
const dotted = normalizeOnboardingEventType(underscore);
assert.equal(
isValidOnboardingLedgerEvent({ type: underscore, payload: {} }),
true,
`${underscore} should satisfy onboarding status (normalized to ${dotted})`,
);
}
// ── Regression: completedAtFromOnboardingPayload paths mirrored by reset SQL ──
// resetOnboardingLedger's jsonb predicate checks top-level completed_at/completedAt
// and onboarding.completed_at/completedAt. Pin that the JS helper (used for status
// validity) agrees a snapshot carrying any of these is a completion snapshot.
for (const [label, payload] of [
["top-level completed_at", { completed_at: now }],
["top-level completedAt", { completedAt: now }],
["onboarding.completed_at", { onboarding: { completed_at: now } }],
["onboarding.completedAt", { onboarding: { completedAt: now } }],
] as const) {
assert.equal(
completedAtFromOnboardingPayload(payload),
now,
`completion timestamp extracted from ${label}`,
);
assert.equal(
isValidOnboardingLedgerEvent({ type: "onboarding.snapshot.saved", payload }),
true,
`snapshot with ${label} is a completion snapshot`,
);
}
// ── Regression: intermediate snapshots (no completion marker) stay invalid ──
assert.equal(
completedAtFromOnboardingPayload({ onboarding: { current_step: 2 } }),
undefined,
"intermediate snapshot has no completion timestamp",
);
console.log("onboarding-ledger tests passed");
process.exit(0);

View File

@@ -0,0 +1,160 @@
import assert from "node:assert/strict";
import {
DEFAULT_ONBOARDING_DATA,
defaultOnboardingData,
extractOnboardingData,
assertOnboardingRevision,
deriveOnboardingPayload,
mergeOnboardingPatch,
OnboardingRevisionConflict,
} from "../src/events/onboarding-ledger.js";
/**
* Focused tests for the onboarding preference helpers (pure — no DB/network).
* Covers: default v3 shape, revision conflict, partial update merge,
* status/completed_at consistency + payload derivation, and unrelated
* preference preservation. Matches the scripts/X.test.ts convention
* (node:assert over pure functions).
*/
// ── 1. default v3 response ───────────────────────────────────────────────────
{
const def = defaultOnboardingData();
assert.equal(def.schema_version, 3, "default is v3");
assert.equal(def.revision, 0);
assert.equal(def.status, "in_progress");
assert.equal(def.access_choice, null);
assert.equal(def.qx_estimate, null);
assert.equal(def.completed_at, null);
assert.equal(def.consent.privacy_accepted, false);
assert.equal(def.progress.stage, "consent");
// DEFAULT constant must match the factory.
assert.deepEqual(def, DEFAULT_ONBOARDING_DATA);
// Factory returns a fresh clone, not the shared constant.
assert.notEqual(def, DEFAULT_ONBOARDING_DATA);
}
// extractOnboardingData returns the default when nothing is stored.
{
const empty = extractOnboardingData(undefined);
assert.equal(empty.schema_version, 3);
assert.equal(empty.revision, 0);
const fromNonObject = extractOnboardingData("nonsense");
assert.equal(fromNonObject.status, "in_progress");
}
// ── 2. revision conflict (optimistic concurrency) ───────────────────────────
{
const current = extractOnboardingData({ revision: 5 });
// Matching revision is accepted (no throw).
assert.doesNotThrow(() => assertOnboardingRevision(5, current));
// Mismatched revision throws the typed conflict.
assert.throws(
() => assertOnboardingRevision(4, current),
(err) => err instanceof OnboardingRevisionConflict && err.expected === 4 && err.actual === 5,
);
}
// ── 3. partial update merge (revision bump + progress stamp) ────────────────
{
const stored = extractOnboardingData({ revision: 2 });
const incoming = { ...stored, revision: 2, profile: { ...stored.profile, mode: "founder" } };
const merged = mergeOnboardingPatch({ onboarding: stored }, incoming);
const next = extractOnboardingData(merged.onboarding);
assert.equal(next.revision, 3, "revision bumps by one from the stored value");
assert.equal(next.profile.mode, "founder", "incoming profile change is applied");
assert.ok(next.progress.updated_at, "progress.updated_at is stamped on save");
}
// ── 4. status/completed_at consistency + payload derivation ─────────────────
{
const base = defaultOnboardingData();
const completed = {
...base,
revision: 0,
status: "completed" as const,
profile: { ...base.profile, intent: "land-a-role", mode: "student", icp: "intern", question_branch: "student" },
responses: {
...base.responses,
career_barriers: ["no-network"],
desired_outcomes: ["interviews", "offer"],
target_role: "Product Intern",
target_field: "technical",
weekly_time_commitment: "5-10",
experience_level: "0-2",
work_context: "audience",
},
// deliberately omit completed_at; merge must stamp it.
};
const merged = mergeOnboardingPatch({}, completed);
const next = extractOnboardingData(merged.onboarding);
assert.equal(next.status, "completed");
assert.ok(next.completed_at, "completed status without completed_at is stamped");
// Payload is a faithful projection — no invented completion math.
const payload = deriveOnboardingPayload(next);
assert.equal(payload.schema_version, 1);
assert.equal(payload.source_revision, next.revision);
assert.equal(payload.mode, "student");
assert.equal(payload.onboarding_icp, "intern");
assert.equal(payload.target_role, "Product Intern");
assert.equal(payload.target_field, "technical");
assert.deepEqual(payload.goals, ["interviews", "offer"]);
assert.deepEqual(payload.barriers, ["no-network"]);
assert.equal(payload.weekly_time_commitment, "5-10");
assert.equal(payload.experience_context, "0-2 · audience");
}
// completed_at is cleared when regressing to in_progress with an explicit null.
{
const completed = { ...defaultOnboardingData(), status: "completed" as const, completed_at: "2026-07-10T00:00:00.000Z" };
const regressed = { ...completed, status: "in_progress" as const, completed_at: null };
const merged = mergeOnboardingPatch({}, regressed);
const next = extractOnboardingData(merged.onboarding);
assert.equal(next.status, "in_progress");
assert.equal(next.completed_at, null, "stale completed_at is cleared on regression");
}
// ── 5. unrelated preference preservation ────────────────────────────────────
{
const preferences = {
onboarding: { revision: 1, status: "in_progress" },
interview_preferences: { focus_areas: ["behavioral"] },
resume_preferences: { target_title: "Data Scientist" },
mission_preferences: { active_goal: "land-offer" },
target_roles: ["Product Intern"],
target_companies: ["Acme"],
};
const incoming = { ...extractOnboardingData(preferences.onboarding), revision: 1, profile: { intent: "x", mode: "student", icp: "intern", question_branch: "student" } };
const merged = mergeOnboardingPatch(preferences, incoming);
// The onboarding blob is updated.
assert.equal(extractOnboardingData(merged.onboarding).revision, 2);
// Every unrelated preference key is preserved verbatim.
assert.deepEqual(merged.interview_preferences, { focus_areas: ["behavioral"] });
assert.deepEqual(merged.resume_preferences, { target_title: "Data Scientist" });
assert.deepEqual(merged.mission_preferences, { active_goal: "land-offer" });
assert.deepEqual(merged.target_roles, ["Product Intern"]);
assert.deepEqual(merged.target_companies, ["Acme"]);
}
// ── 6. access_choice persistence (trial | full | null) ───────────────────────
{
const base = defaultOnboardingData();
const trial = { ...base, revision: 0, access_choice: "trial" as const };
const mergedTrial = mergeOnboardingPatch({}, trial);
assert.equal(extractOnboardingData(mergedTrial.onboarding).access_choice, "trial");
const full = { ...base, revision: 0, access_choice: "full" as const };
const mergedFull = mergeOnboardingPatch({}, full);
assert.equal(extractOnboardingData(mergedFull.onboarding).access_choice, "full");
// Garbage access_choice is rejected back to null by extraction.
const garbage = extractOnboardingData({ access_choice: "pro" });
assert.equal(garbage.access_choice, null);
}
console.log("onboarding-preferences: all assertions passed");

View File

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