Files
growqr-backend/scripts/onboarding-bulk-reset.ts
-Puter e50be827ca fix(scripts): repair onboarding-bulk-reset with A2A user-service endpoint
Rewire Phase 1 to call the user-service POST /api/v1/users/onboarding-reset A2A
endpoint (targeting by clerk_id) instead of the per-user authenticated DELETE
route, which could not reach all users. A 404 from user-service means the user
has no profile (backend-only) — a clean skip that does not block Phase 2.

Phase 2 (backend ledger reset via resetOnboardingLedger) now runs ONLY after
Phase 1 succeeds or is skipped; a non-404 pref error blocks the ledger for that
user to avoid inconsistent state. Triple guard (NODE_ENV=staging,
ONBOARDING_BULK_RESET_ALLOWED=true, --confirm) and aggregate-only output are
preserved. Add source-structure assertions for the CLI contract.
2026-07-12 20:50:17 +05:30

195 lines
7.3 KiB
TypeScript
Executable File

#!/usr/bin/env tsx
/**
* Staging-only bulk onboarding reset CLI.
*
* Resets onboarding for EVERY user in the backend `users` table (the
* Clerk-mirrored enrollment source) in two phases:
*
* Phase 1 — user-service preferences reset (A2A).
* Calls POST /api/v1/users/onboarding-reset on user-service with the A2A
* key. This resets preferences.onboarding to the default in-progress v3
* doc, preserving all unrelated preference keys. It targets users by
* clerk_id WITHOUT impersonating a Clerk session. A 404 means the user has
* no user-service profile (backend-only user) — a documented skip that
* never blocks Phase 2.
*
* Phase 2 — backend completion ledger reset (direct, idempotent).
* Calls resetOnboardingLedger(userId) for every backend user whose Phase 1
* succeeded OR was skipped (404 backend-only). A Phase 1 error (non-404
* failure) blocks Phase 2 for that user to avoid inconsistent state
* (preferences say "completed" but ledger says not). This deletes only
* completion-type ledger rows (dotted AND underscore aliases) plus
* completion-bearing snapshots, preserving intermediate snapshots,
* missions, curator context, and qscore baselines.
*
* GUARDS — all three must hold or the script aborts before any network call:
* 1. NODE_ENV=staging (refuses production/development)
* 2. ONBOARDING_BULK_RESET_ALLOWED=true (explicit opt-in flag)
* 3. --confirm on the CLI (typed acknowledgement)
*
* Usage:
* NODE_ENV=staging ONBOARDING_BULK_RESET_ALLOWED=true \
* npx tsx scripts/onboarding-bulk-reset.ts --confirm
*
* Output: aggregate-only audit counts (no per-user PII) and a summary.
*
* This script is INTENTIONALLY not wired to any HTTP surface. It is a
* staging-only operations tool; bulk reset must never be exposed as a public
* endpoint. Per-user reset is the authenticated DELETE /users/onboarding route.
*/
import { eq, asc } from "drizzle-orm";
import { db } from "../src/db/client.js";
import { users } from "../src/db/schema.js";
import { config } from "../src/config.js";
import { log } from "../src/log.js";
import { resetOnboardingLedger } from "../src/events/onboarding-ledger.js";
type PrefOutcome =
| { userId: string; ok: true }
| { userId: string; ok: false; skipped: true; reason: string; status: number }
| { userId: string; ok: false; skipped: false; error: string; status: number };
type LedgerOutcome =
| { userId: string; ok: true; ledgerRowsDeleted: number }
| { userId: string; ok: false; error: string };
function assertStagingGuard(argv: string[]): void {
const isStaging = config.nodeEnv === "staging";
const allowed = process.env.ONBOARDING_BULK_RESET_ALLOWED === "true";
const confirmed = argv.includes("--confirm");
const failures: string[] = [];
if (!isStaging) failures.push(`NODE_ENV must be "staging" (got "${config.nodeEnv}")`);
if (!allowed) failures.push("ONBOARDING_BULK_RESET_ALLOWED must be set to \"true\"");
if (!confirmed) failures.push("--confirm argument is required");
if (failures.length > 0) {
console.error("onboarding-bulk-reset: ABORTED — guard checks failed:");
for (const f of failures) console.error(` - ${f}`);
console.error("");
console.error("This script is staging-only and will reset onboarding for EVERY user.");
console.error("Re-run with all guards satisfied to proceed.");
process.exit(2);
}
}
function userServiceUrl(): string {
return config.userServiceUrl.replace(/\/$/, "");
}
/**
* Phase 1: reset preferences.onboarding via the user-service A2A endpoint.
* A 404 means the user has no user-service profile (backend-only) — a clean
* skip that does NOT block the backend ledger reset.
*/
async function resetPreferences(clerkId: string, a2aKey: string): Promise<PrefOutcome> {
const res = await fetch(`${userServiceUrl()}/api/v1/users/onboarding-reset`, {
method: "POST",
headers: {
authorization: `Bearer ${a2aKey}`,
"content-type": "application/json",
},
body: JSON.stringify({ clerk_id: clerkId }),
});
if (res.status === 404) {
return { userId: clerkId, ok: false, skipped: true, reason: "no user-service profile (backend-only)", status: 404 };
}
if (!res.ok) {
const text = await res.text().catch(() => "");
return { userId: clerkId, ok: false, skipped: false, error: text || res.statusText, status: res.status };
}
return { userId: clerkId, ok: true };
}
/**
* Phase 2: directly reset the backend completion ledger for a user.
* Always runs after Phase 1 (success or skip) — backend-only users still need
* their ledger cleared. Idempotent.
*/
async function resetLedger(userId: string): Promise<LedgerOutcome> {
try {
const ledgerRowsDeleted = await resetOnboardingLedger(userId);
return { userId, ok: true, ledgerRowsDeleted };
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return { userId, ok: false, error: message };
}
}
async function main() {
assertStagingGuard(process.argv.slice(2));
const a2aKey = config.a2aAllowedKey;
if (!a2aKey) {
console.error("onboarding-bulk-reset: ABORTED — A2A_ALLOWED_KEY is not configured");
process.exit(2);
}
const allUsers = await db.select({ id: users.id }).from(users).orderBy(asc(users.id));
const total = allUsers.length;
console.log(`onboarding-bulk-reset: targeting ${total} user(s)`);
console.log(`onboarding-bulk-reset: NODE_ENV=${config.nodeEnv}, guard=on`);
console.log(`onboarding-bulk-reset: user-service=${userServiceUrl()}`);
let prefOk = 0;
let prefSkipped = 0;
let prefErrors = 0;
let ledgerOk = 0;
let ledgerErrors = 0;
let ledgerTotal = 0;
let ledgerBlocked = 0;
for (const u of allUsers) {
// Phase 1: user-service preferences reset (A2A).
const pref = await resetPreferences(u.id, a2aKey);
if (pref.ok) {
prefOk += 1;
} else if (pref.skipped) {
prefSkipped += 1;
} else {
prefErrors += 1;
}
// Phase 2: backend ledger reset — ONLY after Phase 1 succeeds or is
// skipped (404 / backend-only). A pref error (non-404 failure) leaves
// preferences.onboarding untouched, so clearing the ledger here would
// create an inconsistent state (prefs say "completed", ledger says not).
// Skip the ledger for errored users so an operator can retry after fix.
if (!pref.ok && !pref.skipped) {
ledgerBlocked += 1;
continue;
}
const ledger = await resetLedger(u.id);
if (ledger.ok) {
ledgerOk += 1;
ledgerTotal += ledger.ledgerRowsDeleted;
} else {
ledgerErrors += 1;
}
}
console.log("");
console.log("onboarding-bulk-reset: summary (aggregate only — no per-user PII)");
console.log(` total users: ${total}`);
console.log(` preferences reset: ${prefOk} ok, ${prefSkipped} skipped (backend-only), ${prefErrors} error(s)`);
console.log(` ledger reset: ${ledgerOk} ok, ${ledgerErrors} error(s), ${ledgerBlocked} blocked by pref error, ${ledgerTotal} row(s) deleted`);
const hadErrors = prefErrors > 0 || ledgerErrors > 0;
log.info(
{ total, prefOk, prefSkipped, prefErrors, ledgerOk, ledgerErrors, ledgerBlocked, ledgerTotal },
"onboarding bulk reset complete",
);
process.exit(hadErrors ? 1 : 0);
}
main().catch((err) => {
console.error("onboarding-bulk-reset: fatal", err);
process.exit(1);
});