Two-stage delete with revenue archive in AdminStack
I needed a safe way to delete portals and clients without destroying paid invoice records.
The problem
AdminStack had soft delete for portals but no hard delete. Deleting a client would cascade and destroy invoices. I needed a three-tier ladder: close project (reversible), deactivate portal (soft delete), and finally hard delete with one rule: paid revenue is never destroyed.
Migration 056
I flipped two foreign keys from CASCADE to SET NULL. The portal_invoices.portal_id and invoices.client_id columns now detach instead of cascading. I added is_archived and denormalized label columns to both tables, and dropped the NOT NULL constraint on invoices.client_id so SET NULL could fire.
Archive-first ordering
Paid invoices archive in place. Unpaid invoices get purged along with their Stripe payment link deactivation and time-entry detachment. I run the archive step first, then the delete. PostgREST has no multi-statement transactions, so this ordering means a partial failure leaves an archived-but-still-linked row, not lost revenue.
Revenue totals keep working
The dashboard reads row-level fields for totals: status, amount, and paid_at. Archived paid rows keep status='paid' so they still count. Only the per-client labels come from a join, so I added a denormalized-label fallback in the portal-invoices route and a client_name fallback plus an Archived badge in the invoices list.
Two-stage UI
The trash icon now works in two stages. Active portals deactivate on first click. Inactive portals hard delete on second click. The delete dialogs show file counts, message counts, whether the client uploaded files, paid-versus-unpaid invoice totals, and point to the export action. Each dialog requires typing the portal or client name to confirm.
Post-launch fix
I tested the typed-confirmation guard in production and it failed silently on mismatch. I moved the guard into ConfirmDialog via an optional requiredValue prop. The button now stays disabled until the input matches exactly, and a hint shows when it does not match. I also added client / project labels to portal delete dialogs because similarly-named clients like "Lisa" and "Lisa S" were hard to tell apart.
Client delete
Client delete now archives invoices before deleting the client row. Portals have no client_id foreign key (they link via project_id), so nothing cascades. Portals stay live but unlinked. The dialog discloses this and shows portal counts.
Storage cleanup
I delete database rows first, then do best-effort storage removal and log any orphans. This matches the pattern from the fileId route. I found 11 historical orphans during the audit and left them for a separate sweep.
What shipped
Five commits across two sessions. Migration 056 applied manually in production. The portal DELETE endpoint gained a ?mode=hard branch. The client DELETE endpoint got archive-before-delete logic. The invoices list and portal-invoices aggregation route handle null foreign keys. The UI shows two-stage trash icons and typed-confirmation dialogs with full disclosure.