ddd6b736ba
- 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.
148 lines
5.5 KiB
TypeScript
148 lines
5.5 KiB
TypeScript
import {
|
||
bigint,
|
||
boolean,
|
||
index,
|
||
integer,
|
||
jsonb,
|
||
pgTable,
|
||
text,
|
||
timestamp,
|
||
uniqueIndex,
|
||
uuid,
|
||
} from "drizzle-orm/pg-core";
|
||
import { sql } from "drizzle-orm";
|
||
import {
|
||
createdAt,
|
||
subscriptionPlanEnum,
|
||
subscriptionProviderEnum,
|
||
subscriptionStatusEnum,
|
||
updatedAt,
|
||
} 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").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(),
|
||
plan: subscriptionPlanEnum("plan").notNull(),
|
||
originalTransactionId: text("original_transaction_id"),
|
||
status: subscriptionStatusEnum("status").notNull(),
|
||
purchasedAt: timestamp("purchased_at", { withTimezone: true }),
|
||
expiresAt: timestamp("expires_at", { withTimezone: true }),
|
||
gracePeriodExpiresAt: timestamp("grace_period_expires_at", { withTimezone: true }),
|
||
canceledAt: timestamp("canceled_at", { withTimezone: true }),
|
||
lastVerifiedAt: timestamp("last_verified_at", { withTimezone: true }),
|
||
createdAt: createdAt(),
|
||
updatedAt: updatedAt(),
|
||
},
|
||
(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`),
|
||
],
|
||
);
|
||
|
||
/** Händelselogg: köp, förnyelse, grace, churn (spec §47). */
|
||
export const subscriptionEvents = pgTable(
|
||
"subscription_events",
|
||
{
|
||
id: uuid("id").primaryKey().defaultRandom(),
|
||
subscriptionId: uuid("subscription_id").references(() => subscriptions.id, {
|
||
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),
|
||
index("subscription_events_pseudonym_idx").on(t.pseudonym),
|
||
],
|
||
);
|
||
|
||
/** Råa store-transaktioner för revision (spec §47). */
|
||
export const storeTransactions = pgTable(
|
||
"store_transactions",
|
||
{
|
||
id: uuid("id").primaryKey().defaultRandom(),
|
||
provider: subscriptionProviderEnum("provider").notNull(),
|
||
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),
|
||
index("store_transactions_pseudonym_idx").on(t.pseudonym),
|
||
],
|
||
);
|
||
|
||
/** App Store Server Notifications / Play RTDN – tas emot rått, processas av worker. */
|
||
export const storeNotifications = pgTable(
|
||
"store_notifications",
|
||
{
|
||
id: uuid("id").primaryKey().defaultRandom(),
|
||
provider: subscriptionProviderEnum("provider").notNull(),
|
||
notificationType: text("notification_type"),
|
||
rawPayload: jsonb("raw_payload").notNull(),
|
||
signatureVerified: boolean("signature_verified").notNull().default(false),
|
||
processed: boolean("processed").notNull().default(false),
|
||
processedAt: timestamp("processed_at", { withTimezone: true }),
|
||
error: text("error"),
|
||
createdAt: createdAt(),
|
||
},
|
||
(t) => [index("store_notifications_pending_idx").on(t.processed, t.createdAt)],
|
||
);
|
||
|
||
/** Trial-spårning: en trial per användare (spec §46). */
|
||
export const trials = pgTable("trials", {
|
||
userId: uuid("user_id")
|
||
.primaryKey()
|
||
.references(() => users.id, { onDelete: "cascade" }),
|
||
startedAt: timestamp("started_at", { withTimezone: true }).notNull().defaultNow(),
|
||
endsAt: timestamp("ends_at", { withTimezone: true }).notNull(),
|
||
});
|
||
|
||
/** Månatlig AI-användning för fair use / gratiskvot (spec §45–46). */
|
||
export const aiUsageCounters = pgTable(
|
||
"ai_usage_counters",
|
||
{
|
||
userId: uuid("user_id")
|
||
.notNull()
|
||
.references(() => users.id, { onDelete: "cascade" }),
|
||
/** Format YYYY-MM. */
|
||
month: text("month").notNull(),
|
||
aiScans: integer("ai_scans").notNull().default(0),
|
||
aiTokensIn: integer("ai_tokens_in").notNull().default(0),
|
||
aiTokensOut: integer("ai_tokens_out").notNull().default(0),
|
||
aiCostUsdMicrocents: bigint("ai_cost_usd_microcents", { mode: "number" }).notNull().default(0),
|
||
updatedAt: updatedAt(),
|
||
},
|
||
(t) => [uniqueIndex("ai_usage_user_month_unique").on(t.userId, t.month)],
|
||
);
|