import { randomUUID } from "node:crypto"; import { and, eq, getTableName, inArray, ne, or, sql } from "drizzle-orm"; import type { NodePgDatabase } from "drizzle-orm/node-postgres"; import { deleteUserAnalyticsEvents } from "./analytics-gdpr.js"; import * as schema from "./schema/index.js"; type Database = NodePgDatabase; export type ErasureLogEntry = { table: string; action: "delete" | "anonymize" | "pseudonymize" | "retain_pseudonym"; count: number; }; export type ErasurePlan = { /** S3 URLs/keys that must be purged from object storage. */ imageReferences: string[]; /** Households that will be hard-deleted because the user was the sole member. */ householdsToDelete: string[]; }; const RETENTION_YEARS = 7; /** Build the plan without mutating anything. */ export async function buildErasurePlan(db: Database, userId: string): Promise { const imageReferences: string[] = []; // --- Personal images (always deleted) --- const correctionImages = await db .select({ key: schema.aiCorrections.imageS3Key }) .from(schema.aiCorrections) .where(eq(schema.aiCorrections.userId, userId)); for (const row of correctionImages) { if (row.key) imageReferences.push(row.key); } const bankImages = await db .select({ key: schema.aiTrainingBank.imageS3Key }) .from(schema.aiTrainingBank) .innerJoin(schema.aiCorrections, eq(schema.aiTrainingBank.correctionId, schema.aiCorrections.id)) .where(eq(schema.aiCorrections.userId, userId)); for (const row of bankImages) { if (row.key) imageReferences.push(row.key); } const scanRows = await db .select({ keys: schema.scanJobs.s3Keys }) .from(schema.scanJobs) .where(eq(schema.scanJobs.userId, userId)); for (const row of scanRows) { for (const key of row.keys ?? []) { if (key) imageReferences.push(key); } } const memoryPhotos = await db .select({ url: schema.foodMemories.photoUrl }) .from(schema.foodMemories) .where(eq(schema.foodMemories.userId, userId)); for (const row of memoryPhotos) { if (row.url) imageReferences.push(row.url); } // --- Households --- const householdRows = await db .select({ householdId: schema.householdMembers.householdId }) .from(schema.householdMembers) .where(eq(schema.householdMembers.userId, userId)); const householdsToDelete: string[] = []; for (const { householdId } of householdRows) { const memberCount = await db .select({ count: sql`count(*)::int` }) .from(schema.householdMembers) .where(eq(schema.householdMembers.householdId, householdId)); if (Number(memberCount[0]?.count ?? 0) <= 1) { householdsToDelete.push(householdId); } } // Images tied to households we are about to delete. if (householdsToDelete.length > 0) { const receiptImages = await db .select({ url: schema.receipts.imageUrl }) .from(schema.receipts) .where(inArray(schema.receipts.householdId, householdsToDelete)); for (const row of receiptImages) { if (row.url) imageReferences.push(row.url); } const householdMemoryPhotos = await db .select({ url: schema.foodMemories.photoUrl }) .from(schema.foodMemories) .where(inArray(schema.foodMemories.householdId, householdsToDelete)); for (const row of householdMemoryPhotos) { if (row.url) imageReferences.push(row.url); } } // --- Draft/private recipe images (public recipes stay but are anonymized) --- const draftRecipeImages = await db .select({ urls: schema.recipes.imageUrls }) .from(schema.recipes) .where( and( eq(schema.recipes.creatorUserId, userId), ne(schema.recipes.status, "published"), ), ); for (const row of draftRecipeImages) { for (const url of row.urls ?? []) { if (url) imageReferences.push(url); } } return { imageReferences: [...new Set(imageReferences)], householdsToDelete, }; } export async function eraseUser(db: Database, userId: string): Promise { const plan = await buildErasurePlan(db, userId); const log: ErasureLogEntry[] = []; // ---- 1. Auth / tokens / consents / idempotency / push / notifications (hard delete) ---- const credDel = await db .delete(schema.userCredentials) .where(eq(schema.userCredentials.userId, userId)) .returning({ userId: schema.userCredentials.userId }); if (credDel.length) log.push({ table: getTableName(schema.userCredentials), action: "delete", count: credDel.length }); const adminTotpDel = await db .delete(schema.adminTotp) .where(eq(schema.adminTotp.userId, userId)) .returning({ userId: schema.adminTotp.userId }); if (adminTotpDel.length) log.push({ table: getTableName(schema.adminTotp), action: "delete", count: adminTotpDel.length }); const emailTokensDel = await db .delete(schema.emailVerificationTokens) .where(eq(schema.emailVerificationTokens.userId, userId)) .returning({ id: schema.emailVerificationTokens.id }); if (emailTokensDel.length) log.push({ table: getTableName(schema.emailVerificationTokens), action: "delete", count: emailTokensDel.length }); const pwTokensDel = await db .delete(schema.passwordResetTokens) .where(eq(schema.passwordResetTokens.userId, userId)) .returning({ id: schema.passwordResetTokens.id }); if (pwTokensDel.length) log.push({ table: getTableName(schema.passwordResetTokens), action: "delete", count: pwTokensDel.length }); const refreshDel = await db .delete(schema.refreshTokens) .where(eq(schema.refreshTokens.userId, userId)) .returning({ id: schema.refreshTokens.id }); if (refreshDel.length) log.push({ table: getTableName(schema.refreshTokens), action: "delete", count: refreshDel.length }); const consentsDel = await db .delete(schema.userConsents) .where(eq(schema.userConsents.userId, userId)) .returning({ userId: schema.userConsents.userId }); if (consentsDel.length) log.push({ table: getTableName(schema.userConsents), action: "delete", count: consentsDel.length }); const idemDel = await db .delete(schema.idempotencyKeys) .where(eq(schema.idempotencyKeys.userId, userId)) .returning({ key: schema.idempotencyKeys.key }); if (idemDel.length) log.push({ table: getTableName(schema.idempotencyKeys), action: "delete", count: idemDel.length }); const pushDel = await db .delete(schema.pushTokens) .where(eq(schema.pushTokens.userId, userId)) .returning({ token: schema.pushTokens.token }); if (pushDel.length) log.push({ table: getTableName(schema.pushTokens), action: "delete", count: pushDel.length }); const notifDel = await db .delete(schema.notifications) .where(eq(schema.notifications.userId, userId)) .returning({ id: schema.notifications.id }); if (notifDel.length) log.push({ table: getTableName(schema.notifications), action: "delete", count: notifDel.length }); const feedbackDel = await db .delete(schema.feedback) .where(eq(schema.feedback.userId, userId)) .returning({ id: schema.feedback.id }); if (feedbackDel.length) log.push({ table: getTableName(schema.feedback), action: "delete", count: feedbackDel.length }); // ---- 2. Recipes ---- const deletedRecipes = await db .delete(schema.recipes) .where( and( eq(schema.recipes.creatorUserId, userId), ne(schema.recipes.status, "published"), ), ) .returning({ id: schema.recipes.id }); if (deletedRecipes.length) log.push({ table: getTableName(schema.recipes), action: "delete", count: deletedRecipes.length }); const anonymizedRecipes = await db .update(schema.recipes) .set({ creatorUserId: null, creatorDisplayName: null, updatedAt: new Date() }) .where( and( eq(schema.recipes.creatorUserId, userId), eq(schema.recipes.status, "published"), ), ) .returning({ id: schema.recipes.id }); if (anonymizedRecipes.length) log.push({ table: getTableName(schema.recipes), action: "anonymize", count: anonymizedRecipes.length }); const ratingsDel = await db .delete(schema.recipeRatings) .where(eq(schema.recipeRatings.userId, userId)) .returning({ id: schema.recipeRatings.id }); if (ratingsDel.length) log.push({ table: getTableName(schema.recipeRatings), action: "delete", count: ratingsDel.length }); const favoritesDel = await db .delete(schema.recipeFavorites) .where(eq(schema.recipeFavorites.userId, userId)) .returning({ recipeId: schema.recipeFavorites.recipeId }); if (favoritesDel.length) log.push({ table: getTableName(schema.recipeFavorites), action: "delete", count: favoritesDel.length }); const cooksDel = await db .delete(schema.recipeCooks) .where(eq(schema.recipeCooks.userId, userId)) .returning({ id: schema.recipeCooks.id }); if (cooksDel.length) log.push({ table: getTableName(schema.recipeCooks), action: "delete", count: cooksDel.length }); const creatorStatsDel = await db .delete(schema.creatorStats) .where(eq(schema.creatorStats.userId, userId)) .returning({ userId: schema.creatorStats.userId }); if (creatorStatsDel.length) log.push({ table: getTableName(schema.creatorStats), action: "delete", count: creatorStatsDel.length }); const followsDel = await db .delete(schema.creatorFollows) .where( or(eq(schema.creatorFollows.followerUserId, userId), eq(schema.creatorFollows.creatorUserId, userId)), ) .returning({ followerUserId: schema.creatorFollows.followerUserId }); if (followsDel.length) log.push({ table: getTableName(schema.creatorFollows), action: "delete", count: followsDel.length }); // ---- 3. Personal content ---- const mealsDel = await db .delete(schema.meals) .where(eq(schema.meals.userId, userId)) .returning({ id: schema.meals.id }); if (mealsDel.length) log.push({ table: getTableName(schema.meals), action: "delete", count: mealsDel.length }); const foodMemDel = await db .delete(schema.foodMemories) .where(eq(schema.foodMemories.userId, userId)) .returning({ id: schema.foodMemories.id }); if (foodMemDel.length) log.push({ table: getTableName(schema.foodMemories), action: "delete", count: foodMemDel.length }); const tasteDel = await db .delete(schema.tasteSignals) .where(eq(schema.tasteSignals.userId, userId)) .returning({ id: schema.tasteSignals.id }); if (tasteDel.length) log.push({ table: getTableName(schema.tasteSignals), action: "delete", count: tasteDel.length }); const memoryDel = await db .delete(schema.memoryItems) .where(eq(schema.memoryItems.userId, userId)) .returning({ id: schema.memoryItems.id }); if (memoryDel.length) log.push({ table: getTableName(schema.memoryItems), action: "delete", count: memoryDel.length }); // ---- 4. AI / scans / training ---- const aiCorrectionsDel = await db .delete(schema.aiCorrections) .where(eq(schema.aiCorrections.userId, userId)) .returning({ id: schema.aiCorrections.id }); if (aiCorrectionsDel.length) log.push({ table: getTableName(schema.aiCorrections), action: "delete", count: aiCorrectionsDel.length }); const scanJobsDel = await db .delete(schema.scanJobs) .where(eq(schema.scanJobs.userId, userId)) .returning({ id: schema.scanJobs.id }); if (scanJobsDel.length) log.push({ table: getTableName(schema.scanJobs), action: "delete", count: scanJobsDel.length }); const aiUsageDel = await db .delete(schema.aiUsageCounters) .where(eq(schema.aiUsageCounters.userId, userId)) .returning({ userId: schema.aiUsageCounters.userId }); if (aiUsageDel.length) log.push({ table: getTableName(schema.aiUsageCounters), action: "delete", count: aiUsageDel.length }); const trialsDel = await db .delete(schema.trials) .where(eq(schema.trials.userId, userId)) .returning({ userId: schema.trials.userId }); if (trialsDel.length) log.push({ table: getTableName(schema.trials), action: "delete", count: trialsDel.length }); // ---- 5. Financial / subscriptions (pseudonymize) ---- const subCountRow = await db .select({ count: sql`count(*)::int` }) .from(schema.subscriptions) .where(eq(schema.subscriptions.userId, userId)); const txCountRow = await db .select({ count: sql`count(*)::int` }) .from(schema.storeTransactions) .where(eq(schema.storeTransactions.userId, userId)); const hasFinancialRecords = Number(subCountRow[0]?.count ?? 0) > 0 || Number(txCountRow[0]?.count ?? 0) > 0; if (hasFinancialRecords) { const pseudonym = randomUUID(); const retentionUntil = new Date(); retentionUntil.setFullYear(retentionUntil.getFullYear() + RETENTION_YEARS); await db.insert(schema.gdprRetentionPseudonyms).values({ userId, pseudonym, retentionUntil, }); const subPseud = await db .update(schema.subscriptions) .set({ userId: null, pseudonym, householdId: null, updatedAt: new Date() }) .where(eq(schema.subscriptions.userId, userId)) .returning({ id: schema.subscriptions.id }); if (subPseud.length) log.push({ table: getTableName(schema.subscriptions), action: "pseudonymize", count: subPseud.length }); const storePseud = await db .update(schema.storeTransactions) .set({ userId: null, pseudonym, rawPayload: sql`jsonb_strip_nulls(jsonb_build_object('transaction_id', ${schema.storeTransactions.transactionId}, 'product_id', ${schema.storeTransactions.productId}, 'provider', ${schema.storeTransactions.provider}))`, }) .where(eq(schema.storeTransactions.userId, userId)) .returning({ id: schema.storeTransactions.id }); if (storePseud.length) log.push({ table: getTableName(schema.storeTransactions), action: "pseudonymize", count: storePseud.length }); const eventPseud = await db .update(schema.subscriptionEvents) .set({ userId: null, pseudonym, payload: sql`jsonb_build_object('anonymized', true)` }) .where(eq(schema.subscriptionEvents.userId, userId)) .returning({ id: schema.subscriptionEvents.id }); if (eventPseud.length) log.push({ table: getTableName(schema.subscriptionEvents), action: "pseudonymize", count: eventPseud.length }); } // ---- 6. Audit logs (anonymize actor, keep for retention) ---- const auditAnon = await db .update(schema.auditLogs) .set({ actorUserId: null, ip: null, metadata: sql`jsonb_build_object('anonymized', true)` }) .where(eq(schema.auditLogs.actorUserId, userId)) .returning({ id: schema.auditLogs.id }); if (auditAnon.length) log.push({ table: getTableName(schema.auditLogs), action: "anonymize", count: auditAnon.length }); // ---- 7. Domain events (anonymize) ---- const domainAnon = await db .update(schema.domainEvents) .set({ userId: null, payload: sql`jsonb_build_object('anonymized', true)` }) .where(eq(schema.domainEvents.userId, userId)) .returning({ id: schema.domainEvents.id }); if (domainAnon.length) log.push({ table: getTableName(schema.domainEvents), action: "anonymize", count: domainAnon.length }); // ---- 8. Households ---- // Remove memberships first; remember surviving households for receipt anonymization. const survivingHouseholds = await db .delete(schema.householdMembers) .where(eq(schema.householdMembers.userId, userId)) .returning({ householdId: schema.householdMembers.householdId }); if (plan.householdsToDelete.length > 0) { const hhDel = await db .delete(schema.households) .where(inArray(schema.households.id, plan.householdsToDelete)) .returning({ id: schema.households.id }); if (hhDel.length) log.push({ table: getTableName(schema.households), action: "delete", count: hhDel.length }); } const survivingHouseholdIds = survivingHouseholds .map((m) => m.householdId) .filter((id) => !plan.householdsToDelete.includes(id)); if (survivingHouseholdIds.length > 0) { const receiptAnon = await db .update(schema.receipts) .set({ imageUrl: null }) .where(inArray(schema.receipts.householdId, survivingHouseholdIds)) .returning({ id: schema.receipts.id }); if (receiptAnon.length) { log.push({ table: getTableName(schema.receipts), action: "anonymize", count: receiptAnon.length }); await db .update(schema.receiptLines) .set({ rawText: "" }) .where(inArray(schema.receiptLines.receiptId, receiptAnon.map((r) => r.id))); } // Anonymize cooking sessions started by user in surviving households. const cookingAnon = await db .update(schema.cookingSessions) .set({ startedByUserId: null }) .where(eq(schema.cookingSessions.startedByUserId, userId)) .returning({ id: schema.cookingSessions.id }); if (cookingAnon.length) log.push({ table: getTableName(schema.cookingSessions), action: "anonymize", count: cookingAnon.length }); // Anonymize household-level records that still reference the user. const conflictAnon = await db .update(schema.inventoryConflicts) .set({ resolvedByUserId: null }) .where(eq(schema.inventoryConflicts.resolvedByUserId, userId)) .returning({ id: schema.inventoryConflicts.id }); if (conflictAnon.length) log.push({ table: getTableName(schema.inventoryConflicts), action: "anonymize", count: conflictAnon.length }); const transactionAnon = await db .update(schema.inventoryTransactions) .set({ actorUserId: null }) .where(eq(schema.inventoryTransactions.actorUserId, userId)) .returning({ id: schema.inventoryTransactions.id }); if (transactionAnon.length) log.push({ table: getTableName(schema.inventoryTransactions), action: "anonymize", count: transactionAnon.length }); const mealBoxAnon = await db .update(schema.mealBoxes) .set({ reservedForUserId: null }) .where(eq(schema.mealBoxes.reservedForUserId, userId)) .returning({ id: schema.mealBoxes.id }); if (mealBoxAnon.length) log.push({ table: getTableName(schema.mealBoxes), action: "anonymize", count: mealBoxAnon.length }); const shoppingAnon = await db .update(schema.shoppingListItems) .set({ addedByUserId: null }) .where(eq(schema.shoppingListItems.addedByUserId, userId)) .returning({ id: schema.shoppingListItems.id }); if (shoppingAnon.length) log.push({ table: getTableName(schema.shoppingListItems), action: "anonymize", count: shoppingAnon.length }); } // ---- 9. Analytics ---- const analyticsDel = await deleteUserAnalyticsEvents(db, userId); if (analyticsDel > 0) log.push({ table: "analytics_events", action: "delete", count: analyticsDel }); // ---- 10. Health/preferences/locale (delete) ---- await db.delete(schema.userHealthProfiles).where(eq(schema.userHealthProfiles.userId, userId)); await db.delete(schema.userPreferences).where(eq(schema.userPreferences.userId, userId)); await db.delete(schema.userLocalePreferences).where(eq(schema.userLocalePreferences.userId, userId)); return log; }