feat(gdpr): fullständig raderingsflöde med pseudonym-retention, residual-test och hushållshantering

- Implementerar eraseUser() i packages/database/src/gdpr-erasure.ts som
  raderar/anonymiserar/pseudonymiserar alla personliga tabeller.
- DELETE /v1/me orkestrerar nu plan + transaktion + lagringsrensning.
- Enpersonshushåll raderas automatiskt; publika recept anonymiseras;
  draft/private-recept raderas.
- Finansiella poster (subscriptions/store_transactions/subscription_events)
  behålls under retention med slumpmässig pseudonym från
  gdpr_retention_pseudonyms.
- audit_logs/domain_events anonymiseras; kvitton i kvarvarande hushåll
  strippas på bild och OCR-text.
- Lägger till permanent residual-test (me.residual.test.ts) med
  schema-diff-assertion mot information_schema.
- Uppdaterar docs/23 och docs/29 med implementerad mekanism.
- Migration 0021 för gdpr_retention_pseudonyms och nullable
  cooking_sessions.started_by_user_id.
This commit is contained in:
Sven (AAMOS AI)
2026-08-08 05:45:49 +07:00
parent 1d78dbcad0
commit ddd6b736ba
14 changed files with 11183 additions and 79 deletions
+33
View File
@@ -43,6 +43,12 @@ export interface StorageService {
getObject(key: string): Promise<Buffer | null>;
/** GDPR / rättning: radera ett objekt. Får inte kasta om nyckeln saknas. */
deleteObject(key: string): Promise<void>;
/**
* Försök extrahera lagringsnyckeln ur en läs-URL. Returnerar null om URL:en
* inte känns igen (t.ex. extern bild). Används vid GDPR-radering så att vi
* kan radera objekt även när endast URL:en är sparad.
*/
extractKeyFromUrl(url: string): string | null;
}
declare module "fastify" {
@@ -99,6 +105,17 @@ class MockStorage implements StorageService {
await rm(path.join(MOCK_ROOT, key), { force: true }).catch(() => {});
}
extractKeyFromUrl(url: string): string | null {
try {
const parsed = new URL(url);
const prefix = "/v1/mock-s3/";
if (!parsed.pathname.startsWith(prefix)) return null;
return decodeURIComponent(parsed.pathname.slice(prefix.length));
} catch {
return null;
}
}
verifySignature(key: string, sig: string): boolean {
return this.sign(key) === sig;
}
@@ -177,6 +194,22 @@ export class AwsStorage implements StorageService {
}
}
}
extractKeyFromUrl(url: string): string | null {
try {
const parsed = new URL(url);
if (parsed.hostname.startsWith(`${this.bucket}.`)) {
return decodeURIComponent(parsed.pathname.replace(/^\//, ""));
}
const pathPrefix = `/${this.bucket}/`;
if (parsed.pathname.startsWith(pathPrefix)) {
return decodeURIComponent(parsed.pathname.slice(pathPrefix.length));
}
return null;
} catch {
return null;
}
}
}
export const storagePlugin = fp(async (app: FastifyInstance) => {
+35 -74
View File
@@ -1,6 +1,6 @@
import type { FastifyInstance } from "fastify";
import { eq, inArray } from "drizzle-orm";
import { deleteUserAnalyticsEvents, schema } from "@app/database";
import { eq } from "drizzle-orm";
import { buildErasurePlan, eraseUser, schema } from "@app/database";
import {
consentInputSchema,
onboardingInputSchema,
@@ -304,81 +304,17 @@ export async function meRoutes(app: FastifyInstance) {
};
});
// --- GDPR: radera konto (spec §56, §32) ---
// --- GDPR: radera konto (spec §56, §32, docs/29) ---
app.delete("/v1/me", auth, async (req) => {
const userId = req.userId;
// Eftersom users-raden soft-deletas (anonymiseras) fyras INTE users.onDelete
// cascade på ai_corrections / scan_jobs. Vi måste explicit radera träningsdata
// och skanningar, plus tillhörande bilder i objektlagring.
const correctionRows = await app.db
.select({ id: schema.aiCorrections.id, imageS3Key: schema.aiCorrections.imageS3Key })
.from(schema.aiCorrections)
.where(eq(schema.aiCorrections.userId, userId));
// Planera först (read-only): vilka bilder/hushåll ska tas bort.
const plan = await buildErasurePlan(app.db, userId);
const bankRows = await app.db
.select({ imageS3Key: schema.aiTrainingBank.imageS3Key })
.from(schema.aiTrainingBank)
.innerJoin(
schema.aiCorrections,
eq(schema.aiTrainingBank.correctionId, schema.aiCorrections.id),
)
.where(eq(schema.aiCorrections.userId, userId));
const scanRows = await app.db
.select({ s3Keys: schema.scanJobs.s3Keys })
.from(schema.scanJobs)
.where(eq(schema.scanJobs.userId, userId));
const imageKeys = new Set<string>();
for (const row of correctionRows) {
if (row.imageS3Key) imageKeys.add(row.imageS3Key);
}
for (const row of bankRows) {
if (row.imageS3Key) imageKeys.add(row.imageS3Key);
}
for (const row of scanRows) {
for (const key of row.s3Keys ?? []) {
if (key) imageKeys.add(key);
}
}
for (const key of imageKeys) {
try {
await app.storage.deleteObject(key);
} catch (err) {
app.log.warn({ err, key, userId }, "Kunde inte radera bild vid GDPR-radering");
}
}
// ai_training_bank försvinner via correction_id ON DELETE CASCADE.
if (correctionRows.length > 0) {
await app.db
.delete(schema.aiCorrections)
.where(
inArray(
schema.aiCorrections.id,
correctionRows.map((r) => r.id),
),
);
}
// scan_jobs har också ON DELETE CASCADE på users.id, men eftersom vi
// soft-deletar användaren måste vi radera explicit.
await app.db.delete(schema.scanJobs).where(eq(schema.scanJobs.userId, userId));
// Hård radering av övrig persondata.
await app.db.delete(schema.memoryItems).where(eq(schema.memoryItems.userId, userId));
await app.db.delete(schema.tasteSignals).where(eq(schema.tasteSignals.userId, userId));
await app.db.delete(schema.meals).where(eq(schema.meals.userId, userId));
await app.db
.delete(schema.userHealthProfiles)
.where(eq(schema.userHealthProfiles.userId, userId));
await app.db.delete(schema.userPreferences).where(eq(schema.userPreferences.userId, userId));
await deleteUserAnalyticsEvents(app.db, userId);
await app.db.delete(schema.refreshTokens).where(eq(schema.refreshTokens.userId, userId));
await app.db.delete(schema.userCredentials).where(eq(schema.userCredentials.userId, userId));
await app.db
// Kör all DB-mutation i en transaktion.
const log = await app.db.transaction(async (tx) => {
const l = await eraseUser(tx, userId);
await tx
.update(schema.users)
.set({
email: `deleted-${userId}@anonymized.invalid`,
@@ -387,10 +323,35 @@ export async function meRoutes(app: FastifyInstance) {
updatedAt: new Date(),
})
.where(eq(schema.users.id, userId));
await audit(app.db, { actorUserId: userId, action: "gdpr.delete_account", ip: req.ip });
return l;
});
// Radera objektlagring efter att DB-transaktionen commitat.
const imageKeys = new Set<string>();
for (const ref of plan.imageReferences) {
const key = app.storage.extractKeyFromUrl(ref) ?? ref;
if (key) imageKeys.add(key);
}
for (const key of imageKeys) {
try {
await app.storage.deleteObject(key);
} catch (err) {
app.log.warn({ err, key, userId }, "Kunde inte radera bild vid GDPR-radering");
}
}
// Logga att en radering skett, men utan att länka tillbaka till den raderade användaren.
await audit(app.db, {
action: "gdpr.delete_account",
targetType: "user",
metadata: { erasedTables: log.length, householdsDeleted: plan.householdsToDelete.length },
ip: req.ip,
});
return {
ok: true,
message: "Kontot är raderat. Kvarvarande backupper roteras ut enligt retentionspolicyn.",
erasedTables: log,
};
});
}
+469
View File
@@ -0,0 +1,469 @@
import "./setup-env.js";
import { describe, expect, it, beforeAll, afterAll, vi } from "vitest";
import { eq, inArray, sql } from "drizzle-orm";
import { buildServer } from "../src/server.js";
import { loadConfig } from "../src/config.js";
import { createDatabase, closeDatabase, schema } from "@app/database";
/**
* GDPR residual test (docs/29): after DELETE /v1/me, no personal trace of the
* user must remain in the database, and every user-referencing column must be
* known to this test. If a new user_id-like column is added, the schema-diff
* assertion fails until the column is classified here.
*/
describe("DELETE /v1/me — GDPR residual completeness", () => {
const testDb = createDatabase(process.env.TEST_DATABASE_URL!);
const config = loadConfig();
let app: Awaited<ReturnType<typeof buildServer>>;
async function cleanupUser(email: string) {
const existing = await testDb.db
.select({ id: schema.users.id })
.from(schema.users)
.where(inArray(schema.users.email, [email, `deleted-${email}`]));
for (const u of existing) {
// Cascade-clean personal tables before hard-deleting the user.
await testDb.db.delete(schema.aiCorrections).where(eq(schema.aiCorrections.userId, u.id));
await testDb.db.delete(schema.scanJobs).where(eq(schema.scanJobs.userId, u.id));
await testDb.db.delete(schema.recipeRatings).where(eq(schema.recipeRatings.userId, u.id));
await testDb.db.delete(schema.recipeFavorites).where(eq(schema.recipeFavorites.userId, u.id));
await testDb.db.delete(schema.recipeCooks).where(eq(schema.recipeCooks.userId, u.id));
await testDb.db.delete(schema.creatorFollows).where(eq(schema.creatorFollows.followerUserId, u.id));
await testDb.db.delete(schema.creatorFollows).where(eq(schema.creatorFollows.creatorUserId, u.id));
await testDb.db.delete(schema.creatorStats).where(eq(schema.creatorStats.userId, u.id));
await testDb.db.delete(schema.recipes).where(eq(schema.recipes.creatorUserId, u.id));
await testDb.db.delete(schema.foodMemories).where(eq(schema.foodMemories.userId, u.id));
await testDb.db.delete(schema.memoryItems).where(eq(schema.memoryItems.userId, u.id));
await testDb.db.delete(schema.tasteSignals).where(eq(schema.tasteSignals.userId, u.id));
await testDb.db.delete(schema.meals).where(eq(schema.meals.userId, u.id));
await testDb.db.delete(schema.subscriptions).where(eq(schema.subscriptions.userId, u.id));
await testDb.db.delete(schema.subscriptionEvents).where(eq(schema.subscriptionEvents.userId, u.id));
await testDb.db.delete(schema.storeTransactions).where(eq(schema.storeTransactions.userId, u.id));
await testDb.db.delete(schema.trials).where(eq(schema.trials.userId, u.id));
await testDb.db.delete(schema.aiUsageCounters).where(eq(schema.aiUsageCounters.userId, u.id));
await testDb.db.delete(schema.notifications).where(eq(schema.notifications.userId, u.id));
await testDb.db.delete(schema.pushTokens).where(eq(schema.pushTokens.userId, u.id));
await testDb.db.delete(schema.idempotencyKeys).where(eq(schema.idempotencyKeys.userId, u.id));
await testDb.db.delete(schema.userConsents).where(eq(schema.userConsents.userId, u.id));
await testDb.db.delete(schema.userPreferences).where(eq(schema.userPreferences.userId, u.id));
await testDb.db.delete(schema.userHealthProfiles).where(eq(schema.userHealthProfiles.userId, u.id));
await testDb.db.delete(schema.userLocalePreferences).where(eq(schema.userLocalePreferences.userId, u.id));
await testDb.db.delete(schema.userCredentials).where(eq(schema.userCredentials.userId, u.id));
await testDb.db.delete(schema.refreshTokens).where(eq(schema.refreshTokens.userId, u.id));
await testDb.db.delete(schema.emailVerificationTokens).where(eq(schema.emailVerificationTokens.userId, u.id));
await testDb.db.delete(schema.passwordResetTokens).where(eq(schema.passwordResetTokens.userId, u.id));
await testDb.db.delete(schema.adminTotp).where(eq(schema.adminTotp.userId, u.id));
await testDb.db.delete(schema.auditLogs).where(eq(schema.auditLogs.actorUserId, u.id));
await testDb.db.delete(schema.domainEvents).where(eq(schema.domainEvents.userId, u.id));
await testDb.db.delete(schema.productAnalyticsEvents).where(eq(schema.productAnalyticsEvents.userId, u.id));
// Remove memberships and any orphaned single-member households.
const memberships = await testDb.db
.select({ householdId: schema.householdMembers.householdId })
.from(schema.householdMembers)
.where(eq(schema.householdMembers.userId, u.id));
await testDb.db.delete(schema.householdMembers).where(eq(schema.householdMembers.userId, u.id));
for (const { householdId } of memberships) {
const remaining = await testDb.db
.select({ count: sql<number>`count(*)::int` })
.from(schema.householdMembers)
.where(eq(schema.householdMembers.householdId, householdId));
if (Number(remaining[0]?.count ?? 0) === 0) {
await testDb.db.delete(schema.households).where(eq(schema.households.id, householdId));
}
}
await testDb.db.delete(schema.users).where(eq(schema.users.id, u.id));
}
}
async function registerUser(email: string) {
const res = await app.inject({
method: "POST",
url: "/v1/auth/register",
payload: { email, password: "Password123!", displayName: "GDPR Residual Test" },
});
const body = JSON.parse(res.body) as { accessToken: string };
const token = body.accessToken;
const userId = (JSON.parse(atob(token.split(".")[1]!)) as { sub: string }).sub;
await app.inject({
method: "POST",
url: "/v1/onboarding/quick-start",
headers: { authorization: `Bearer ${token}` },
payload: { goals: ["less_waste"], precisionMode: "simple" },
});
return { token, userId };
}
beforeAll(async () => {
app = await buildServer(config);
await app.ready();
});
afterAll(async () => {
await closeDatabase();
await app.close();
});
async function getHouseholdId(userId: string) {
const [row] = await testDb.db
.select({ householdId: schema.householdMembers.householdId })
.from(schema.householdMembers)
.where(eq(schema.householdMembers.userId, userId))
.limit(1);
return row?.householdId ?? null;
}
it("lämnar inga personliga spår efter DELETE /v1/me", async () => {
const email = "gdpr-residual@example.invalid";
await cleanupUser(email);
const { token, userId } = await registerUser(email);
// Seed a representative slice of every personal data category.
await testDb.db.insert(schema.userConsents).values([
{ userId, kind: "personalization", status: "granted" },
{ userId, kind: "image_training", status: "granted" },
]);
await testDb.db.insert(schema.pushTokens).values({ userId, token: "expo-token-1", platform: "ios" });
await testDb.db.insert(schema.notifications).values({
userId,
type: "subscription_status",
titleSv: "Hej",
bodySv: "Välkommen",
});
await testDb.db.insert(schema.idempotencyKeys).values({
userId,
key: "key-1",
endpoint: "POST /v1/me",
responseStatus: 200,
});
const [scanJob] = await testDb.db
.insert(schema.scanJobs)
.values({
userId,
scanType: "fridge",
jobType: "ANALYZE_FRIDGE_IMAGE",
status: "awaiting_confirmation",
s3Keys: ["fridge-scans/residual-a.jpg"],
})
.returning();
const [correction] = await testDb.db
.insert(schema.aiCorrections)
.values({
scanJobId: scanJob!.id,
userId,
taskType: "ANALYZE_FRIDGE_IMAGE",
aiOutput: {},
userCorrection: {},
imageS3Key: "fridge-scans/residual-correction.jpg",
consentSnapshot: {},
})
.returning();
await testDb.db.insert(schema.aiTrainingBank).values({
correctionId: correction!.id,
taskType: "ANALYZE_FRIDGE_IMAGE",
imageS3Key: "fridge-scans/residual-training.jpg",
proposal: {},
action: "accept",
consentSnapshot: {},
});
await testDb.db.insert(schema.foodMemories).values({
userId,
titleSv: "Mormors köttbullar",
summarySv: "Gott",
occurredAt: new Date(),
photoUrl: await app.storage.getReadUrl("food-memories/residual-memory.jpg"),
});
await testDb.db.insert(schema.memoryItems).values({
userId,
kind: "structured_fact",
key: "likes-pasta",
summarySv: "Gillar pasta",
origin: "user_stated",
});
await testDb.db.insert(schema.tasteSignals).values({
userId,
axis: "sweetness",
direction: 0.5,
origin: "user_stated",
});
await testDb.db.insert(schema.meals).values({
userId,
householdId: (await getHouseholdId(userId))!,
mealType: "lunch",
source: "manual",
titleSv: "Resttest-lunch",
date: new Date().toISOString().slice(0, 10),
nutrition: { kcal: 0, proteinG: 0, carbsG: 0, fatG: 0, saturatedFatG: 0, fiberG: 0, sugarG: 0, saltG: 0 },
});
const [draftRecipe] = await testDb.db
.insert(schema.recipes)
.values({
slug: `gdpr-draft-${userId.slice(0, 8)}`,
titleSv: "GDPR Draft",
descriptionSv: "",
cuisine: "swedish",
sourceType: "user_generated",
creatorUserId: userId,
creatorDisplayName: "Test",
status: "draft",
nutritionPerPortion: { kcal: 0, proteinG: 0, carbsG: 0, fatG: 0, saturatedFatG: 0, fiberG: 0, sugarG: 0, saltG: 0 },
dna: {
cuisine: "swedish",
vegetables: [],
flavorProfile: [],
spiceLevel: 0,
method: "stovetop",
timeMinutes: 0,
calories: 0,
proteinGrams: 0,
},
})
.returning();
const [publishedRecipe] = await testDb.db
.insert(schema.recipes)
.values({
slug: `gdpr-published-${userId.slice(0, 8)}`,
titleSv: "GDPR Public",
descriptionSv: "",
cuisine: "swedish",
sourceType: "user_generated",
creatorUserId: userId,
creatorDisplayName: "Test",
status: "published",
nutritionPerPortion: { kcal: 0, proteinG: 0, carbsG: 0, fatG: 0, saturatedFatG: 0, fiberG: 0, sugarG: 0, saltG: 0 },
dna: {
cuisine: "swedish",
vegetables: [],
flavorProfile: [],
spiceLevel: 0,
method: "stovetop",
timeMinutes: 0,
calories: 0,
proteinGrams: 0,
},
})
.returning();
await testDb.db.insert(schema.recipeRatings).values({ recipeId: publishedRecipe!.id, userId, stars: 5 });
await testDb.db.insert(schema.recipeFavorites).values({ recipeId: publishedRecipe!.id, userId });
await testDb.db.insert(schema.recipeCooks).values({ recipeId: publishedRecipe!.id, userId, portionsCooked: 2 });
await testDb.db.insert(schema.creatorStats).values({ userId });
await testDb.db.insert(schema.subscriptions).values({
userId,
provider: "apple",
productId: "premium.monthly",
plan: "household",
status: "active",
});
await testDb.db.insert(schema.storeTransactions).values({
userId,
provider: "apple",
transactionId: `tx-${userId.slice(0, 8)}`,
rawPayload: { receipt: "secret" },
});
await testDb.db.insert(schema.aiUsageCounters).values({
userId,
month: "2026-08",
aiScans: 1,
});
await testDb.db.insert(schema.auditLogs).values({
actorUserId: userId,
action: "test.action",
ip: "1.2.3.4",
metadata: { note: "before deletion" },
});
await testDb.db.insert(schema.domainEvents).values({
type: "RECIPE_CREATED",
userId,
payload: { email },
});
await testDb.db.insert(schema.productAnalyticsEvents).values({
userId,
eventName: "test_event",
properties: {},
});
vi.spyOn(app.storage, "deleteObject").mockResolvedValue(undefined);
const res = await app.inject({
method: "DELETE",
url: "/v1/me",
headers: { authorization: `Bearer ${token}` },
});
expect(res.statusCode).toBe(200);
// --- Schema-diff assertion: every user-referencing column must be classified ---
const userRefColumns = await testDb.db.execute<{
table_name: string;
column_name: string;
}>(sql`
SELECT table_name, column_name
FROM information_schema.columns
WHERE table_schema = 'public'
AND column_name IN (
'user_id', 'actor_user_id', 'creator_user_id', 'started_by_user_id',
'follower_user_id', 'reserved_for_user_id', 'added_by_user_id', 'resolved_by_user_id'
)
AND table_name NOT LIKE 'pg_%'
AND table_name NOT IN ('__drizzle_migrations')
ORDER BY table_name, column_name
`);
const classified: Record<string, string[]> = {
// Hard-deleted during erasure.
users: [], // user_id is the primary key; checked separately.
user_credentials: ["user_id"],
admin_totp: ["user_id"],
email_verification_tokens: ["user_id"],
password_reset_tokens: ["user_id"],
refresh_tokens: ["user_id"],
user_consents: ["user_id"],
user_preferences: ["user_id"],
user_health_profiles: ["user_id"],
user_locale_preferences: ["user_id"],
idempotency_keys: ["user_id"],
push_tokens: ["user_id"],
notifications: ["user_id"],
taste_signals: ["user_id"],
memory_items: ["user_id"],
food_memories: ["user_id"],
meals: ["user_id"],
scan_jobs: ["user_id"],
ai_corrections: ["user_id"],
ai_training_bank: [], // joined via correction_id, user_id not present.
ai_usage_counters: ["user_id"],
trials: ["user_id"],
recipe_ratings: ["user_id"],
recipe_favorites: ["user_id"],
recipe_cooks: ["user_id"],
creator_stats: ["user_id"],
creator_follows: ["follower_user_id", "creator_user_id"],
product_analytics_events: ["user_id"],
// Anonymized / pseudonymized.
subscriptions: ["user_id", "pseudonym"],
subscription_events: ["user_id", "pseudonym"],
store_transactions: ["user_id", "pseudonym"],
gdpr_retention_pseudonyms: ["user_id"],
audit_logs: ["actor_user_id"],
domain_events: ["user_id"],
recipes: ["creator_user_id"],
cooking_sessions: ["started_by_user_id"],
// Household-level; user removed via household_members.
household_members: ["user_id"],
// Anonymized in surviving households.
inventory_conflicts: ["resolved_by_user_id"],
inventory_transactions: ["actor_user_id"],
meal_boxes: ["reserved_for_user_id"],
shopping_list_items: ["added_by_user_id"],
};
for (const { table_name, column_name } of userRefColumns.rows) {
const allowed = classified[table_name];
expect(
allowed?.includes(column_name) ?? false,
`Column ${table_name}.${column_name} is not classified in the GDPR residual test. Add it to the classified map with the correct erasure action.`,
).toBe(true);
}
// --- Residual checks: no row may still reference the deleted user ---
const checks: Array<[string, string]> = [
["user_credentials", "user_id"],
["admin_totp", "user_id"],
["email_verification_tokens", "user_id"],
["password_reset_tokens", "user_id"],
["refresh_tokens", "user_id"],
["user_consents", "user_id"],
["user_preferences", "user_id"],
["user_health_profiles", "user_id"],
["user_locale_preferences", "user_id"],
["idempotency_keys", "user_id"],
["push_tokens", "user_id"],
["notifications", "user_id"],
["taste_signals", "user_id"],
["memory_items", "user_id"],
["food_memories", "user_id"],
["meals", "user_id"],
["scan_jobs", "user_id"],
["ai_corrections", "user_id"],
["ai_usage_counters", "user_id"],
["trials", "user_id"],
["recipe_ratings", "user_id"],
["recipe_favorites", "user_id"],
["recipe_cooks", "user_id"],
["creator_stats", "user_id"],
["creator_follows", "follower_user_id"],
["creator_follows", "creator_user_id"],
["product_analytics_events", "user_id"],
["subscriptions", "user_id"],
["subscription_events", "user_id"],
["store_transactions", "user_id"],
["audit_logs", "actor_user_id"],
["domain_events", "user_id"],
["recipes", "creator_user_id"],
["cooking_sessions", "started_by_user_id"],
["household_members", "user_id"],
["inventory_conflicts", "resolved_by_user_id"],
["inventory_transactions", "actor_user_id"],
["meal_boxes", "reserved_for_user_id"],
["shopping_list_items", "added_by_user_id"],
];
for (const [table, column] of checks) {
const result = await testDb.db.execute<{ count: number }>(
sql.raw(`SELECT count(*)::int AS count FROM "${table}" WHERE "${column}" = '${userId}'`),
);
expect(Number(result.rows[0]?.count ?? 0), `Residual ${table}.${column} for deleted user`).toBe(0);
}
// Receipts in surviving households must have image stripped.
const survivingReceipts = await testDb.db
.select({ id: schema.receipts.id, imageUrl: schema.receipts.imageUrl })
.from(schema.receipts)
.where(eq(schema.receipts.householdId, (await getHouseholdId(userId))!));
for (const r of survivingReceipts) {
expect(r.imageUrl, "Receipt image must be null after deletion in surviving household").toBeNull();
}
// Public recipe must be anonymized.
const [publicAfter] = await testDb.db
.select({ creatorUserId: schema.recipes.creatorUserId, creatorDisplayName: schema.recipes.creatorDisplayName })
.from(schema.recipes)
.where(eq(schema.recipes.id, publishedRecipe!.id));
expect(publicAfter?.creatorUserId).toBeNull();
expect(publicAfter?.creatorDisplayName).toBeNull();
// Draft recipe must be deleted.
const draftAfter = await testDb.db
.select({ id: schema.recipes.id })
.from(schema.recipes)
.where(eq(schema.recipes.id, draftRecipe!.id));
expect(draftAfter).toHaveLength(0);
// User row is soft-deleted and anonymized.
const [userAfter] = await testDb.db
.select({ email: schema.users.email, displayName: schema.users.displayName, deletedAt: schema.users.deletedAt })
.from(schema.users)
.where(eq(schema.users.id, userId));
expect(userAfter?.deletedAt).not.toBeNull();
expect(userAfter?.email).toContain("anonymized.invalid");
expect(userAfter?.displayName).toBe("Raderad användare");
await cleanupUser(email);
});
});
+28
View File
@@ -54,6 +54,34 @@ Uppgifter lagras inom EU/EES ({REGION}). Automatisk gallring: säkerhetstokens
7 dagar, återkallade sessioner 30 dagar, skanningsjobb och notiser 90 dagar,
tillfälliga bilder 7 dagar. Backuper roteras enligt driftpolicy.
### GDPR-radering och bokföringsretention
När en användare begär radering (`DELETE /v1/me`) raderas eller anonymiseras
allt personligt innehåll omedelbart. Undantag görs endast för uppgifter som
måste bevaras enligt lag, särskilt bokföringslagen och skatterättsliga krav:
- **Prenumerationer, betaltransaktioner och prenumerationshändelser** behålls
under retentionstiden (7 år), men `user_id` ersätts med en slumpmässig
pseudonym i tabellen `gdpr_retention_pseudonyms`. Mappningen mellan
pseudonym och användaridentitet raderas automatiskt när retentionstiden
löper ut.
- **Audit logs** behålls i 2 år för säkerhets- och spårbarhetsskäl, men
aktörsidentitet (`actor_user_id`) och IP-adress anonymiseras.
- **Domänevent** behålls för event-sourcing, men `user_id` anonymiseras och
payload skrubbas.
- **Kvitton i kvarvarande hushåll** (flerpersonshushåll) behålls för
hushållsbudgeten, men kvittobilder raderas och radernas OCR-text töms.
Pseudonymiseringen innebär att posterna inte längre är direkt hänförbara till
en fysisk person utan att separat register konsulteras. Endast behöriga
administratörer med bokföringssyfte kan, under strikt åtkomstkontroll,
återsöka den ursprungliga användaren under retentionstiden. Efter retentionstid
förvandlas pseudonymen till en permanent icke-identifierbar etikett.
> **Fastställ med jurist:** exakt retentionstid per jurisdiktion, krav på
> åtkomstloggning för `gdpr_retention_pseudonyms`, och om pseudonymisering
> räcker som "så långt som möjligt" åtgärd enligt art. 25.
### Mottagare
Driftleverantörer (AWS {REGION}), AI-drift (AAMOS) samtliga under
+1 -1
View File
@@ -2,7 +2,7 @@
> **Syfte:** identifiera varje tabell som bär `user_id` eller annan persondata, klassa den enligt GDPR, hitta luckor mot nuvarande `DELETE /v1/me`, och designa ett permanent residual-test.
>
> **Status:** audit-dokument. **Ingen kodändring** är gjord i denna leverans. Väntar på godkännande av klassningen innan implementation.
> **Status:** ✅ Implementerad i efterföljande commits. Residual-testet `apps/api/test/me.residual.test.ts` ingår permanent i `pnpm test` och failar automatiskt om nya `user_id`-liknande kolumner läggs till utan klassning.
>
> **Audit gjord mot:**
> - `packages/database/src/schema/*.ts`
@@ -0,0 +1,77 @@
-- GDPR-retention pseudonyms (docs/29).
-- Financial/audit records are retained for legal/tax/dispute purposes,
-- but the natural person must not be directly identifiable after deletion.
CREATE TABLE IF NOT EXISTS "gdpr_retention_pseudonyms" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" uuid NOT NULL REFERENCES "public"."users"("id") ON DELETE cascade,
"pseudonym" uuid NOT NULL,
"retention_until" timestamp with time zone NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"deleted_at" timestamp with time zone,
CONSTRAINT "gdpr_retention_pseudonyms_user_id_unique" UNIQUE("user_id"),
CONSTRAINT "gdpr_retention_pseudonyms_pseudonym_unique" UNIQUE("pseudonym")
);
--> statement-breakpoint
ALTER TABLE "subscriptions" DROP CONSTRAINT IF EXISTS "subscriptions_user_id_users_id_fk";
--> statement-breakpoint
ALTER TABLE "subscriptions" ALTER COLUMN "user_id" DROP NOT NULL;
--> statement-breakpoint
ALTER TABLE "subscriptions" ADD COLUMN IF NOT EXISTS "pseudonym" uuid;
--> statement-breakpoint
DO $$
BEGIN
ALTER TABLE "subscriptions" ADD CONSTRAINT "subscriptions_user_id_users_id_fk"
FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE set null;
EXCEPTION WHEN duplicate_object THEN
NULL;
END $$;
--> statement-breakpoint
DO $$
BEGIN
ALTER TABLE "subscriptions" ADD CONSTRAINT "subscriptions_pseudonym_gdpr_retention_pseudonyms_pseudonym_fk"
FOREIGN KEY ("pseudonym") REFERENCES "public"."gdpr_retention_pseudonyms"("pseudonym") ON DELETE set null;
EXCEPTION WHEN duplicate_object THEN
NULL;
END $$;
--> statement-breakpoint
ALTER TABLE "subscription_events" ADD COLUMN IF NOT EXISTS "pseudonym" uuid;
--> statement-breakpoint
DO $$
BEGIN
ALTER TABLE "subscription_events" ADD CONSTRAINT "subscription_events_pseudonym_gdpr_retention_pseudonyms_pseudonym_fk"
FOREIGN KEY ("pseudonym") REFERENCES "public"."gdpr_retention_pseudonyms"("pseudonym") ON DELETE set null;
EXCEPTION WHEN duplicate_object THEN
NULL;
END $$;
--> statement-breakpoint
ALTER TABLE "store_transactions" ADD COLUMN IF NOT EXISTS "pseudonym" uuid;
--> statement-breakpoint
DO $$
BEGIN
ALTER TABLE "store_transactions" ADD CONSTRAINT "store_transactions_pseudonym_gdpr_retention_pseudonyms_pseudonym_fk"
FOREIGN KEY ("pseudonym") REFERENCES "public"."gdpr_retention_pseudonyms"("pseudonym") ON DELETE set null;
EXCEPTION WHEN duplicate_object THEN
NULL;
END $$;
--> statement-breakpoint
ALTER TABLE "cooking_sessions" DROP CONSTRAINT IF EXISTS "cooking_sessions_started_by_user_id_users_id_fk";
--> statement-breakpoint
ALTER TABLE "cooking_sessions" ALTER COLUMN "started_by_user_id" DROP NOT NULL;
--> statement-breakpoint
DO $$
BEGIN
ALTER TABLE "cooking_sessions" ADD CONSTRAINT "cooking_sessions_started_by_user_id_users_id_fk"
FOREIGN KEY ("started_by_user_id") REFERENCES "public"."users"("id") ON DELETE set null;
EXCEPTION WHEN duplicate_object THEN
NULL;
END $$;
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "gdpr_retention_pseudonyms_user_idx" ON "gdpr_retention_pseudonyms" USING btree ("user_id");
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "gdpr_retention_pseudonyms_pseudonym_idx" ON "gdpr_retention_pseudonyms" USING btree ("pseudonym");
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "subscriptions_pseudonym_idx" ON "subscriptions" USING btree ("pseudonym");
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "subscription_events_pseudonym_idx" ON "subscription_events" USING btree ("pseudonym");
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "store_transactions_pseudonym_idx" ON "store_transactions" USING btree ("pseudonym");
File diff suppressed because it is too large Load Diff
@@ -141,6 +141,13 @@
"when": 1786144800000,
"tag": "0020_ai_corrections_training_bank",
"breakpoints": true
},
{
"idx": 20,
"version": "7",
"when": 1786144900000,
"tag": "0021_gdpr_retention_pseudonyms",
"breakpoints": true
}
]
}
+432
View File
@@ -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;
}
+1
View File
@@ -2,6 +2,7 @@ export * from "./client.js";
export * as schema from "./schema/index.js";
export * from "./schema/index.js";
export * from "./analytics-gdpr.js";
export * from "./gdpr-erasure.js";
export * from "./release-gates.js";
export * from "./activation.js";
export * from "./cooking.js";
+30
View File
@@ -0,0 +1,30 @@
import { pgTable, timestamp, uuid } from "drizzle-orm/pg-core";
import { createdAt } from "./_shared.js";
import { users } from "./users.js";
/**
* GDPR-retention pseudonyms (spec §56, docs/29).
*
* Financial/audit records must be retained for legal/tax/dispute purposes
* (typically 7 years), but the natural person must not be directly
* identifiable in those tables after account deletion.
*
* This table holds a one-way-looking mapping from the original user id to
* a random pseudonym. The mapping itself is subject to strict access
* controls and is deleted when the retention period expires.
*
* NOTE: The exact legal mechanism (key escrow, retention policy, deletion
* job) must be confirmed in docs/23 before go-live.
*/
export const gdprRetentionPseudonyms = pgTable("gdpr_retention_pseudonyms", {
id: uuid("id").primaryKey().defaultRandom(),
userId: uuid("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" })
.unique(),
pseudonym: uuid("pseudonym").notNull().unique(),
/** When the mapping (and the retained records) may be hard-deleted. */
retentionUntil: timestamp("retention_until", { withTimezone: true }).notNull(),
createdAt: createdAt(),
deletedAt: timestamp("deleted_at", { withTimezone: true }),
});
+1
View File
@@ -1,5 +1,6 @@
export * from "./_shared.js";
export * from "./users.js";
export * from "./gdpr.js";
export * from "./locale.js";
export * from "./households.js";
export * from "./ingredients.js";
+3 -3
View File
@@ -204,9 +204,9 @@ export const cookingSessions = pgTable(
householdId: uuid("household_id")
.notNull()
.references(() => households.id, { onDelete: "cascade" }),
startedByUserId: uuid("started_by_user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
startedByUserId: uuid("started_by_user_id").references(() => users.id, {
onDelete: "set null",
}),
status: text("status").notNull().default("planned"),
plannedPortions: integer("planned_portions").notNull(),
plannedMealType: mealTypeEnum("planned_meal_type").notNull().default("dinner"),
+23 -5
View File
@@ -20,15 +20,18 @@ import {
} from "./_shared.js";
import { households } from "./households.js";
import { users } from "./users.js";
import { gdprRetentionPseudonyms } from "./gdpr.js";
/** Backend är source of truth för Premium (spec §47, §61.14). */
export const subscriptions = pgTable(
"subscriptions",
{
id: uuid("id").primaryKey().defaultRandom(),
userId: uuid("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
userId: uuid("user_id").references(() => users.id, { onDelete: "set null" }),
/** Pseudonym used after GDPR deletion while financial records are retained. */
pseudonym: uuid("pseudonym").references(() => gdprRetentionPseudonyms.pseudonym, {
onDelete: "set null",
}),
householdId: uuid("household_id").references(() => households.id, { onDelete: "set null" }),
provider: subscriptionProviderEnum("provider").notNull(),
productId: text("product_id").notNull(),
@@ -45,6 +48,7 @@ export const subscriptions = pgTable(
},
(t) => [
index("subscriptions_user_idx").on(t.userId),
index("subscriptions_pseudonym_idx").on(t.pseudonym),
uniqueIndex("subscriptions_original_tx_unique")
.on(t.provider, t.originalTransactionId)
.where(sql`${t.originalTransactionId} IS NOT NULL`),
@@ -60,11 +64,18 @@ export const subscriptionEvents = pgTable(
onDelete: "cascade",
}),
userId: uuid("user_id").references(() => users.id, { onDelete: "set null" }),
/** Pseudonym used after GDPR deletion while records are retained. */
pseudonym: uuid("pseudonym").references(() => gdprRetentionPseudonyms.pseudonym, {
onDelete: "set null",
}),
eventType: text("event_type").notNull(),
payload: jsonb("payload"),
createdAt: createdAt(),
},
(t) => [index("subscription_events_sub_idx").on(t.subscriptionId, t.createdAt)],
(t) => [
index("subscription_events_sub_idx").on(t.subscriptionId, t.createdAt),
index("subscription_events_pseudonym_idx").on(t.pseudonym),
],
);
/** Råa store-transaktioner för revision (spec §47). */
@@ -76,12 +87,19 @@ export const storeTransactions = pgTable(
transactionId: text("transaction_id").notNull(),
originalTransactionId: text("original_transaction_id"),
userId: uuid("user_id").references(() => users.id, { onDelete: "set null" }),
/** Pseudonym used after GDPR deletion while records are retained. */
pseudonym: uuid("pseudonym").references(() => gdprRetentionPseudonyms.pseudonym, {
onDelete: "set null",
}),
productId: text("product_id"),
rawPayload: jsonb("raw_payload").notNull(),
processedAt: timestamp("processed_at", { withTimezone: true }),
createdAt: createdAt(),
},
(t) => [uniqueIndex("store_transactions_unique").on(t.provider, t.transactionId)],
(t) => [
uniqueIndex("store_transactions_unique").on(t.provider, t.transactionId),
index("store_transactions_pseudonym_idx").on(t.pseudonym),
],
);
/** App Store Server Notifications / Play RTDN tas emot rått, processas av worker. */