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
+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. */