Files
Jyotisha/frontend/src/lib/account-deletion-worker.ts
T
Jesse_ChenandClaude Opus 5.5 96adbce3a9 feat(account): self-service deletion with a 7-day cooling-off period
Request signs out everywhere and freezes paid routes (423 + DB triggers);
signing in within 7 days shows the pending gate with 撤销注销. A periodic
idempotent worker purges personal content afterwards and keeps finance
rows against a tombstoned identity. Read-only admin list.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N4f2nya58RoRu4yEmJgRGE
2026-09-30 09:42:11 +08:00

65 lines
2.6 KiB
TypeScript

import "server-only";
import {
ACCOUNT_DELETION_FIRST_TICK_MS,
ACCOUNT_DELETION_TICK_MS,
purgeErrorCode,
runAccountDeletionTick,
} from "@/lib/account-deletion-worker-core";
import { createAdminSupabaseClient } from "@/lib/supabase/admin";
type WorkerGlobal = typeof globalThis & { jyotishaAccountDeletionWorker?: { stop: () => void } };
async function tick(): Promise<void> {
const admin = createAdminSupabaseClient();
await runAccountDeletionTick({
now: () => new Date(),
listDue: async (nowIso, limit, maxAttempts) => {
const { data, error } = await admin
.from("account_deletion_requests")
.select("id,attempt_count")
.eq("status", "pending")
.lte("scheduled_for", nowIso)
.order("scheduled_for", { ascending: true })
.limit(limit * 2);
if (error || !Array.isArray(data)) return [];
// Attempts are filtered here: the self-hosted query builder has no lt()
// (an unimplemented filter once broke a list for two weeks, BUG-990).
return (data as { id: unknown; attempt_count: unknown }[])
.filter((row) => typeof row.attempt_count !== "number" || row.attempt_count < maxAttempts)
.slice(0, limit)
.map((row) => String(row.id));
},
purge: async (requestId) => {
const { data, error } = await admin.rpc("purge_deleted_account", { p_request_id: requestId });
if (error) return { ok: false, code: purgeErrorCode(error.message) };
const status = (data as { status?: unknown } | null)?.status;
return { ok: true, status: typeof status === "string" ? status : "unknown" };
},
markFailed: async (requestId, code) => {
await admin.rpc("mark_account_deletion_attempt_failed", { p_request_id: requestId, p_error_code: code });
},
log: (line) => console.info(line),
});
}
/**
* One unref'ed timer per server process: first run a minute after start, then
* every 30 minutes. Several instances are safe — the purge locks the request
* row and a completed request is skipped.
*/
export function startAccountDeletionWorker(): void {
const state = globalThis as WorkerGlobal;
if (state.jyotishaAccountDeletionWorker) return;
const run = () => {
tick().catch((error: unknown) => {
console.error(`[account-deletion-worker] tick failed reason=${error instanceof Error ? error.name : "UnknownError"}`);
});
};
const first = setTimeout(run, ACCOUNT_DELETION_FIRST_TICK_MS);
const every = setInterval(run, ACCOUNT_DELETION_TICK_MS);
first.unref?.();
every.unref?.();
state.jyotishaAccountDeletionWorker = { stop: () => { clearTimeout(first); clearInterval(every); } };
}