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.
195 lines
7.3 KiB
TypeScript
Executable File
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);
|
|
});
|