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:
@@ -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,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";
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user