Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history

account-deletion.ts

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

account-deletion.tsBlame233 lines · 1 contributor
c63b860Claude1/**
2 * Block P5 — Account deletion with a 30-day grace period.
3 *
4 * The privacy policy (src/routes/legal/privacy.tsx §5) promises:
5 * "Account data: retained while your account is active and for thirty
6 * (30) days after account deletion, after which we intend to purge it."
7 *
8 * Flow:
9 * 1. User clicks "Delete my account" in /settings.
10 * → `scheduleAccountDeletion()` marks `users.deleted_at = now()`,
11 * `users.deletion_scheduled_for = now() + 30 days`, deletes all
12 * sessions, audits `account.deletion.scheduled`, sends a
13 * confirmation email.
14 * 2. During the grace period the user can sign back in. The /login
15 * handler calls `cancelAccountDeletion()` which clears both columns,
16 * audits, and sends a "welcome back" email.
17 * 3. The autopilot `account-purge` task hard-deletes any rows whose
18 * `deletion_scheduled_for` is in the past. Capped at 50 users per
19 * tick. Per-user errors are logged + skipped — never thrown — so a
20 * single FK violation can't stall the queue.
21 *
22 * Nothing here throws. All DB / email failures are logged and swallowed.
23 */
24
25import { eq, lt } from "drizzle-orm";
26import { db } from "../db";
27import { sessions, users } from "../db/schema";
28import { sendEmail, absoluteUrl } from "./email";
29import { audit } from "./notify";
30
31/** Grace period before a scheduled deletion becomes a hard purge. */
32export const GRACE_PERIOD_DAYS = 30;
33/** Default per-tick cap for `purgeScheduledAccounts`. */
34export const DEFAULT_PURGE_CAP = 50;
35
36const MS_PER_DAY = 24 * 60 * 60 * 1000;
37
38export async function scheduleAccountDeletion(
39 userId: string,
40 opts: { now?: Date } = {}
41): Promise<{ ok: boolean; scheduledFor: Date }> {
42 const now = opts.now ?? new Date();
43 const scheduledFor = new Date(now.getTime() + GRACE_PERIOD_DAYS * MS_PER_DAY);
44
45 let user: { id: string; username: string; email: string } | null = null;
46 try {
47 const rows = await db
48 .update(users)
49 .set({
50 deletedAt: now,
51 deletionScheduledFor: scheduledFor,
52 updatedAt: now,
53 })
54 .where(eq(users.id, userId))
55 .returning({
56 id: users.id,
57 username: users.username,
58 email: users.email,
59 });
60 user = rows[0] ?? null;
61 } catch (err) {
62 console.error("[account-deletion] schedule update failed:", err);
63 return { ok: false, scheduledFor };
64 }
65
66 if (!user) return { ok: false, scheduledFor };
67
68 try {
69 await db.delete(sessions).where(eq(sessions.userId, userId));
70 } catch (err) {
71 console.error("[account-deletion] session purge failed:", err);
72 }
73
74 await audit({
75 userId,
76 action: "account.deletion.scheduled",
77 targetType: "user",
78 targetId: userId,
79 metadata: { scheduledFor: scheduledFor.toISOString() },
80 });
81
82 const tpl = renderScheduledEmail({ username: user.username, scheduledFor });
83 await sendEmail({ to: user.email, subject: tpl.subject, text: tpl.text });
84
85 return { ok: true, scheduledFor };
86}
87
88export async function cancelAccountDeletion(
89 userId: string
90): Promise<{ ok: boolean }> {
91 let user: { id: string; username: string; email: string } | null = null;
92 try {
93 const rows = await db
94 .update(users)
95 .set({
96 deletedAt: null,
97 deletionScheduledFor: null,
98 updatedAt: new Date(),
99 })
100 .where(eq(users.id, userId))
101 .returning({
102 id: users.id,
103 username: users.username,
104 email: users.email,
105 });
106 user = rows[0] ?? null;
107 } catch (err) {
108 console.error("[account-deletion] cancel update failed:", err);
109 return { ok: false };
110 }
111
112 if (!user) return { ok: false };
113
114 await audit({
115 userId,
116 action: "account.deletion.cancelled",
117 targetType: "user",
118 targetId: userId,
119 });
120
121 const tpl = renderRestoredEmail({ username: user.username });
122 await sendEmail({ to: user.email, subject: tpl.subject, text: tpl.text });
123
124 return { ok: true };
125}
126
127export async function purgeScheduledAccounts(
128 opts: { now?: Date; cap?: number } = {}
129): Promise<{ purged: number; errors: number }> {
130 const now = opts.now ?? new Date();
131 const cap = Math.max(1, opts.cap ?? DEFAULT_PURGE_CAP);
132
133 let candidates: Array<{ id: string; username: string; email: string }> = [];
134 try {
135 candidates = await db
136 .select({
137 id: users.id,
138 username: users.username,
139 email: users.email,
140 })
141 .from(users)
142 .where(lt(users.deletionScheduledFor, now))
143 .limit(cap);
144 } catch (err) {
145 console.error("[account-deletion] purge candidate query failed:", err);
146 return { purged: 0, errors: 1 };
147 }
148
149 let purged = 0;
150 let errors = 0;
151 for (const c of candidates) {
152 try {
153 const deleted = await db
154 .delete(users)
155 .where(eq(users.id, c.id))
156 .returning({ id: users.id });
157 if (deleted.length > 0) {
158 purged += 1;
159 await audit({
160 userId: null,
161 action: "account.purged",
162 targetType: "user",
163 targetId: c.id,
164 metadata: { username: c.username },
165 });
166 }
167 } catch (err) {
168 errors += 1;
169 console.error(
170 `[account-deletion] purge failed for user=${c.id} (${c.username}):`,
171 err
172 );
173 }
174 }
175
176 return { purged, errors };
177}
178
179export function daysUntilPurge(
180 user: { deletionScheduledFor: Date | null },
181 now: Date = new Date()
182): number | null {
183 if (!user.deletionScheduledFor) return null;
184 const ms = user.deletionScheduledFor.getTime() - now.getTime();
185 if (ms <= 0) return 0;
186 return Math.ceil(ms / MS_PER_DAY);
187}
188
189export function renderScheduledEmail(input: {
190 username: string;
191 scheduledFor: Date;
192}): { subject: string; text: string } {
193 const when = input.scheduledFor.toUTCString();
194 const subject = "Your Gluecron account is scheduled for deletion";
195 const text = [
196 `Hi ${input.username},`,
197 "",
198 `Your Gluecron account will be permanently deleted on ${when}.`,
199 "",
200 "All of your repos, issues, PRs, and settings will be purged after that",
201 "date. If you change your mind, just sign in any time before then and we",
202 "will cancel the deletion automatically.",
203 "",
204 `Cancel deletion: ${absoluteUrl("/login")}`,
205 "",
206 "— gluecron",
207 ].join("\n");
208 return { subject, text };
209}
210
211export function renderRestoredEmail(input: { username: string }): {
212 subject: string;
213 text: string;
214} {
215 const subject = "Welcome back — your Gluecron account has been restored";
216 const text = [
217 `${input.username},`,
218 "",
219 "Your account is no longer scheduled for deletion. Everything's right",
220 "where you left it.",
221 "",
222 `Visit your dashboard: ${absoluteUrl("/dashboard")}`,
223 "",
224 "— gluecron",
225 ].join("\n");
226 return { subject, text };
227}
228
229export const __test = {
230 GRACE_PERIOD_DAYS,
231 DEFAULT_PURGE_CAP,
232 MS_PER_DAY,
233};