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.
| c63b860 | 1 | /** |
| 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 | ||
| 25 | import { eq, lt } from "drizzle-orm"; | |
| 26 | import { db } from "../db"; | |
| 27 | import { sessions, users } from "../db/schema"; | |
| 28 | import { sendEmail, absoluteUrl } from "./email"; | |
| 29 | import { audit } from "./notify"; | |
| 30 | ||
| 31 | /** Grace period before a scheduled deletion becomes a hard purge. */ | |
| 32 | export const GRACE_PERIOD_DAYS = 30; | |
| 33 | /** Default per-tick cap for `purgeScheduledAccounts`. */ | |
| 34 | export const DEFAULT_PURGE_CAP = 50; | |
| 35 | ||
| 36 | const MS_PER_DAY = 24 * 60 * 60 * 1000; | |
| 37 | ||
| 38 | export 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 | ||
| 88 | export 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 | ||
| 127 | export 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 | ||
| 179 | export 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 | ||
| 189 | export 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 | ||
| 211 | export 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 | ||
| 229 | export const __test = { | |
| 230 | GRACE_PERIOD_DAYS, | |
| 231 | DEFAULT_PURGE_CAP, | |
| 232 | MS_PER_DAY, | |
| 233 | }; |