|
|
|
@@ -0,0 +1,432 @@
|
|
|
|
|
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<typeof schema>;
|
|
|
|
|
|
|
|
|
|
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<ErasurePlan> {
|
|
|
|
|
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<number>`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<ErasureLogEntry[]> {
|
|
|
|
|
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 });
|
|
|
|
|
|
|
|
|
|
// ---- 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<number>`count(*)::int` })
|
|
|
|
|
.from(schema.subscriptions)
|
|
|
|
|
.where(eq(schema.subscriptions.userId, userId));
|
|
|
|
|
const txCountRow = await db
|
|
|
|
|
.select({ count: sql<number>`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;
|
|
|
|
|
}
|