Skip to content

Commit f206e83

Browse files
committed
feat(admin): real hard-delete user button + auth.db writable handle
The existing "Delete data" button on the admin user-detail page was a forwarder to an apps/api endpoint that doesn't exist, so clicks did nothing beyond writing an audit row. Tim repeatedly needs to recycle the same test phone number during the WhatsApp pool-join debug, which the soft-delete left blocked because the user + phone_otp + rate_limit rows all stayed put. Replaces the fake soft-delete with a real hard-delete that mirrors the SQL Tim was running by hand. lib/db.ts - New authDbWritable() parallel to gameDbWritable(); read handles stay readonly so accidental writes through gameDb()/authDb() fail. lib/live.ts - hardDeleteUser(userId): single transaction per DB. Wipes auth.session, auth.phone_otp (by phone), auth.rate_limit (by phone digits), auth.user, game.brackets, game.syndicate_owners_membership. - For each *active* membership removed, decrements syndicates.member_count atomically inside the same txn (pending / denied rows never bumped it, so they don't decrement here). - Returns counts + per-syndicate decrement record for the audit log. app/api/users/[id]/data/route.ts - Replaces the apps/api forwarder with a direct call to hardDeleteUser. Still gated to super-admin only; writes a user.hard_delete audit entry capturing deletion counts + member_count decrements. app/(authed)/users/[id]/Customer360Tabs.tsx - Renames button "Delete data" → "Delete user", confirm-dialog body rewritten to spell out the full wipe scope and that it cannot be undone. Existing confirm phrase (type the user id verbatim) is kept as the friction step. On success, bounces to /users (the detail page would 404 on the now-gone id). Signed-off-by: Tim Thomas <0800tim@gmail.com>
1 parent 6582508 commit f206e83

4 files changed

Lines changed: 214 additions & 37 deletions

File tree

apps/admin/app/(authed)/users/[id]/Customer360Tabs.tsx

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ export function Customer360Tabs({ userId, data, role, profileSlot }: Customer360
8080
className="text-xs px-2 py-1 rounded bg-danger-600 hover:bg-danger-500 text-ink-50"
8181
data-testid="delete-data-btn"
8282
>
83-
Delete data
83+
Delete user
8484
</button>
8585
)}
8686
</div>
@@ -128,10 +128,10 @@ export function Customer360Tabs({ userId, data, role, profileSlot }: Customer360
128128

129129
{confirmDelete && (
130130
<ConfirmDialog
131-
title="Delete user data?"
132-
body={`Soft-deletes the user record, predictions, and personal data for ${userId}.\n\nThis is reversible within 30 days via the audit log; after that the row is hard-purged.`}
131+
title="Delete user permanently?"
132+
body={`HARD deletes ${userId} across auth.db + game.db: the user row, all sessions, OTP + rate-limit state for their phone, every bracket they've saved, and every pool membership (with member_count decremented for any active pools).\n\nThis cannot be undone. Type the user id below to confirm.`}
133133
confirmPhrase={userId}
134-
confirmLabel={busy ? "Deleting..." : "Delete data"}
134+
confirmLabel={busy ? "Deleting" : "Delete user"}
135135
destructive
136136
onCancel={() => setConfirmDelete(false)}
137137
onConfirm={async () => {
@@ -142,10 +142,16 @@ export function Customer360Tabs({ userId, data, role, profileSlot }: Customer360
142142
method: "DELETE",
143143
});
144144
if (!r.ok) {
145-
setError(`Delete failed (${r.status}).`);
145+
const body = (await r.json().catch(() => ({}))) as {
146+
error?: string;
147+
};
148+
setError(`Delete failed (${r.status}${body.error ? `: ${body.error}` : ""}).`);
146149
return;
147150
}
148151
setConfirmDelete(false);
152+
// Bounce to the users list — the detail page would 404
153+
// immediately on this id anyway.
154+
window.location.href = "/users";
149155
} finally {
150156
setBusy(false);
151157
}
Lines changed: 33 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,27 @@
11
/**
2-
* DELETE /api/users/[id]/data — soft-delete a user's data.
2+
* DELETE /api/users/[id]/data — HARD delete a user across auth.db +
3+
* game.db. Gated to super-admin and audited.
34
*
4-
* Gated to super-admin. The actual delete propagates to apps/api (which is
5-
* responsible for cascading the soft-delete across game-service, crm-bridge,
6-
* social-publisher etc. — the dashboard never deletes upstream data
7-
* directly). The dashboard simply records the request in its audit log and
8-
* forwards to apps/api `/v1/admin/users/:id/data`.
5+
* Previously this was a soft-delete forwarder to apps/api (which is a
6+
* stub), so the button on the user-detail page did nothing meaningful.
7+
* Tim 2026-06-05: real testing of the WhatsApp pool-join flow requires
8+
* being able to repeatedly recycle the same phone number, so we now do
9+
* the hard delete locally via `hardDeleteUser` in lib/live.ts. Mirrors
10+
* the SQL pattern used by the one-off shell delete that triggered this.
11+
*
12+
* Wipe scope:
13+
* - auth.db: session, phone_otp (by phone), rate_limit (by phone), user
14+
* - game.db: brackets, syndicate_owners_membership
15+
* - game.db: syndicates.member_count -1 per *active* membership removed
16+
*
17+
* Irreversible. The confirm modal on the client requires typing the
18+
* user_id verbatim before the button fires.
919
*/
1020

1121
import { NextResponse, type NextRequest } from "next/server";
1222
import { writeAudit } from "@/lib/audit";
1323
import { readSession } from "@/lib/auth";
14-
import { upstreamGet } from "@/lib/upstream-fetch";
24+
import { hardDeleteUser } from "@/lib/live";
1525

1626
export const runtime = "nodejs";
1727
export const dynamic = "force-dynamic";
@@ -26,34 +36,25 @@ export async function DELETE(_req: NextRequest, props: { params: Promise<{ id: s
2636
return NextResponse.json({ error: "forbidden" }, { status: 403 });
2737
}
2838

29-
const apiBase = process.env.VTORN_API_BASE ?? "http://localhost:3310";
30-
// We use upstreamGet only for its swallowing semantics — but a DELETE needs
31-
// an explicit fetch. Inline the same swallow-on-error pattern.
32-
let upstreamOk = true;
33-
try {
34-
const r = await fetch(
35-
`${apiBase}/v1/admin/users/${encodeURIComponent(params.id)}/data`,
36-
{ method: "DELETE", cache: "no-store" },
37-
);
38-
upstreamOk = r.ok;
39-
} catch {
40-
upstreamOk = false;
39+
const userId = (params.id ?? "").trim();
40+
if (!userId) {
41+
return NextResponse.json({ error: "bad_request" }, { status: 400 });
4142
}
42-
// Reference upstreamGet so the import isn't dead while we keep its
43-
// signature available for future GET-based health checks.
44-
void upstreamGet;
43+
44+
const outcome = hardDeleteUser(userId);
4545

4646
await writeAudit(session, {
47-
action: "user.data.delete",
48-
target: params.id,
49-
after: { soft_deleted: true, upstream_ok: upstreamOk },
47+
action: "user.hard_delete",
48+
target: userId,
49+
after: {
50+
status: outcome.status,
51+
deleted: outcome.deleted,
52+
member_count_decrements: outcome.member_count_decrements,
53+
},
5054
});
5155

52-
if (!upstreamOk) {
53-
return NextResponse.json(
54-
{ ok: false, error: "upstream_unavailable", queued: true },
55-
{ status: 202 },
56-
);
57-
}
58-
return NextResponse.json({ ok: true });
56+
return NextResponse.json({
57+
ok: outcome.status === "deleted",
58+
...outcome,
59+
});
5960
}

apps/admin/lib/db.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ function resolveDbPath(envKey: string, fallback: string): string {
2929
}
3030

3131
let _authDb: DB | null = null;
32+
let _authDbRw: DB | null = null;
3233
let _gameDb: DB | null = null;
3334
let _gameDbRw: DB | null = null;
3435
let _oddsDb: DB | null = null;
@@ -84,6 +85,25 @@ export function gameDbWritable(): DB | null {
8485
return _gameDbRw;
8586
}
8687

88+
/**
89+
* Writable auth.db connection. Mirrors {@link gameDbWritable}'s
90+
* rationale: kept separate from the readonly handle so accidental
91+
* writes through the read path still fail fast. Used by admin actions
92+
* that delete user records (see hardDeleteUser in lib/live.ts).
93+
*/
94+
export function authDbWritable(): DB | null {
95+
if (_authDbRw) return _authDbRw;
96+
const p = resolveDbPath("ADMIN_AUTH_DB_PATH", "apps/auth-sms/data/auth.db");
97+
if (!existsSync(p)) {
98+
// eslint-disable-next-line no-console
99+
console.warn(`[admin/db] auth.db not found at ${p}; admin writes disabled`);
100+
return null;
101+
}
102+
_authDbRw = new Database(p, { readonly: false, fileMustExist: true });
103+
_authDbRw.pragma("journal_mode = WAL");
104+
return _authDbRw;
105+
}
106+
87107
export function oddsDb(): DB | null {
88108
if (_oddsDb) return _oddsDb;
89109
const p = resolveDbPath(

apps/admin/lib/live.ts

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818

1919
import {
2020
authDb,
21+
authDbWritable,
2122
gameDb,
2223
gameDbWritable,
2324
oddsDb,
@@ -655,6 +656,155 @@ export function removeMemberFromSyndicate(
655656
};
656657
}
657658

659+
// ---------------- user hard-delete ------------------------------------
660+
661+
export interface DeletedCounts {
662+
user: number;
663+
sessions: number;
664+
phone_otp: number;
665+
rate_limit: number;
666+
brackets: number;
667+
pool_memberships: number;
668+
}
669+
670+
export interface DeleteUserOutcome {
671+
status: "deleted" | "not_found";
672+
user_id: string;
673+
phone: string | null;
674+
deleted: DeletedCounts;
675+
/** Pools whose cached member_count was decremented because the user
676+
* had an *active* membership row in them. Pending / denied rows
677+
* don't bump the counter on insert so they don't decrement here. */
678+
member_count_decrements: Array<{ slug: string; new_count: number }>;
679+
}
680+
681+
/**
682+
* Hard-delete a user across auth.db + game.db. The button in the admin
683+
* user-detail page calls this through `/api/users/[id]/data`. Use with
684+
* care — irreversible. The flow Tim's testing repeatedly during the
685+
* pool-join debug needed this because phone numbers are unique-per-user
686+
* and the old soft-delete left rows that blocked re-test.
687+
*
688+
* Cleanup matrix (in transaction per DB):
689+
* auth.session → DELETE WHERE user_id
690+
* auth.phone_otp → DELETE WHERE phone
691+
* auth.rate_limit → DELETE WHERE key LIKE phone digits
692+
* auth.user → DELETE WHERE id
693+
* game.brackets → DELETE WHERE user_id
694+
* game.syndicate_owners_membership → DELETE WHERE user_id
695+
* game.syndicates.member_count → -1 per active membership row deleted
696+
*
697+
* Tables we deliberately DON'T touch:
698+
* - game.users (legacy view) — unused write path
699+
* - game.bracket_import_audit / user_api_keys — not joined by user_id
700+
* on the deletion contract; safe to keep as historical record
701+
* - auth.email_otp — phone-keyed flow only for now
702+
*/
703+
export function hardDeleteUser(userId: string): DeleteUserOutcome {
704+
const adb = authDbWritable();
705+
const gdb = gameDbWritable();
706+
const counts: DeletedCounts = {
707+
user: 0,
708+
sessions: 0,
709+
phone_otp: 0,
710+
rate_limit: 0,
711+
brackets: 0,
712+
pool_memberships: 0,
713+
};
714+
const decrements: Array<{ slug: string; new_count: number }> = [];
715+
716+
// Resolve phone first; we need it for phone-keyed cleanups even after
717+
// the `user` row is gone.
718+
let phone: string | null = null;
719+
if (adb) {
720+
const row = adb
721+
.prepare(`SELECT phone FROM user WHERE id = ?`)
722+
.get(userId) as { phone: string | null } | undefined;
723+
phone = row?.phone?.trim() || null;
724+
}
725+
726+
// game.db cleanup runs first so we can capture which pools owe a
727+
// member_count decrement. Pending / denied rows don't count.
728+
if (gdb) {
729+
const txn = gdb.transaction(() => {
730+
// Find active memberships before deleting them so we know which
731+
// syndicate counters to decrement.
732+
const activeMemberships = gdb
733+
.prepare(
734+
`SELECT s.id AS syndicate_id, s.slug
735+
FROM syndicate_owners_membership m
736+
JOIN syndicates s ON s.id = m.syndicate_id
737+
WHERE m.user_id = ?
738+
AND (m.status IS NULL OR m.status = 'active')`,
739+
)
740+
.all(userId) as Array<{ syndicate_id: string; slug: string }>;
741+
742+
counts.brackets = gdb
743+
.prepare(`DELETE FROM brackets WHERE user_id = ?`)
744+
.run(userId).changes ?? 0;
745+
746+
counts.pool_memberships = gdb
747+
.prepare(`DELETE FROM syndicate_owners_membership WHERE user_id = ?`)
748+
.run(userId).changes ?? 0;
749+
750+
// Decrement member_count for each active membership we just removed.
751+
const decStmt = gdb.prepare(
752+
`UPDATE syndicates
753+
SET member_count = MAX(0, member_count - 1)
754+
WHERE id = ?
755+
RETURNING slug, member_count`,
756+
);
757+
for (const m of activeMemberships) {
758+
const r = decStmt.get(m.syndicate_id) as
759+
| { slug: string; member_count: number }
760+
| undefined;
761+
if (r) decrements.push({ slug: r.slug, new_count: r.member_count });
762+
}
763+
});
764+
txn();
765+
}
766+
767+
// auth.db cleanup. Phone-keyed rows clear regardless of user-row hit
768+
// so we don't leak OTP / rate-limit state on a no-op delete.
769+
if (adb) {
770+
const txn = adb.transaction(() => {
771+
counts.sessions = adb
772+
.prepare(`DELETE FROM session WHERE user_id = ?`)
773+
.run(userId).changes ?? 0;
774+
if (phone) {
775+
counts.phone_otp = adb
776+
.prepare(`DELETE FROM phone_otp WHERE phone = ?`)
777+
.run(phone).changes ?? 0;
778+
// rate_limit.key is shaped like 'phone:642...:otp-issued'; LIKE
779+
// matches without anchoring the wrapping prefix/suffix.
780+
const phoneDigits = phone.replace(/^\+/, "");
781+
counts.rate_limit = adb
782+
.prepare(`DELETE FROM rate_limit WHERE key LIKE ?`)
783+
.run(`%${phoneDigits}%`).changes ?? 0;
784+
}
785+
counts.user = adb
786+
.prepare(`DELETE FROM user WHERE id = ?`)
787+
.run(userId).changes ?? 0;
788+
});
789+
txn();
790+
}
791+
792+
const hit =
793+
counts.user +
794+
counts.brackets +
795+
counts.pool_memberships +
796+
counts.sessions >
797+
0;
798+
799+
return {
800+
status: hit ? "deleted" : "not_found",
801+
user_id: userId,
802+
phone,
803+
deleted: counts,
804+
member_count_decrements: decrements,
805+
};
806+
}
807+
658808
// ---------------- API keys ---------------------------------------------
659809

660810
interface ApiKeyDbRow {

0 commit comments

Comments
 (0)