"use server" import { revalidatePath } from "next/cache" import { and, eq } from "drizzle-orm" import { db } from "@/lib/db" import { account_deletion_requests } from "@/lib/db/schema" import { getSessionUser, isAdminUser } from "@/lib/session" import { LEGAL } from "@/lib/legal" import { sendEmail, accountDeletionRequestedHtml } from "@/lib/email/send" // ============================================================================ // GDPR self-service actions (Settings → Privacy & Data). // // Deletion is a two-step, grace-period flow: the request schedules a hard // delete LEGAL.dataDeletionDays out (the retention window promised on /gdpr); // the gdpr cron executes it. Until then the account stays usable and the user // can cancel. Data export is a GET route (/api/gdpr/export), not an action, // so the browser can download it as a file. // ============================================================================ const SETTINGS_PATH = "/settings/privacy" export type DeletionRequestDTO = { id: string status: "pending" | "cancelled" | "completed" scheduled_for: string created_at: string } export async function requestAccountDeletion(input: { confirmEmail: string reason?: string }): Promise { const user = await getSessionUser() if (!user) throw new Error("Unauthorized") // Admins manage the platform — deleting one from self-service risks locking // everyone out. They can be removed via the admin panel by another admin. if (isAdminUser(user as { id?: string; email?: string; role?: string | null })) { throw new Error("Admin accounts cannot be deleted from self-service. Contact another administrator.") } const typed = (input.confirmEmail ?? "").trim().toLowerCase() if (!typed || typed !== user.email.toLowerCase()) { throw new Error("The email you typed doesn't match your account email.") } const existing = await db.query.account_deletion_requests.findFirst({ where: and( eq(account_deletion_requests.user_id, user.id), eq(account_deletion_requests.status, "pending") ), }) if (existing) throw new Error("Your account is already scheduled for deletion.") const scheduledFor = new Date(Date.now() + LEGAL.dataDeletionDays * 24 * 60 * 60 * 1000).toISOString() let row: typeof account_deletion_requests.$inferSelect try { ;[row] = await db .insert(account_deletion_requests) .values({ user_id: user.id, email: user.email, reason: (input.reason ?? "").trim().slice(0, 500) || null, scheduled_for: scheduledFor, }) .returning() } catch { // The partial unique index makes a double-submit race land here. throw new Error("Your account is already scheduled for deletion.") } await sendEmail({ to: user.email, subject: "Your account deletion is scheduled", html: accountDeletionRequestedHtml({ name: user.name || user.email, scheduledDate: new Date(scheduledFor).toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric", }), graceDays: LEGAL.dataDeletionDays, }), }) revalidatePath(SETTINGS_PATH) return { id: row.id, status: row.status, scheduled_for: row.scheduled_for, created_at: row.created_at } } export async function cancelAccountDeletion(): Promise { const user = await getSessionUser() if (!user) throw new Error("Unauthorized") const [row] = await db .update(account_deletion_requests) .set({ status: "cancelled", cancelled_at: new Date().toISOString() }) .where( and( eq(account_deletion_requests.user_id, user.id), eq(account_deletion_requests.status, "pending") ) ) .returning({ id: account_deletion_requests.id }) if (!row) throw new Error("No pending deletion request found.") revalidatePath(SETTINGS_PATH) }