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 <noreply@anthropic.com>
This commit is contained in:
788
scripts/cleanup-test-data.ts
Normal file
788
scripts/cleanup-test-data.ts
Normal file
@@ -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 <command> [options]
|
||||||
|
*
|
||||||
|
* Commands:
|
||||||
|
* list Show all organizations and users
|
||||||
|
* delete-org <name-or-id> Delete an organization (drops tenant schema + shared data)
|
||||||
|
* delete-user <email-or-id> 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<boolean> {
|
||||||
|
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<any[]> {
|
||||||
|
const result = await pool.query(sql, params);
|
||||||
|
return result.rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── List command ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function listAll(): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
if (!identifier) {
|
||||||
|
console.error(red('✗ Please provide an organization name or ID.'));
|
||||||
|
console.log(' Usage: npx tsx cleanup-test-data.ts delete-org <name-or-id>');
|
||||||
|
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<void> {
|
||||||
|
if (!identifier) {
|
||||||
|
console.error(red('✗ Please provide a user email or ID.'));
|
||||||
|
console.log(' Usage: npx tsx cleanup-test-data.ts delete-user <email-or-id>');
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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 <command> [target] [options]
|
||||||
|
|
||||||
|
${bold('Commands:')}
|
||||||
|
${cyan('list')} Show all organizations, users, and tenant schemas
|
||||||
|
${cyan('delete-org')} <name-or-id> Delete an organization and its tenant schema
|
||||||
|
${cyan('delete-user')} <email-or-id> 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<void> {
|
||||||
|
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();
|
||||||
@@ -4,7 +4,8 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"description": "Standalone scripts for HOA LedgerIQ platform (cron jobs, data fetching)",
|
"description": "Standalone scripts for HOA LedgerIQ platform (cron jobs, data fetching)",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"fetch-cd-rates": "tsx fetch-cd-rates.ts"
|
"fetch-cd-rates": "tsx fetch-cd-rates.ts",
|
||||||
|
"cleanup": "tsx cleanup-test-data.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"dotenv": "^16.4.7",
|
"dotenv": "^16.4.7",
|
||||||
|
|||||||
Reference in New Issue
Block a user