From e2d72223c80285c6a5d5a3ff74493d25d9490f8e Mon Sep 17 00:00:00 2001 From: olsch01 Date: Wed, 18 Mar 2026 08:59:27 -0400 Subject: [PATCH] feat: add test data cleanup utility script Interactive CLI for managing test organizations, users, and tenant schemas. Supports list, delete-org, delete-user, purge-all, and reseed commands with dry-run mode and safety guards for platform owner protection. Co-Authored-By: Claude Opus 4.6 --- scripts/cleanup-test-data.ts | 788 +++++++++++++++++++++++++++++++++++ scripts/package.json | 3 +- 2 files changed, 790 insertions(+), 1 deletion(-) create mode 100644 scripts/cleanup-test-data.ts diff --git a/scripts/cleanup-test-data.ts b/scripts/cleanup-test-data.ts new file mode 100644 index 0000000..5ac9a87 --- /dev/null +++ b/scripts/cleanup-test-data.ts @@ -0,0 +1,788 @@ +#!/usr/bin/env tsx +/** + * Test Data Cleanup Utility + * + * Interactive CLI for managing test organizations, users, and tenant data. + * Supports listing, selective deletion, full purge, and re-seeding. + * + * Usage: + * cd scripts + * npx tsx cleanup-test-data.ts [options] + * + * Commands: + * list Show all organizations and users + * delete-org Delete an organization (drops tenant schema + shared data) + * delete-user Delete a user (cascades through all related tables) + * purge-all Remove ALL orgs/users except platform owner + * reseed Purge all, then re-run db/seed/seed.sql + * + * Options: + * --dry-run Show what would be deleted without executing + * --force Skip confirmation prompts + * + * Environment: + * DATABASE_URL - PostgreSQL connection string (reads from ../.env) + */ + +import * as dotenv from 'dotenv'; +import { resolve } from 'path'; +import { readFileSync } from 'fs'; +import { Pool } from 'pg'; +import * as readline from 'readline'; + +// ── Load environment ──────────────────────────────────────────────────────── + +dotenv.config({ path: resolve(__dirname, '..', '.env') }); + +const DATABASE_URL = process.env.DATABASE_URL; +if (!DATABASE_URL) { + console.error(red('✗ DATABASE_URL not set. Check your .env file.')); + process.exit(1); +} + +// ── CLI colors ────────────────────────────────────────────────────────────── + +function red(s: string): string { return `\x1b[31m${s}\x1b[0m`; } +function green(s: string): string { return `\x1b[32m${s}\x1b[0m`; } +function yellow(s: string): string { return `\x1b[33m${s}\x1b[0m`; } +function cyan(s: string): string { return `\x1b[36m${s}\x1b[0m`; } +function bold(s: string): string { return `\x1b[1m${s}\x1b[0m`; } +function dim(s: string): string { return `\x1b[2m${s}\x1b[0m`; } + +// ── CLI argument parsing ──────────────────────────────────────────────────── + +const args = process.argv.slice(2); +const command = args.find(a => !a.startsWith('--')) || ''; +const target = args.filter(a => !a.startsWith('--')).slice(1).join(' '); +const dryRun = args.includes('--dry-run'); +const force = args.includes('--force'); + +// ── Helpers ───────────────────────────────────────────────────────────────── + +function isUUID(s: string): boolean { + return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(s); +} + +function padRight(s: string, len: number): string { + return s.length >= len ? s.substring(0, len) : s + ' '.repeat(len - s.length); +} + +function truncate(s: string, len: number): string { + return s.length > len ? s.substring(0, len - 1) + '…' : s; +} + +function formatDate(d: Date | string | null): string { + if (!d) return '—'; + const date = typeof d === 'string' ? new Date(d) : d; + return date.toISOString().split('T')[0]; +} + +async function confirm(prompt: string): Promise { + if (force) return true; + if (dryRun) return false; + + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + return new Promise((resolve) => { + rl.question(`${prompt} [y/N]: `, (answer) => { + rl.close(); + resolve(answer.trim().toLowerCase() === 'y'); + }); + }); +} + +function logDryRun(sql: string): void { + console.log(dim(` [DRY RUN] ${sql}`)); +} + +// ── Database pool ─────────────────────────────────────────────────────────── + +const pool = new Pool({ connectionString: DATABASE_URL }); + +async function query(sql: string, params?: any[]): Promise { + const result = await pool.query(sql, params); + return result.rows; +} + +// ── List command ──────────────────────────────────────────────────────────── + +async function listAll(): Promise { + console.log(bold('\n📋 Organizations\n')); + + const orgs = await query(` + SELECT + o.id, o.name, o.schema_name, o.status, o.plan_level, + o.billing_interval, o.collection_method, + o.stripe_customer_id, o.stripe_subscription_id, + o.trial_ends_at, o.created_at, + COUNT(uo.id) AS user_count + FROM shared.organizations o + LEFT JOIN shared.user_organizations uo ON uo.organization_id = o.id + GROUP BY o.id + ORDER BY o.created_at + `); + + if (orgs.length === 0) { + console.log(dim(' No organizations found.\n')); + } else { + // Header + console.log( + ' ' + + padRight('Name', 30) + + padRight('Status', 12) + + padRight('Plan', 16) + + padRight('Billing', 10) + + padRight('Users', 7) + + padRight('Stripe Customer', 22) + + 'Created' + ); + console.log(' ' + '─'.repeat(110)); + + for (const o of orgs) { + const statusColor = o.status === 'active' ? green : o.status === 'trial' ? cyan : o.status === 'past_due' ? yellow : red; + console.log( + ' ' + + padRight(truncate(o.name, 28), 30) + + padRight(statusColor(o.status), 12 + 9) + // +9 for ANSI escape codes + padRight(`${o.plan_level}/${o.billing_interval || 'month'}`, 16) + + padRight(String(o.user_count), 7) + + padRight(o.stripe_customer_id ? truncate(o.stripe_customer_id, 20) : '—', 22) + + formatDate(o.created_at) + ); + } + console.log(dim(`\n ${orgs.length} organization(s) total`)); + + // Show IDs for reference + console.log(dim('\n IDs:')); + for (const o of orgs) { + console.log(dim(` ${o.name}: ${o.id}`)); + console.log(dim(` schema: ${o.schema_name}`)); + } + } + + console.log(bold('\n👤 Users\n')); + + const users = await query(` + SELECT + u.id, u.email, u.first_name, u.last_name, + u.is_superadmin, u.is_platform_owner, + u.last_login_at, u.created_at, + COALESCE( + STRING_AGG( + o.name || ' (' || uo.role || ')', + ', ' + ), + '—' + ) AS memberships, + COUNT(uo.id) AS org_count + FROM shared.users u + LEFT JOIN shared.user_organizations uo ON uo.user_id = u.id + LEFT JOIN shared.organizations o ON o.id = uo.organization_id + GROUP BY u.id + ORDER BY u.created_at + `); + + if (users.length === 0) { + console.log(dim(' No users found.\n')); + } else { + // Header + console.log( + ' ' + + padRight('Email', 35) + + padRight('Name', 25) + + padRight('Flags', 18) + + padRight('Orgs', 6) + + 'Created' + ); + console.log(' ' + '─'.repeat(100)); + + for (const u of users) { + const flags: string[] = []; + if (u.is_platform_owner) flags.push(cyan('owner')); + if (u.is_superadmin) flags.push(yellow('super')); + + const name = [u.first_name, u.last_name].filter(Boolean).join(' ') || '—'; + console.log( + ' ' + + padRight(truncate(u.email, 33), 35) + + padRight(truncate(name, 23), 25) + + padRight(flags.length ? flags.join(', ') : '—', 18 + (flags.length * 9)) + + padRight(String(u.org_count), 6) + + formatDate(u.created_at) + ); + } + console.log(dim(`\n ${users.length} user(s) total`)); + + // Show memberships + console.log(dim('\n Memberships:')); + for (const u of users) { + console.log(dim(` ${u.email}: ${u.memberships}`)); + } + } + + // Tenant schemas + console.log(bold('\n🗄️ Tenant Schemas\n')); + const schemas = await query(` + SELECT schema_name + FROM information_schema.schemata + WHERE schema_name LIKE 'tenant_%' + ORDER BY schema_name + `); + + if (schemas.length === 0) { + console.log(dim(' No tenant schemas found.\n')); + } else { + for (const s of schemas) { + console.log(` • ${s.schema_name}`); + } + console.log(dim(`\n ${schemas.length} tenant schema(s) total\n`)); + } +} + +// ── Delete organization ───────────────────────────────────────────────────── + +async function deleteOrg(identifier: string): Promise { + if (!identifier) { + console.error(red('✗ Please provide an organization name or ID.')); + console.log(' Usage: npx tsx cleanup-test-data.ts delete-org '); + process.exit(1); + } + + // Look up org + const whereClause = isUUID(identifier) ? 'id = $1' : 'LOWER(name) = LOWER($1)'; + const orgs = await query( + `SELECT id, name, schema_name, status, stripe_customer_id, stripe_subscription_id + FROM shared.organizations WHERE ${whereClause}`, + [identifier] + ); + + if (orgs.length === 0) { + console.error(red(`✗ Organization not found: ${identifier}`)); + process.exit(1); + } + + const org = orgs[0]; + + // Show what will be deleted + console.log(bold(`\n🏢 Delete Organization: ${org.name}\n`)); + console.log(` ID: ${org.id}`); + console.log(` Schema: ${org.schema_name}`); + console.log(` Status: ${org.status}`); + + if (org.stripe_customer_id) { + console.log(yellow(`\n ⚠ Stripe Customer: ${org.stripe_customer_id}`)); + console.log(yellow(` You should manually delete/archive this customer in the Stripe Dashboard.`)); + } + if (org.stripe_subscription_id) { + console.log(yellow(` ⚠ Stripe Subscription: ${org.stripe_subscription_id}`)); + console.log(yellow(` You should manually cancel this subscription in the Stripe Dashboard.`)); + } + + // Count related data + const userCount = (await query( + 'SELECT COUNT(*) as cnt FROM shared.user_organizations WHERE organization_id = $1', + [org.id] + ))[0].cnt; + + const inviteCount = (await query( + 'SELECT COUNT(*) as cnt FROM shared.invitations WHERE organization_id = $1', + [org.id] + ))[0].cnt; + + // Check if tenant schema exists + const schemaExists = (await query( + `SELECT COUNT(*) as cnt FROM information_schema.schemata WHERE schema_name = $1`, + [org.schema_name] + ))[0].cnt > 0; + + console.log(`\n Will delete:`); + console.log(` • Organization record from shared.organizations`); + console.log(` • ${userCount} user-organization membership(s) (users themselves are preserved)`); + console.log(` • ${inviteCount} invitation(s)`); + if (schemaExists) { + console.log(red(` • DROP SCHEMA ${org.schema_name} CASCADE (all tenant financial data)`)); + } else { + console.log(dim(` • Schema ${org.schema_name} does not exist (skip)`)); + } + console.log(` • Related rows in: onboarding_progress, stripe_events, email_log`); + + if (dryRun) { + console.log(yellow('\n [DRY RUN] No changes made.\n')); + logDryRun(`DROP SCHEMA IF EXISTS ${org.schema_name} CASCADE`); + logDryRun(`DELETE FROM shared.onboarding_progress WHERE organization_id = '${org.id}'`); + logDryRun(`DELETE FROM shared.stripe_events WHERE ... (related to org)`); + logDryRun(`DELETE FROM shared.organizations WHERE id = '${org.id}'`); + return; + } + + const confirmed = await confirm(red(`\n This is destructive and cannot be undone. Proceed?`)); + if (!confirmed) { + console.log(dim(' Aborted.\n')); + return; + } + + // Execute deletion + const client = await pool.connect(); + try { + await client.query('BEGIN'); + + // 1. Drop tenant schema + if (schemaExists) { + console.log(` Dropping schema ${org.schema_name}...`); + await client.query(`DROP SCHEMA IF EXISTS "${org.schema_name}" CASCADE`); + } + + // 2. Clean up shared tables with org FK + await client.query('DELETE FROM shared.onboarding_progress WHERE organization_id = $1', [org.id]); + await client.query('DELETE FROM shared.invitations WHERE organization_id = $1', [org.id]); + + // 3. Delete organization (cascades to user_organizations, invite_tokens) + await client.query('DELETE FROM shared.organizations WHERE id = $1', [org.id]); + + await client.query('COMMIT'); + console.log(green(`\n ✓ Organization "${org.name}" and schema "${org.schema_name}" deleted successfully.\n`)); + } catch (err) { + await client.query('ROLLBACK'); + console.error(red(`\n ✗ Error deleting organization: ${(err as Error).message}\n`)); + throw err; + } finally { + client.release(); + } +} + +// ── Delete user ───────────────────────────────────────────────────────────── + +async function deleteUser(identifier: string): Promise { + if (!identifier) { + console.error(red('✗ Please provide a user email or ID.')); + console.log(' Usage: npx tsx cleanup-test-data.ts delete-user '); + process.exit(1); + } + + const whereClause = isUUID(identifier) ? 'id = $1' : 'LOWER(email) = LOWER($1)'; + const users = await query( + `SELECT id, email, first_name, last_name, is_superadmin, is_platform_owner + FROM shared.users WHERE ${whereClause}`, + [identifier] + ); + + if (users.length === 0) { + console.error(red(`✗ User not found: ${identifier}`)); + process.exit(1); + } + + const user = users[0]; + const name = [user.first_name, user.last_name].filter(Boolean).join(' ') || '(no name)'; + + // Platform owner protection + if (user.is_platform_owner) { + console.error(red(`\n ✗ Cannot delete platform owner: ${user.email}`)); + console.error(red(' The platform owner account is protected and cannot be removed.\n')); + process.exit(1); + } + + console.log(bold(`\n👤 Delete User: ${user.email}\n`)); + console.log(` ID: ${user.id}`); + console.log(` Name: ${name}`); + + if (user.is_superadmin) { + console.log(yellow(' ⚠ This user is a SUPERADMIN')); + } + + // Count related data + const memberships = await query( + `SELECT o.name, uo.role FROM shared.user_organizations uo + JOIN shared.organizations o ON o.id = uo.organization_id + WHERE uo.user_id = $1`, + [user.id] + ); + + const tokenCounts = { + refresh: (await query('SELECT COUNT(*) as cnt FROM shared.refresh_tokens WHERE user_id = $1', [user.id]))[0].cnt, + passkeys: (await query('SELECT COUNT(*) as cnt FROM shared.user_passkeys WHERE user_id = $1', [user.id]))[0].cnt, + loginHistory: (await query('SELECT COUNT(*) as cnt FROM shared.login_history WHERE user_id = $1', [user.id]))[0].cnt, + }; + + console.log(`\n Will delete:`); + console.log(` • User record from shared.users`); + console.log(` • ${memberships.length} org membership(s):`); + for (const m of memberships) { + console.log(` – ${m.name} (${m.role})`); + } + console.log(` • ${tokenCounts.refresh} refresh token(s)`); + console.log(` • ${tokenCounts.passkeys} passkey(s)`); + console.log(` • ${tokenCounts.loginHistory} login history record(s)`); + console.log(` • Related: password_reset_tokens, invite_tokens (cascade)`); + + if (dryRun) { + console.log(yellow('\n [DRY RUN] No changes made.\n')); + logDryRun(`DELETE FROM shared.users WHERE id = '${user.id}'`); + return; + } + + const confirmMsg = user.is_superadmin + ? red(`\n ⚠ This is a SUPERADMIN account. Are you SURE you want to delete it?`) + : red(`\n This is destructive and cannot be undone. Proceed?`); + + const confirmed = await confirm(confirmMsg); + if (!confirmed) { + console.log(dim(' Aborted.\n')); + return; + } + + // Execute deletion (cascade handles related tables) + await query('DELETE FROM shared.users WHERE id = $1', [user.id]); + console.log(green(`\n ✓ User "${user.email}" deleted successfully.\n`)); +} + +// ── Purge all ─────────────────────────────────────────────────────────────── + +async function purgeAll(): Promise { + console.log(bold('\n🔥 Purge All Test Data\n')); + + // Gather current state + const orgs = await query( + `SELECT id, name, schema_name, stripe_customer_id, stripe_subscription_id + FROM shared.organizations ORDER BY name` + ); + + const userCount = (await query( + 'SELECT COUNT(*) as cnt FROM shared.users WHERE is_platform_owner = false' + ))[0].cnt; + + const platformOwner = (await query( + 'SELECT email FROM shared.users WHERE is_platform_owner = true' + )); + + const schemas = await query( + `SELECT schema_name FROM information_schema.schemata WHERE schema_name LIKE 'tenant_%' ORDER BY schema_name` + ); + + // Stripe warnings + const stripeOrgs = orgs.filter((o: any) => o.stripe_customer_id || o.stripe_subscription_id); + + console.log(` This will:`); + console.log(red(` • Drop ${schemas.length} tenant schema(s):`)); + for (const s of schemas) { + console.log(red(` – ${s.schema_name}`)); + } + console.log(red(` • Delete ${orgs.length} organization(s):`)); + for (const o of orgs) { + console.log(red(` – ${o.name}`)); + } + console.log(red(` • Delete ${userCount} non-owner user(s)`)); + console.log(` • Truncate: user_organizations, invitations, refresh_tokens,`); + console.log(` password_reset_tokens, invite_tokens, user_passkeys,`); + console.log(` login_history, ai_recommendation_log, stripe_events,`); + console.log(` onboarding_progress, email_log`); + console.log(green(` • Preserve: platform owner (${platformOwner.length ? platformOwner[0].email : 'none found'})`)); + console.log(green(` • Preserve: cd_rates (market data)`)); + + if (stripeOrgs.length > 0) { + console.log(yellow('\n ⚠ Stripe data that should be cleaned up manually:')); + for (const o of stripeOrgs) { + if (o.stripe_customer_id) { + console.log(yellow(` Customer: ${o.stripe_customer_id} (${o.name})`)); + } + if (o.stripe_subscription_id) { + console.log(yellow(` Subscription: ${o.stripe_subscription_id} (${o.name})`)); + } + } + } + + if (dryRun) { + console.log(yellow('\n [DRY RUN] No changes made.\n')); + for (const s of schemas) { + logDryRun(`DROP SCHEMA "${s.schema_name}" CASCADE`); + } + logDryRun('TRUNCATE shared.user_organizations, shared.invitations, ...'); + logDryRun('DELETE FROM shared.organizations'); + logDryRun("DELETE FROM shared.users WHERE is_platform_owner = false"); + return; + } + + const confirmed = await confirm(red(`\n ⚠ THIS WILL DESTROY ALL DATA. Are you absolutely sure?`)); + if (!confirmed) { + console.log(dim(' Aborted.\n')); + return; + } + + const client = await pool.connect(); + try { + await client.query('BEGIN'); + + // 1. Drop all tenant schemas + for (const s of schemas) { + console.log(` Dropping schema ${s.schema_name}...`); + await client.query(`DROP SCHEMA IF EXISTS "${s.schema_name}" CASCADE`); + } + + // 2. Truncate shared junction/log tables (order matters for FK constraints) + console.log(' Truncating shared tables...'); + + // Tables with FK to users AND organizations — truncate first + await client.query('DELETE FROM shared.user_organizations'); + await client.query('DELETE FROM shared.invitations'); + await client.query('DELETE FROM shared.invite_tokens'); + await client.query('DELETE FROM shared.onboarding_progress'); + + // Tables with FK to users only + await client.query('DELETE FROM shared.refresh_tokens'); + await client.query('DELETE FROM shared.password_reset_tokens'); + await client.query('DELETE FROM shared.user_passkeys'); + await client.query('DELETE FROM shared.login_history'); + + // Tables with FK to organizations (ON DELETE SET NULL) + await client.query('DELETE FROM shared.ai_recommendation_log'); + await client.query('DELETE FROM shared.stripe_events'); + await client.query('DELETE FROM shared.email_log'); + + // 3. Delete organizations + console.log(' Deleting organizations...'); + await client.query('DELETE FROM shared.organizations'); + + // 4. Delete non-owner users + console.log(' Deleting non-owner users...'); + await client.query('DELETE FROM shared.users WHERE is_platform_owner = false'); + + await client.query('COMMIT'); + + console.log(green(`\n ✓ Purge complete.`)); + console.log(green(` Dropped ${schemas.length} schema(s), deleted ${orgs.length} org(s), deleted ${userCount} user(s).`)); + if (platformOwner.length) { + console.log(green(` Platform owner preserved: ${platformOwner[0].email}\n`)); + } + } catch (err) { + await client.query('ROLLBACK'); + console.error(red(`\n ✗ Error during purge: ${(err as Error).message}\n`)); + throw err; + } finally { + client.release(); + } +} + +// ── Reseed ────────────────────────────────────────────────────────────────── + +async function reseed(): Promise { + console.log(bold('\n🌱 Purge All + Re-Seed\n')); + console.log(' This will purge all test data and then run db/seed/seed.sql'); + console.log(' to restore the default test environment.\n'); + + if (!dryRun && !force) { + const confirmed = await confirm(red(' This will destroy all data and re-seed. Proceed?')); + if (!confirmed) { + console.log(dim(' Aborted.\n')); + return; + } + // Set force for the inner purge to avoid double-prompting + (global as any).__forceOverride = true; + } + + // Run purge + const origForce = force; + try { + // Temporarily force purge to skip its own confirmation + if (!dryRun) { + Object.defineProperty(global, '__forceOverride', { value: true, writable: true, configurable: true }); + } + await purgeAllInternal(); + } finally { + delete (global as any).__forceOverride; + } + + if (dryRun) { + logDryRun('Execute db/seed/seed.sql'); + console.log(yellow('\n [DRY RUN] No changes made.\n')); + return; + } + + // Run seed SQL + console.log('\n Running seed script...'); + const seedPath = resolve(__dirname, '..', 'db', 'seed', 'seed.sql'); + let seedSql: string; + try { + seedSql = readFileSync(seedPath, 'utf-8'); + } catch (err) { + console.error(red(` ✗ Could not read seed file: ${seedPath}`)); + console.error(red(` ${(err as Error).message}\n`)); + process.exit(1); + } + + const client = await pool.connect(); + try { + await client.query(seedSql); + console.log(green(`\n ✓ Re-seed complete. Database restored to seed state.\n`)); + } catch (err) { + console.error(red(`\n ✗ Error running seed: ${(err as Error).message}\n`)); + throw err; + } finally { + client.release(); + } +} + +/** + * Internal purge that respects __forceOverride to skip confirmation + * when called from reseed(). + */ +async function purgeAllInternal(): Promise { + const orgs = await query( + `SELECT id, name, schema_name, stripe_customer_id, stripe_subscription_id + FROM shared.organizations ORDER BY name` + ); + + const userCount = (await query( + 'SELECT COUNT(*) as cnt FROM shared.users WHERE is_platform_owner = false' + ))[0].cnt; + + const platformOwner = await query( + 'SELECT email FROM shared.users WHERE is_platform_owner = true' + ); + + const schemas = await query( + `SELECT schema_name FROM information_schema.schemata WHERE schema_name LIKE 'tenant_%' ORDER BY schema_name` + ); + + const stripeOrgs = orgs.filter((o: any) => o.stripe_customer_id || o.stripe_subscription_id); + + if (stripeOrgs.length > 0) { + console.log(yellow(' ⚠ Stripe data that should be cleaned up manually:')); + for (const o of stripeOrgs) { + if (o.stripe_customer_id) console.log(yellow(` Customer: ${o.stripe_customer_id} (${o.name})`)); + if (o.stripe_subscription_id) console.log(yellow(` Subscription: ${o.stripe_subscription_id} (${o.name})`)); + } + } + + if (dryRun) { + for (const s of schemas) { + logDryRun(`DROP SCHEMA "${s.schema_name}" CASCADE`); + } + logDryRun('DELETE FROM shared tables...'); + logDryRun('DELETE FROM shared.organizations'); + logDryRun("DELETE FROM shared.users WHERE is_platform_owner = false"); + return; + } + + const client = await pool.connect(); + try { + await client.query('BEGIN'); + + for (const s of schemas) { + console.log(` Dropping schema ${s.schema_name}...`); + await client.query(`DROP SCHEMA IF EXISTS "${s.schema_name}" CASCADE`); + } + + console.log(' Truncating shared tables...'); + await client.query('DELETE FROM shared.user_organizations'); + await client.query('DELETE FROM shared.invitations'); + await client.query('DELETE FROM shared.invite_tokens'); + await client.query('DELETE FROM shared.onboarding_progress'); + await client.query('DELETE FROM shared.refresh_tokens'); + await client.query('DELETE FROM shared.password_reset_tokens'); + await client.query('DELETE FROM shared.user_passkeys'); + await client.query('DELETE FROM shared.login_history'); + await client.query('DELETE FROM shared.ai_recommendation_log'); + await client.query('DELETE FROM shared.stripe_events'); + await client.query('DELETE FROM shared.email_log'); + + console.log(' Deleting organizations...'); + await client.query('DELETE FROM shared.organizations'); + + console.log(' Deleting non-owner users...'); + await client.query('DELETE FROM shared.users WHERE is_platform_owner = false'); + + await client.query('COMMIT'); + + console.log(green(` ✓ Purged ${schemas.length} schema(s), ${orgs.length} org(s), ${userCount} user(s).`)); + if (platformOwner.length) { + console.log(green(` Platform owner preserved: ${platformOwner[0].email}`)); + } + } catch (err) { + await client.query('ROLLBACK'); + console.error(red(` ✗ Error during purge: ${(err as Error).message}`)); + throw err; + } finally { + client.release(); + } +} + +// ── Help ──────────────────────────────────────────────────────────────────── + +function showHelp(): void { + console.log(` +${bold('HOA LedgerIQ — Test Data Cleanup Utility')} + +${bold('Usage:')} + npx tsx cleanup-test-data.ts [target] [options] + +${bold('Commands:')} + ${cyan('list')} Show all organizations, users, and tenant schemas + ${cyan('delete-org')} Delete an organization and its tenant schema + ${cyan('delete-user')} Delete a user and all related data + ${cyan('purge-all')} Remove ALL data except the platform owner + ${cyan('reseed')} Purge all, then re-run db/seed/seed.sql + +${bold('Options:')} + ${dim('--dry-run')} Show what would happen without making changes + ${dim('--force')} Skip confirmation prompts + +${bold('Examples:')} + npx tsx cleanup-test-data.ts list + npx tsx cleanup-test-data.ts delete-org "Sunrise Valley HOA" + npx tsx cleanup-test-data.ts delete-org 550e8400-e29b-41d4-a716-446655440000 + npx tsx cleanup-test-data.ts delete-user admin@sunrisevalley.org + npx tsx cleanup-test-data.ts delete-user admin@sunrisevalley.org --dry-run + npx tsx cleanup-test-data.ts purge-all --dry-run + npx tsx cleanup-test-data.ts reseed --force + +${bold('Safety:')} + • Platform owner account (is_platform_owner=true) is ${green('never deleted')} + • Superadmin deletions require extra confirmation + • Stripe customer/subscription IDs are shown as warnings for manual cleanup + • cd_rates market data is ${green('always preserved')} +`); +} + +// ── Main ──────────────────────────────────────────────────────────────────── + +async function main(): Promise { + if (dryRun) { + console.log(yellow('\n ── DRY RUN MODE ── No changes will be made ──\n')); + } + + try { + switch (command) { + case 'list': + await listAll(); + break; + case 'delete-org': + await deleteOrg(target); + break; + case 'delete-user': + await deleteUser(target); + break; + case 'purge-all': + await purgeAll(); + break; + case 'reseed': + await reseed(); + break; + case 'help': + case '--help': + case '-h': + showHelp(); + break; + default: + if (command) { + console.error(red(`\n ✗ Unknown command: ${command}\n`)); + } + showHelp(); + process.exit(command ? 1 : 0); + } + } catch (err) { + console.error(red(`\nFatal error: ${(err as Error).message}`)); + process.exit(1); + } finally { + await pool.end(); + } +} + +main(); diff --git a/scripts/package.json b/scripts/package.json index e1c3406..bbdf22e 100644 --- a/scripts/package.json +++ b/scripts/package.json @@ -4,7 +4,8 @@ "private": true, "description": "Standalone scripts for HOA LedgerIQ platform (cron jobs, data fetching)", "scripts": { - "fetch-cd-rates": "tsx fetch-cd-rates.ts" + "fetch-cd-rates": "tsx fetch-cd-rates.ts", + "cleanup": "tsx cleanup-test-data.ts" }, "dependencies": { "dotenv": "^16.4.7",