diff --git a/apps/api/src/plugins/storage.ts b/apps/api/src/plugins/storage.ts index 9186840..c88a08e 100644 --- a/apps/api/src/plugins/storage.ts +++ b/apps/api/src/plugins/storage.ts @@ -43,6 +43,12 @@ export interface StorageService { getObject(key: string): Promise; /** GDPR / rättning: radera ett objekt. Får inte kasta om nyckeln saknas. */ deleteObject(key: string): Promise; + /** + * 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) => { diff --git a/apps/api/src/routes/me.ts b/apps/api/src/routes/me.ts index 3eaf6a3..d456233 100644 --- a/apps/api/src/routes/me.ts +++ b/apps/api/src/routes/me.ts @@ -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,45 +304,34 @@ 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)); + // 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`, + displayName: "Raderad användare", + deletedAt: new Date(), + updatedAt: new Date(), + }) + .where(eq(schema.users.id, userId)); + return l; + }); + // Radera objektlagring efter att DB-transaktionen commitat. const imageKeys = new Set(); - for (const row of correctionRows) { - if (row.imageS3Key) imageKeys.add(row.imageS3Key); + for (const ref of plan.imageReferences) { + const key = app.storage.extractKeyFromUrl(ref) ?? ref; + if (key) imageKeys.add(key); } - 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); @@ -351,46 +340,18 @@ export async function meRoutes(app: FastifyInstance) { } } - // 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), - ), - ); - } + // 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, + }); - // 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 - .update(schema.users) - .set({ - email: `deleted-${userId}@anonymized.invalid`, - displayName: "Raderad användare", - deletedAt: new Date(), - updatedAt: new Date(), - }) - .where(eq(schema.users.id, userId)); - await audit(app.db, { actorUserId: userId, action: "gdpr.delete_account", ip: req.ip }); return { ok: true, message: "Kontot är raderat. Kvarvarande backupper roteras ut enligt retentionspolicyn.", + erasedTables: log, }; }); } diff --git a/apps/api/test/me.residual.test.ts b/apps/api/test/me.residual.test.ts new file mode 100644 index 0000000..1ca21b7 --- /dev/null +++ b/apps/api/test/me.residual.test.ts @@ -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>; + + 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`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 = { + // 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); + }); +}); diff --git a/docs/23-juridik-utkast.md b/docs/23-juridik-utkast.md index 9a2dee3..0d5a696 100644 --- a/docs/23-juridik-utkast.md +++ b/docs/23-juridik-utkast.md @@ -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 diff --git a/docs/29-gdpr-radering-audit.md b/docs/29-gdpr-radering-audit.md index e3734b6..0600d1f 100644 --- a/docs/29-gdpr-radering-audit.md +++ b/docs/29-gdpr-radering-audit.md @@ -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` diff --git a/infrastructure/migrations/0021_gdpr_retention_pseudonyms.sql b/infrastructure/migrations/0021_gdpr_retention_pseudonyms.sql new file mode 100644 index 0000000..3624c93 --- /dev/null +++ b/infrastructure/migrations/0021_gdpr_retention_pseudonyms.sql @@ -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"); diff --git a/infrastructure/migrations/meta/0020_snapshot.json b/infrastructure/migrations/meta/0020_snapshot.json new file mode 100644 index 0000000..1ebaa2f --- /dev/null +++ b/infrastructure/migrations/meta/0020_snapshot.json @@ -0,0 +1,10047 @@ +{ + "id": "c5ed7b86-b55f-4aa1-8287-634b85de56a8", + "prevId": "07a54ac3-7f2a-4a58-9423-0ec5bbc4e84e", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.admin_totp": { + "name": "admin_totp", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "secret_base32": { + "name": "secret_base32", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled_at": { + "name": "enabled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_used_step": { + "name": "last_used_step", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "admin_totp_user_id_users_id_fk": { + "name": "admin_totp_user_id_users_id_fk", + "tableFrom": "admin_totp", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.email_verification_tokens": { + "name": "email_verification_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "used_at": { + "name": "used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "email_verification_tokens_user_idx": { + "name": "email_verification_tokens_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "email_verification_tokens_hash_unique": { + "name": "email_verification_tokens_hash_unique", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "email_verification_tokens_user_id_users_id_fk": { + "name": "email_verification_tokens_user_id_users_id_fk", + "tableFrom": "email_verification_tokens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.password_reset_tokens": { + "name": "password_reset_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "used_at": { + "name": "used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "password_reset_tokens_user_idx": { + "name": "password_reset_tokens_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "password_reset_tokens_hash_unique": { + "name": "password_reset_tokens_hash_unique", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "password_reset_tokens_user_id_users_id_fk": { + "name": "password_reset_tokens_user_id_users_id_fk", + "tableFrom": "password_reset_tokens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.refresh_tokens": { + "name": "refresh_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "family_id": { + "name": "family_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "replaced_by_token_id": { + "name": "replaced_by_token_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ip": { + "name": "ip", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "refresh_tokens_user_idx": { + "name": "refresh_tokens_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "refresh_tokens_hash_unique": { + "name": "refresh_tokens_hash_unique", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "refresh_tokens_user_id_users_id_fk": { + "name": "refresh_tokens_user_id_users_id_fk", + "tableFrom": "refresh_tokens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_consents": { + "name": "user_consents", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "consent_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "consent_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "granted_at": { + "name": "granted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_consents_user_id_users_id_fk": { + "name": "user_consents_user_id_users_id_fk", + "tableFrom": "user_consents", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_consents_user_id_kind_pk": { + "name": "user_consents_user_id_kind_pk", + "columns": [ + "user_id", + "kind" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_credentials": { + "name": "user_credentials", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "password_updated_at": { + "name": "password_updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_credentials_user_id_users_id_fk": { + "name": "user_credentials_user_id_users_id_fk", + "tableFrom": "user_credentials", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_health_profiles": { + "name": "user_health_profiles", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "birth_year": { + "name": "birth_year", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "sex": { + "name": "sex", + "type": "sex", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "height_cm": { + "name": "height_cm", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "weight_kg": { + "name": "weight_kg", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "target_weight_kg": { + "name": "target_weight_kg", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "activity_level": { + "name": "activity_level", + "type": "activity_level", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'moderate'" + }, + "training_sessions_per_week": { + "name": "training_sessions_per_week", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "training_types": { + "name": "training_types", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_health_profiles_user_id_users_id_fk": { + "name": "user_health_profiles_user_id_users_id_fk", + "tableFrom": "user_health_profiles", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_preferences": { + "name": "user_preferences", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "primary_goal": { + "name": "primary_goal", + "type": "goal_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "goals": { + "name": "goals", + "type": "goal_type[]", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "diet_pattern": { + "name": "diet_pattern", + "type": "diet_pattern", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'omnivore'" + }, + "religious_rule": { + "name": "religious_rule", + "type": "religious_rule", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'none'" + }, + "allergens": { + "name": "allergens", + "type": "allergen[]", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "intolerances": { + "name": "intolerances", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "avoid_ingredient_ids": { + "name": "avoid_ingredient_ids", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "favorite_cuisines": { + "name": "favorite_cuisines", + "type": "cuisine[]", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "disliked_dishes": { + "name": "disliked_dishes", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "spice_level_max": { + "name": "spice_level_max", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 3 + }, + "weekly_budget_minor": { + "name": "weekly_budget_minor", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "max_cooking_minutes_weekday": { + "name": "max_cooking_minutes_weekday", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "equipment": { + "name": "equipment", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "default_portions": { + "name": "default_portions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 2 + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_preferences_user_id_users_id_fk": { + "name": "user_preferences_user_id_users_id_fk", + "tableFrom": "user_preferences", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified_at": { + "name": "email_verified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "user_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'user'" + }, + "locale": { + "name": "locale", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'sv-SE'" + }, + "precision_mode": { + "name": "precision_mode", + "type": "precision_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'simple'" + }, + "onboarding_completed": { + "name": "onboarding_completed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "onboarding_step": { + "name": "onboarding_step", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'a'" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "users_email_unique": { + "name": "users_email_unique", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.gdpr_retention_pseudonyms": { + "name": "gdpr_retention_pseudonyms", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "pseudonym": { + "name": "pseudonym", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "retention_until": { + "name": "retention_until", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "gdpr_retention_pseudonyms_user_id_users_id_fk": { + "name": "gdpr_retention_pseudonyms_user_id_users_id_fk", + "tableFrom": "gdpr_retention_pseudonyms", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "gdpr_retention_pseudonyms_user_id_unique": { + "name": "gdpr_retention_pseudonyms_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + }, + "gdpr_retention_pseudonyms_pseudonym_unique": { + "name": "gdpr_retention_pseudonyms_pseudonym_unique", + "nullsNotDistinct": false, + "columns": [ + "pseudonym" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_locale_preferences": { + "name": "user_locale_preferences", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "language_tag": { + "name": "language_tag", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'sv-SE'" + }, + "region_code": { + "name": "region_code", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'SE'" + }, + "time_zone": { + "name": "time_zone", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'Europe/Stockholm'" + }, + "measurement_system": { + "name": "measurement_system", + "type": "measurement_system", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'METRIC'" + }, + "temperature_unit": { + "name": "temperature_unit", + "type": "temperature_unit", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'CELSIUS'" + }, + "currency_code": { + "name": "currency_code", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'SEK'" + }, + "first_day_of_week": { + "name": "first_day_of_week", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "use_24_hour_time": { + "name": "use_24_hour_time", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_locale_preferences_user_id_users_id_fk": { + "name": "user_locale_preferences_user_id_users_id_fk", + "tableFrom": "user_locale_preferences", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.household_members": { + "name": "household_members", + "schema": "", + "columns": { + "household_id": { + "name": "household_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "household_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "portion_factor": { + "name": "portion_factor", + "type": "double precision", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "household_members_user_idx": { + "name": "household_members_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "household_members_household_id_households_id_fk": { + "name": "household_members_household_id_households_id_fk", + "tableFrom": "household_members", + "tableTo": "households", + "columnsFrom": [ + "household_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "household_members_user_id_users_id_fk": { + "name": "household_members_user_id_users_id_fk", + "tableFrom": "household_members", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "household_members_household_id_user_id_pk": { + "name": "household_members_household_id_user_id_pk", + "columns": [ + "household_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.households": { + "name": "households", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "invite_code": { + "name": "invite_code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "weekly_budget_minor": { + "name": "weekly_budget_minor", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "first_scan_completed_at": { + "name": "first_scan_completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "fifth_item_confirmed_at": { + "name": "fifth_item_confirmed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "first_recipe_recommendation_viewed_at": { + "name": "first_recipe_recommendation_viewed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "first_recipe_saved_or_started_at": { + "name": "first_recipe_saved_or_started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "activated_at": { + "name": "activated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "first_cooking_session_completed_at": { + "name": "first_cooking_session_completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "inventory_updated_after_cooking_at": { + "name": "inventory_updated_after_cooking_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "value_confirmed_at": { + "name": "value_confirmed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "currency_code": { + "name": "currency_code", + "type": "varchar(3)", + "primaryKey": false, + "notNull": true, + "default": "'SEK'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "households_invite_code_unique": { + "name": "households_invite_code_unique", + "columns": [ + { + "expression": "invite_code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.storage_locations": { + "name": "storage_locations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "household_id": { + "name": "household_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "storage_location_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sublocations": { + "name": "sublocations", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "storage_locations_household_idx": { + "name": "storage_locations_household_idx", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "storage_locations_household_id_households_id_fk": { + "name": "storage_locations_household_id_households_id_fk", + "tableFrom": "storage_locations", + "tableTo": "households", + "columnsFrom": [ + "household_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.canonical_ingredients": { + "name": "canonical_ingredients", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name_sv": { + "name": "name_sv", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name_en": { + "name": "name_en", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "aliases": { + "name": "aliases", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "default_unit": { + "name": "default_unit", + "type": "unit", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "density_g_per_ml": { + "name": "density_g_per_ml", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "grams_per_piece": { + "name": "grams_per_piece", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "allergens": { + "name": "allergens", + "type": "allergen[]", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "is_vegan": { + "name": "is_vegan", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_vegetarian": { + "name": "is_vegetarian", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "contains_gluten": { + "name": "contains_gluten", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "contains_lactose": { + "name": "contains_lactose", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_pork": { + "name": "is_pork", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_beef": { + "name": "is_beef", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_alcohol": { + "name": "is_alcohol", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "nutrition_per_100": { + "name": "nutrition_per_100", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "nutrition_provenance": { + "name": "nutrition_provenance", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "peak_seasons": { + "name": "peak_seasons", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "shelf_life_guidance": { + "name": "shelf_life_guidance", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "default_price_minor_per_kg": { + "name": "default_price_minor_per_kg", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "canonical_ingredients_category_idx": { + "name": "canonical_ingredients_category_idx", + "columns": [ + { + "expression": "category", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.substitutions": { + "name": "substitutions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "from_ingredient_id": { + "name": "from_ingredient_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "to_ingredient_id": { + "name": "to_ingredient_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ratio": { + "name": "ratio", + "type": "double precision", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "instructions_sv": { + "name": "instructions_sv", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "best_for": { + "name": "best_for", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "not_recommended_for": { + "name": "not_recommended_for", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "flavor_impact_sv": { + "name": "flavor_impact_sv", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "texture_impact_sv": { + "name": "texture_impact_sv", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": { + "substitutions_from_idx": { + "name": "substitutions_from_idx", + "columns": [ + { + "expression": "from_ingredient_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "substitutions_pair_unique": { + "name": "substitutions_pair_unique", + "columns": [ + { + "expression": "from_ingredient_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "to_ingredient_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "substitutions_from_ingredient_id_canonical_ingredients_id_fk": { + "name": "substitutions_from_ingredient_id_canonical_ingredients_id_fk", + "tableFrom": "substitutions", + "tableTo": "canonical_ingredients", + "columnsFrom": [ + "from_ingredient_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "substitutions_to_ingredient_id_canonical_ingredients_id_fk": { + "name": "substitutions_to_ingredient_id_canonical_ingredients_id_fk", + "tableFrom": "substitutions", + "tableTo": "canonical_ingredients", + "columnsFrom": [ + "to_ingredient_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.products": { + "name": "products", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "gtin": { + "name": "gtin", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "brand": { + "name": "brand", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "canonical_ingredient_id": { + "name": "canonical_ingredient_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "package_size_value": { + "name": "package_size_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "package_size_unit": { + "name": "package_size_unit", + "type": "unit", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "ingredients_text": { + "name": "ingredients_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "allergens": { + "name": "allergens", + "type": "allergen[]", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "may_contain_allergens": { + "name": "may_contain_allergens", + "type": "allergen[]", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "nutrition": { + "name": "nutrition", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "image_urls": { + "name": "image_urls", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "language": { + "name": "language", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'sv'" + }, + "market": { + "name": "market", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'SE'" + }, + "data_source": { + "name": "data_source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "verification_status": { + "name": "verification_status", + "type": "verification_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'unverified'" + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "valid_from": { + "name": "valid_from", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "valid_to": { + "name": "valid_to", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "products_gtin_version_unique": { + "name": "products_gtin_version_unique", + "columns": [ + { + "expression": "gtin", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"products\".\"gtin\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "products_gtin_current_idx": { + "name": "products_gtin_current_idx", + "columns": [ + { + "expression": "gtin", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"products\".\"valid_to\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "products_canonical_idx": { + "name": "products_canonical_idx", + "columns": [ + { + "expression": "canonical_ingredient_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "products_canonical_ingredient_id_canonical_ingredients_id_fk": { + "name": "products_canonical_ingredient_id_canonical_ingredients_id_fk", + "tableFrom": "products", + "tableTo": "canonical_ingredients", + "columnsFrom": [ + "canonical_ingredient_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.inventory_conflicts": { + "name": "inventory_conflicts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "household_id": { + "name": "household_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "inventory_item_id": { + "name": "inventory_item_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "scan_job_id": { + "name": "scan_job_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "conflict_type": { + "name": "conflict_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_a": { + "name": "source_a", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_a_priority": { + "name": "source_a_priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "source_b": { + "name": "source_b", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_b_priority": { + "name": "source_b_priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "payload_a": { + "name": "payload_a", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "payload_b": { + "name": "payload_b", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "proposed_resolution": { + "name": "proposed_resolution", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "resolved_by_user_id": { + "name": "resolved_by_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "resolution": { + "name": "resolution", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "model_version": { + "name": "model_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prompt_version": { + "name": "prompt_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "resolved_at": { + "name": "resolved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "inventory_conflicts_household_idx": { + "name": "inventory_conflicts_household_idx", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inventory_conflicts_item_idx": { + "name": "inventory_conflicts_item_idx", + "columns": [ + { + "expression": "inventory_item_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inventory_conflicts_status_idx": { + "name": "inventory_conflicts_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "inventory_conflicts_household_id_households_id_fk": { + "name": "inventory_conflicts_household_id_households_id_fk", + "tableFrom": "inventory_conflicts", + "tableTo": "households", + "columnsFrom": [ + "household_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "inventory_conflicts_inventory_item_id_inventory_items_id_fk": { + "name": "inventory_conflicts_inventory_item_id_inventory_items_id_fk", + "tableFrom": "inventory_conflicts", + "tableTo": "inventory_items", + "columnsFrom": [ + "inventory_item_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "inventory_conflicts_resolved_by_user_id_users_id_fk": { + "name": "inventory_conflicts_resolved_by_user_id_users_id_fk", + "tableFrom": "inventory_conflicts", + "tableTo": "users", + "columnsFrom": [ + "resolved_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.inventory_items": { + "name": "inventory_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "household_id": { + "name": "household_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "canonical_ingredient_id": { + "name": "canonical_ingredient_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "product_id": { + "name": "product_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "brand": { + "name": "brand", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "quantity": { + "name": "quantity", + "type": "double precision", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "unit": { + "name": "unit", + "type": "unit", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "storage_location_id": { + "name": "storage_location_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "sublocation": { + "name": "sublocation", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "purchased_at": { + "name": "purchased_at", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "opened_at": { + "name": "opened_at", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "best_before_date": { + "name": "best_before_date", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "use_by_date": { + "name": "use_by_date", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "date_kind": { + "name": "date_kind", + "type": "date_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "frozen_at": { + "name": "frozen_at", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "thawed_at": { + "name": "thawed_at", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "price_minor": { + "name": "price_minor", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "nutrition_per_100": { + "name": "nutrition_per_100", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "inventory_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "confidence": { + "name": "confidence", + "type": "double precision", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "verified_by_user": { + "name": "verified_by_user", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "last_verified_at": { + "name": "last_verified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "expiry_status": { + "name": "expiry_status", + "type": "expiry_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'unknown'" + }, + "trust_state": { + "name": "trust_state", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "default": "'unverified'" + }, + "depleted_at": { + "name": "depleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "model_version": { + "name": "model_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prompt_version": { + "name": "prompt_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "inventory_items_household_idx": { + "name": "inventory_items_household_idx", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inventory_items_household_bb_idx": { + "name": "inventory_items_household_bb_idx", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "best_before_date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inventory_items_location_idx": { + "name": "inventory_items_location_idx", + "columns": [ + { + "expression": "storage_location_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inventory_items_canonical_idx": { + "name": "inventory_items_canonical_idx", + "columns": [ + { + "expression": "canonical_ingredient_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "inventory_items_household_id_households_id_fk": { + "name": "inventory_items_household_id_households_id_fk", + "tableFrom": "inventory_items", + "tableTo": "households", + "columnsFrom": [ + "household_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "inventory_items_canonical_ingredient_id_canonical_ingredients_id_fk": { + "name": "inventory_items_canonical_ingredient_id_canonical_ingredients_id_fk", + "tableFrom": "inventory_items", + "tableTo": "canonical_ingredients", + "columnsFrom": [ + "canonical_ingredient_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "inventory_items_product_id_products_id_fk": { + "name": "inventory_items_product_id_products_id_fk", + "tableFrom": "inventory_items", + "tableTo": "products", + "columnsFrom": [ + "product_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "inventory_items_storage_location_id_storage_locations_id_fk": { + "name": "inventory_items_storage_location_id_storage_locations_id_fk", + "tableFrom": "inventory_items", + "tableTo": "storage_locations", + "columnsFrom": [ + "storage_location_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.inventory_transactions": { + "name": "inventory_transactions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "household_id": { + "name": "household_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "inventory_item_id": { + "name": "inventory_item_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "inventory_transaction_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "quantity_delta": { + "name": "quantity_delta", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "unit": { + "name": "unit", + "type": "unit", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "ref_type": { + "name": "ref_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ref_id": { + "name": "ref_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "cooking_session_id": { + "name": "cooking_session_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "actor_user_id": { + "name": "actor_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "value_minor": { + "name": "value_minor", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "inventory_tx_item_idx": { + "name": "inventory_tx_item_idx", + "columns": [ + { + "expression": "inventory_item_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inventory_tx_household_time_idx": { + "name": "inventory_tx_household_time_idx", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inventory_tx_type_idx": { + "name": "inventory_tx_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "inventory_transactions_household_id_households_id_fk": { + "name": "inventory_transactions_household_id_households_id_fk", + "tableFrom": "inventory_transactions", + "tableTo": "households", + "columnsFrom": [ + "household_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "inventory_transactions_inventory_item_id_inventory_items_id_fk": { + "name": "inventory_transactions_inventory_item_id_inventory_items_id_fk", + "tableFrom": "inventory_transactions", + "tableTo": "inventory_items", + "columnsFrom": [ + "inventory_item_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "inventory_transactions_cooking_session_id_cooking_sessions_id_fk": { + "name": "inventory_transactions_cooking_session_id_cooking_sessions_id_fk", + "tableFrom": "inventory_transactions", + "tableTo": "cooking_sessions", + "columnsFrom": [ + "cooking_session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "inventory_transactions_actor_user_id_users_id_fk": { + "name": "inventory_transactions_actor_user_id_users_id_fk", + "tableFrom": "inventory_transactions", + "tableTo": "users", + "columnsFrom": [ + "actor_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.inventory_decay_profiles": { + "name": "inventory_decay_profiles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "half_life_days": { + "name": "half_life_days", + "type": "double precision", + "primaryKey": false, + "notNull": true, + "default": 7 + }, + "stale_after_days": { + "name": "stale_after_days", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 30 + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "inventory_decay_profiles_name_unique": { + "name": "inventory_decay_profiles_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.inventory_item_decay_profile": { + "name": "inventory_item_decay_profile", + "schema": "", + "columns": { + "inventory_item_id": { + "name": "inventory_item_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "decay_profile_id": { + "name": "decay_profile_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "inventory_item_decay_profile_inventory_item_id_inventory_items_id_fk": { + "name": "inventory_item_decay_profile_inventory_item_id_inventory_items_id_fk", + "tableFrom": "inventory_item_decay_profile", + "tableTo": "inventory_items", + "columnsFrom": [ + "inventory_item_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "inventory_item_decay_profile_decay_profile_id_inventory_decay_profiles_id_fk": { + "name": "inventory_item_decay_profile_decay_profile_id_inventory_decay_profiles_id_fk", + "tableFrom": "inventory_item_decay_profile", + "tableTo": "inventory_decay_profiles", + "columnsFrom": [ + "decay_profile_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.cooking_assumption_profiles": { + "name": "cooking_assumption_profiles", + "schema": "", + "columns": { + "household_id": { + "name": "household_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "canonical_ingredient_id": { + "name": "canonical_ingredient_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "average_eaten_portions": { + "name": "average_eaten_portions", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "average_leftover_portions": { + "name": "average_leftover_portions", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "observation_count": { + "name": "observation_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_session_answers": { + "name": "last_session_answers", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "cooking_assumption_profiles_household_idx": { + "name": "cooking_assumption_profiles_household_idx", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "cooking_assumption_profiles_household_id_households_id_fk": { + "name": "cooking_assumption_profiles_household_id_households_id_fk", + "tableFrom": "cooking_assumption_profiles", + "tableTo": "households", + "columnsFrom": [ + "household_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "cooking_assumption_profiles_canonical_ingredient_id_canonical_ingredients_id_fk": { + "name": "cooking_assumption_profiles_canonical_ingredient_id_canonical_ingredients_id_fk", + "tableFrom": "cooking_assumption_profiles", + "tableTo": "canonical_ingredients", + "columnsFrom": [ + "canonical_ingredient_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "cooking_assumption_profiles_household_id_canonical_ingredient_id_pk": { + "name": "cooking_assumption_profiles_household_id_canonical_ingredient_id_pk", + "columns": [ + "household_id", + "canonical_ingredient_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.cooking_sessions": { + "name": "cooking_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "recipe_id": { + "name": "recipe_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "household_id": { + "name": "household_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "started_by_user_id": { + "name": "started_by_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'planned'" + }, + "planned_portions": { + "name": "planned_portions", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "planned_meal_type": { + "name": "planned_meal_type", + "type": "meal_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'dinner'" + }, + "actual_portions_eaten": { + "name": "actual_portions_eaten", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "leftover_estimate_portions": { + "name": "leftover_estimate_portions", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "meal_date": { + "name": "meal_date", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "meal_box_mutations": { + "name": "meal_box_mutations", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "leftover_note": { + "name": "leftover_note", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "planned_deductions": { + "name": "planned_deductions", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "cancelled_at": { + "name": "cancelled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "cancel_reason": { + "name": "cancel_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "cooking_sessions_household_status_idx": { + "name": "cooking_sessions_household_status_idx", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "cooking_sessions_recipe_idx": { + "name": "cooking_sessions_recipe_idx", + "columns": [ + { + "expression": "recipe_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "cooking_sessions_timeout_idx": { + "name": "cooking_sessions_timeout_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "cooking_sessions_recipe_id_recipes_id_fk": { + "name": "cooking_sessions_recipe_id_recipes_id_fk", + "tableFrom": "cooking_sessions", + "tableTo": "recipes", + "columnsFrom": [ + "recipe_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "cooking_sessions_household_id_households_id_fk": { + "name": "cooking_sessions_household_id_households_id_fk", + "tableFrom": "cooking_sessions", + "tableTo": "households", + "columnsFrom": [ + "household_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "cooking_sessions_started_by_user_id_users_id_fk": { + "name": "cooking_sessions_started_by_user_id_users_id_fk", + "tableFrom": "cooking_sessions", + "tableTo": "users", + "columnsFrom": [ + "started_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.recipe_cooks": { + "name": "recipe_cooks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "recipe_id": { + "name": "recipe_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "household_id": { + "name": "household_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "cooking_session_id": { + "name": "cooking_session_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "portions_cooked": { + "name": "portions_cooked", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "cooked_at": { + "name": "cooked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "recipe_cooks_recipe_idx": { + "name": "recipe_cooks_recipe_idx", + "columns": [ + { + "expression": "recipe_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "recipe_cooks_user_idx": { + "name": "recipe_cooks_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "recipe_cooks_session_idx": { + "name": "recipe_cooks_session_idx", + "columns": [ + { + "expression": "cooking_session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "recipe_cooks_recipe_id_recipes_id_fk": { + "name": "recipe_cooks_recipe_id_recipes_id_fk", + "tableFrom": "recipe_cooks", + "tableTo": "recipes", + "columnsFrom": [ + "recipe_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "recipe_cooks_user_id_users_id_fk": { + "name": "recipe_cooks_user_id_users_id_fk", + "tableFrom": "recipe_cooks", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "recipe_cooks_cooking_session_id_cooking_sessions_id_fk": { + "name": "recipe_cooks_cooking_session_id_cooking_sessions_id_fk", + "tableFrom": "recipe_cooks", + "tableTo": "cooking_sessions", + "columnsFrom": [ + "cooking_session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.recipe_favorites": { + "name": "recipe_favorites", + "schema": "", + "columns": { + "recipe_id": { + "name": "recipe_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "recipe_favorites_user_idx": { + "name": "recipe_favorites_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "recipe_favorites_recipe_id_recipes_id_fk": { + "name": "recipe_favorites_recipe_id_recipes_id_fk", + "tableFrom": "recipe_favorites", + "tableTo": "recipes", + "columnsFrom": [ + "recipe_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "recipe_favorites_user_id_users_id_fk": { + "name": "recipe_favorites_user_id_users_id_fk", + "tableFrom": "recipe_favorites", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "recipe_favorites_recipe_id_user_id_pk": { + "name": "recipe_favorites_recipe_id_user_id_pk", + "columns": [ + "recipe_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.recipe_ingredients": { + "name": "recipe_ingredients", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "recipe_id": { + "name": "recipe_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "canonical_ingredient_id": { + "name": "canonical_ingredient_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name_sv": { + "name": "display_name_sv", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "quantity": { + "name": "quantity", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "unit": { + "name": "unit", + "type": "unit", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "optional": { + "name": "optional", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "group_name": { + "name": "group_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": { + "recipe_ingredients_recipe_idx": { + "name": "recipe_ingredients_recipe_idx", + "columns": [ + { + "expression": "recipe_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "recipe_ingredients_canonical_idx": { + "name": "recipe_ingredients_canonical_idx", + "columns": [ + { + "expression": "canonical_ingredient_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "recipe_ingredients_recipe_id_recipes_id_fk": { + "name": "recipe_ingredients_recipe_id_recipes_id_fk", + "tableFrom": "recipe_ingredients", + "tableTo": "recipes", + "columnsFrom": [ + "recipe_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "recipe_ingredients_canonical_ingredient_id_canonical_ingredients_id_fk": { + "name": "recipe_ingredients_canonical_ingredient_id_canonical_ingredients_id_fk", + "tableFrom": "recipe_ingredients", + "tableTo": "canonical_ingredients", + "columnsFrom": [ + "canonical_ingredient_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.recipe_ratings": { + "name": "recipe_ratings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "recipe_id": { + "name": "recipe_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "stars": { + "name": "stars", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "feedback_tags": { + "name": "feedback_tags", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "comment": { + "name": "comment", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "recipe_ratings_user_recipe_unique": { + "name": "recipe_ratings_user_recipe_unique", + "columns": [ + { + "expression": "recipe_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "recipe_ratings_recipe_idx": { + "name": "recipe_ratings_recipe_idx", + "columns": [ + { + "expression": "recipe_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "recipe_ratings_recipe_id_recipes_id_fk": { + "name": "recipe_ratings_recipe_id_recipes_id_fk", + "tableFrom": "recipe_ratings", + "tableTo": "recipes", + "columnsFrom": [ + "recipe_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "recipe_ratings_user_id_users_id_fk": { + "name": "recipe_ratings_user_id_users_id_fk", + "tableFrom": "recipe_ratings", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.recipe_similarities": { + "name": "recipe_similarities", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "recipe_a_id": { + "name": "recipe_a_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "recipe_b_id": { + "name": "recipe_b_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "similarity_score": { + "name": "similarity_score", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "classification": { + "name": "classification", + "type": "recipe_similarity_class", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "details": { + "name": "details", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "recipe_similarities_pair_unique": { + "name": "recipe_similarities_pair_unique", + "columns": [ + { + "expression": "recipe_a_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "recipe_b_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "recipe_similarities_recipe_a_id_recipes_id_fk": { + "name": "recipe_similarities_recipe_a_id_recipes_id_fk", + "tableFrom": "recipe_similarities", + "tableTo": "recipes", + "columnsFrom": [ + "recipe_a_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "recipe_similarities_recipe_b_id_recipes_id_fk": { + "name": "recipe_similarities_recipe_b_id_recipes_id_fk", + "tableFrom": "recipe_similarities", + "tableTo": "recipes", + "columnsFrom": [ + "recipe_b_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.recipe_source_registry": { + "name": "recipe_source_registry", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "source_name": { + "name": "source_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "license": { + "name": "license", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "right_to_store": { + "name": "right_to_store", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "right_to_modify": { + "name": "right_to_modify", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "right_to_display": { + "name": "right_to_display", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "attribution_required": { + "name": "attribution_required", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "attribution_text": { + "name": "attribution_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "commercial_use": { + "name": "commercial_use", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "valid_from": { + "name": "valid_from", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "valid_to": { + "name": "valid_to", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.recipe_steps": { + "name": "recipe_steps", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "recipe_id": { + "name": "recipe_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "step_number": { + "name": "step_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "instruction_sv": { + "name": "instruction_sv", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timer_seconds": { + "name": "timer_seconds", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "temperature_c": { + "name": "temperature_c", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "tip": { + "name": "tip", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "recipe_steps_recipe_idx": { + "name": "recipe_steps_recipe_idx", + "columns": [ + { + "expression": "recipe_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "recipe_steps_recipe_step_unique": { + "name": "recipe_steps_recipe_step_unique", + "columns": [ + { + "expression": "recipe_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "step_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "recipe_steps_recipe_id_recipes_id_fk": { + "name": "recipe_steps_recipe_id_recipes_id_fk", + "tableFrom": "recipe_steps", + "tableTo": "recipes", + "columnsFrom": [ + "recipe_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.recipes": { + "name": "recipes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title_sv": { + "name": "title_sv", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description_sv": { + "name": "description_sv", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "country": { + "name": "country", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "region": { + "name": "region", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cuisine": { + "name": "cuisine", + "type": "cuisine", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "meal_types": { + "name": "meal_types", + "type": "meal_type[]", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "tags": { + "name": "tags", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "methods": { + "name": "methods", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "equipment": { + "name": "equipment", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "difficulty": { + "name": "difficulty", + "type": "recipe_difficulty", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'easy'" + }, + "prep_time_minutes": { + "name": "prep_time_minutes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cook_time_minutes": { + "name": "cook_time_minutes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_time_minutes": { + "name": "total_time_minutes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "portions": { + "name": "portions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 4 + }, + "nutrition_per_portion": { + "name": "nutrition_per_portion", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "allergens": { + "name": "allergens", + "type": "allergen[]", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "spice_level": { + "name": "spice_level", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "estimated_cost_minor_per_portion": { + "name": "estimated_cost_minor_per_portion", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "storage_guidance_sv": { + "name": "storage_guidance_sv", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "meal_prep_friendly": { + "name": "meal_prep_friendly", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "freezer_friendly": { + "name": "freezer_friendly", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "peak_seasons": { + "name": "peak_seasons", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "holiday_tags": { + "name": "holiday_tags", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "dna": { + "name": "dna", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "variant_type": { + "name": "variant_type", + "type": "recipe_variant_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'standard'" + }, + "variant_of_recipe_id": { + "name": "variant_of_recipe_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "forked_from_recipe_id": { + "name": "forked_from_recipe_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "recipe_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "verification_status": { + "name": "verification_status", + "type": "recipe_verification_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'unverified'" + }, + "source_type": { + "name": "source_type", + "type": "recipe_source_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "source_registry_id": { + "name": "source_registry_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "creator_user_id": { + "name": "creator_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "creator_display_name": { + "name": "creator_display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "image_urls": { + "name": "image_urls", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "rating_average": { + "name": "rating_average", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "rating_count": { + "name": "rating_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cook_count": { + "name": "cook_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "favorite_count": { + "name": "favorite_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "moderation_note": { + "name": "moderation_note", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "recipes_slug_unique": { + "name": "recipes_slug_unique", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "recipes_status_idx": { + "name": "recipes_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "recipes_cuisine_idx": { + "name": "recipes_cuisine_idx", + "columns": [ + { + "expression": "cuisine", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "recipes_variant_of_idx": { + "name": "recipes_variant_of_idx", + "columns": [ + { + "expression": "variant_of_recipe_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "recipes_creator_idx": { + "name": "recipes_creator_idx", + "columns": [ + { + "expression": "creator_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "recipes_total_time_idx": { + "name": "recipes_total_time_idx", + "columns": [ + { + "expression": "total_time_minutes", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "recipes_source_registry_id_recipe_source_registry_id_fk": { + "name": "recipes_source_registry_id_recipe_source_registry_id_fk", + "tableFrom": "recipes", + "tableTo": "recipe_source_registry", + "columnsFrom": [ + "source_registry_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "recipes_creator_user_id_users_id_fk": { + "name": "recipes_creator_user_id_users_id_fk", + "tableFrom": "recipes", + "tableTo": "users", + "columnsFrom": [ + "creator_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ingredient_translations": { + "name": "ingredient_translations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "ingredient_id": { + "name": "ingredient_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "language_tag": { + "name": "language_tag", + "type": "varchar(35)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "aliases": { + "name": "aliases", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "source": { + "name": "source", + "type": "translation_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'seed'" + }, + "status": { + "name": "status", + "type": "translation_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'published'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "ingredient_translations_unique": { + "name": "ingredient_translations_unique", + "columns": [ + { + "expression": "ingredient_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "language_tag", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "ingredient_translations_lang_idx": { + "name": "ingredient_translations_lang_idx", + "columns": [ + { + "expression": "language_tag", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ingredient_translations_ingredient_id_canonical_ingredients_id_fk": { + "name": "ingredient_translations_ingredient_id_canonical_ingredients_id_fk", + "tableFrom": "ingredient_translations", + "tableTo": "canonical_ingredients", + "columnsFrom": [ + "ingredient_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.recipe_step_translations": { + "name": "recipe_step_translations", + "schema": "", + "columns": { + "recipe_id": { + "name": "recipe_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "language_tag": { + "name": "language_tag", + "type": "varchar(35)", + "primaryKey": false, + "notNull": true + }, + "step_number": { + "name": "step_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "instruction": { + "name": "instruction", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tip": { + "name": "tip", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "recipe_step_translations_recipe_id_recipes_id_fk": { + "name": "recipe_step_translations_recipe_id_recipes_id_fk", + "tableFrom": "recipe_step_translations", + "tableTo": "recipes", + "columnsFrom": [ + "recipe_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "recipe_step_translations_recipe_id_language_tag_step_number_pk": { + "name": "recipe_step_translations_recipe_id_language_tag_step_number_pk", + "columns": [ + "recipe_id", + "language_tag", + "step_number" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.recipe_translations": { + "name": "recipe_translations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "recipe_id": { + "name": "recipe_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "language_tag": { + "name": "language_tag", + "type": "varchar(35)", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "storage_guidance": { + "name": "storage_guidance", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "translation_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'draft_ai'" + }, + "source": { + "name": "source", + "type": "translation_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'ai'" + }, + "verification": { + "name": "verification", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "recipe_translations_unique": { + "name": "recipe_translations_unique", + "columns": [ + { + "expression": "recipe_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "language_tag", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "recipe_translations_lang_idx": { + "name": "recipe_translations_lang_idx", + "columns": [ + { + "expression": "language_tag", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "recipe_translations_recipe_id_recipes_id_fk": { + "name": "recipe_translations_recipe_id_recipes_id_fk", + "tableFrom": "recipe_translations", + "tableTo": "recipes", + "columnsFrom": [ + "recipe_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.unit_translations": { + "name": "unit_translations", + "schema": "", + "columns": { + "unit_code": { + "name": "unit_code", + "type": "unit", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "language_tag": { + "name": "language_tag", + "type": "varchar(35)", + "primaryKey": false, + "notNull": true + }, + "abbreviation": { + "name": "abbreviation", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "unit_translations_unit_code_language_tag_pk": { + "name": "unit_translations_unit_code_language_tag_pk", + "columns": [ + "unit_code", + "language_tag" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.allergen_market_rules": { + "name": "allergen_market_rules", + "schema": "", + "columns": { + "region_code": { + "name": "region_code", + "type": "varchar(2)", + "primaryKey": false, + "notNull": true + }, + "allergen": { + "name": "allergen", + "type": "allergen", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "must_highlight": { + "name": "must_highlight", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "allergen_market_rules_region_code_allergen_pk": { + "name": "allergen_market_rules_region_code_allergen_pk", + "columns": [ + "region_code", + "allergen" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.nutrition_display_profiles": { + "name": "nutrition_display_profiles", + "schema": "", + "columns": { + "region_code": { + "name": "region_code", + "type": "varchar(2)", + "primaryKey": true, + "notNull": true + }, + "energy_display": { + "name": "energy_display", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "salt_display": { + "name": "salt_display", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "energy_label_key": { + "name": "energy_label_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.meal_boxes": { + "name": "meal_boxes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "household_id": { + "name": "household_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "recipe_id": { + "name": "recipe_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "cooking_session_id": { + "name": "cooking_session_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "title_sv": { + "name": "title_sv", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "portions": { + "name": "portions", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "portions_remaining": { + "name": "portions_remaining", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "nutrition_per_portion": { + "name": "nutrition_per_portion", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "cooked_at": { + "name": "cooked_at", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "storage_location_id": { + "name": "storage_location_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "frozen": { + "name": "frozen", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "recommended_use_by": { + "name": "recommended_use_by", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "leftover_source": { + "name": "leftover_source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reserved_for_user_id": { + "name": "reserved_for_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'available'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "meal_boxes_household_idx": { + "name": "meal_boxes_household_idx", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "meal_boxes_use_by_idx": { + "name": "meal_boxes_use_by_idx", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "recommended_use_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "meal_boxes_household_id_households_id_fk": { + "name": "meal_boxes_household_id_households_id_fk", + "tableFrom": "meal_boxes", + "tableTo": "households", + "columnsFrom": [ + "household_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "meal_boxes_recipe_id_recipes_id_fk": { + "name": "meal_boxes_recipe_id_recipes_id_fk", + "tableFrom": "meal_boxes", + "tableTo": "recipes", + "columnsFrom": [ + "recipe_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "meal_boxes_cooking_session_id_cooking_sessions_id_fk": { + "name": "meal_boxes_cooking_session_id_cooking_sessions_id_fk", + "tableFrom": "meal_boxes", + "tableTo": "cooking_sessions", + "columnsFrom": [ + "cooking_session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "meal_boxes_storage_location_id_storage_locations_id_fk": { + "name": "meal_boxes_storage_location_id_storage_locations_id_fk", + "tableFrom": "meal_boxes", + "tableTo": "storage_locations", + "columnsFrom": [ + "storage_location_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "meal_boxes_reserved_for_user_id_users_id_fk": { + "name": "meal_boxes_reserved_for_user_id_users_id_fk", + "tableFrom": "meal_boxes", + "tableTo": "users", + "columnsFrom": [ + "reserved_for_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.meals": { + "name": "meals", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "household_id": { + "name": "household_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "date": { + "name": "date", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "meal_type": { + "name": "meal_type", + "type": "meal_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "meal_log_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "recipe_id": { + "name": "recipe_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "cooking_session_id": { + "name": "cooking_session_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "title_sv": { + "name": "title_sv", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "portion_fraction": { + "name": "portion_fraction", + "type": "double precision", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "nutrition": { + "name": "nutrition", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "nutrition_is_estimate": { + "name": "nutrition_is_estimate", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "estimate_min_kcal": { + "name": "estimate_min_kcal", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "estimate_max_kcal": { + "name": "estimate_max_kcal", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "items": { + "name": "items", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "photo_url": { + "name": "photo_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scan_job_id": { + "name": "scan_job_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "logged_at": { + "name": "logged_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "meals_user_date_idx": { + "name": "meals_user_date_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "meals_household_idx": { + "name": "meals_household_idx", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "meals_user_id_users_id_fk": { + "name": "meals_user_id_users_id_fk", + "tableFrom": "meals", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "meals_household_id_households_id_fk": { + "name": "meals_household_id_households_id_fk", + "tableFrom": "meals", + "tableTo": "households", + "columnsFrom": [ + "household_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "meals_recipe_id_recipes_id_fk": { + "name": "meals_recipe_id_recipes_id_fk", + "tableFrom": "meals", + "tableTo": "recipes", + "columnsFrom": [ + "recipe_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "meals_cooking_session_id_cooking_sessions_id_fk": { + "name": "meals_cooking_session_id_cooking_sessions_id_fk", + "tableFrom": "meals", + "tableTo": "cooking_sessions", + "columnsFrom": [ + "cooking_session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.shopping_list_items": { + "name": "shopping_list_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "shopping_list_id": { + "name": "shopping_list_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "canonical_ingredient_id": { + "name": "canonical_ingredient_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "quantity": { + "name": "quantity", + "type": "double precision", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "unit": { + "name": "unit", + "type": "unit", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'COUNT'" + }, + "store_section": { + "name": "store_section", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'hygien_ovrigt'" + }, + "suggested_package_size": { + "name": "suggested_package_size", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "estimated_price_minor": { + "name": "estimated_price_minor", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "checked": { + "name": "checked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "added_by_user_id": { + "name": "added_by_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'manual'" + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": { + "shopping_list_items_list_idx": { + "name": "shopping_list_items_list_idx", + "columns": [ + { + "expression": "shopping_list_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "shopping_list_items_shopping_list_id_shopping_lists_id_fk": { + "name": "shopping_list_items_shopping_list_id_shopping_lists_id_fk", + "tableFrom": "shopping_list_items", + "tableTo": "shopping_lists", + "columnsFrom": [ + "shopping_list_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shopping_list_items_canonical_ingredient_id_canonical_ingredients_id_fk": { + "name": "shopping_list_items_canonical_ingredient_id_canonical_ingredients_id_fk", + "tableFrom": "shopping_list_items", + "tableTo": "canonical_ingredients", + "columnsFrom": [ + "canonical_ingredient_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "shopping_list_items_added_by_user_id_users_id_fk": { + "name": "shopping_list_items_added_by_user_id_users_id_fk", + "tableFrom": "shopping_list_items", + "tableTo": "users", + "columnsFrom": [ + "added_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.shopping_lists": { + "name": "shopping_lists", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "household_id": { + "name": "household_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'Inköpslista'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "week_plan_id": { + "name": "week_plan_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "shopping_lists_household_idx": { + "name": "shopping_lists_household_idx", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "shopping_lists_household_id_households_id_fk": { + "name": "shopping_lists_household_id_households_id_fk", + "tableFrom": "shopping_lists", + "tableTo": "households", + "columnsFrom": [ + "household_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shopping_lists_week_plan_id_week_plans_id_fk": { + "name": "shopping_lists_week_plan_id_week_plans_id_fk", + "tableFrom": "shopping_lists", + "tableTo": "week_plans", + "columnsFrom": [ + "week_plan_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.week_plan_entries": { + "name": "week_plan_entries", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "week_plan_id": { + "name": "week_plan_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "date": { + "name": "date", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "meal_type": { + "name": "meal_type", + "type": "meal_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "recipe_id": { + "name": "recipe_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "meal_box_id": { + "name": "meal_box_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "title_sv": { + "name": "title_sv", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "portions": { + "name": "portions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 2 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'planned'" + }, + "reschedule_reason_sv": { + "name": "reschedule_reason_sv", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": { + "week_plan_entries_plan_idx": { + "name": "week_plan_entries_plan_idx", + "columns": [ + { + "expression": "week_plan_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "week_plan_entries_week_plan_id_week_plans_id_fk": { + "name": "week_plan_entries_week_plan_id_week_plans_id_fk", + "tableFrom": "week_plan_entries", + "tableTo": "week_plans", + "columnsFrom": [ + "week_plan_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "week_plan_entries_recipe_id_recipes_id_fk": { + "name": "week_plan_entries_recipe_id_recipes_id_fk", + "tableFrom": "week_plan_entries", + "tableTo": "recipes", + "columnsFrom": [ + "recipe_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "week_plan_entries_meal_box_id_meal_boxes_id_fk": { + "name": "week_plan_entries_meal_box_id_meal_boxes_id_fk", + "tableFrom": "week_plan_entries", + "tableTo": "meal_boxes", + "columnsFrom": [ + "meal_box_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.week_plans": { + "name": "week_plans", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "household_id": { + "name": "household_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "week_start_date": { + "name": "week_start_date", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "generated_by": { + "name": "generated_by", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'user'" + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "week_plans_household_week_idx": { + "name": "week_plans_household_week_idx", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "week_start_date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "week_plans_household_id_households_id_fk": { + "name": "week_plans_household_id_households_id_fk", + "tableFrom": "week_plans", + "tableTo": "households", + "columnsFrom": [ + "household_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.price_observations": { + "name": "price_observations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "canonical_ingredient_id": { + "name": "canonical_ingredient_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "product_id": { + "name": "product_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "store_name": { + "name": "store_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "price_minor": { + "name": "price_minor", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "quantity": { + "name": "quantity", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "unit": { + "name": "unit", + "type": "unit", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "price_per_kg_minor": { + "name": "price_per_kg_minor", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "observed_at": { + "name": "observed_at", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'receipt'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "price_observations_ingredient_idx": { + "name": "price_observations_ingredient_idx", + "columns": [ + { + "expression": "canonical_ingredient_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "observed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "price_observations_canonical_ingredient_id_canonical_ingredients_id_fk": { + "name": "price_observations_canonical_ingredient_id_canonical_ingredients_id_fk", + "tableFrom": "price_observations", + "tableTo": "canonical_ingredients", + "columnsFrom": [ + "canonical_ingredient_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "price_observations_product_id_products_id_fk": { + "name": "price_observations_product_id_products_id_fk", + "tableFrom": "price_observations", + "tableTo": "products", + "columnsFrom": [ + "product_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.receipt_lines": { + "name": "receipt_lines", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "receipt_id": { + "name": "receipt_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "raw_text": { + "name": "raw_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "normalized_name": { + "name": "normalized_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "canonical_ingredient_id": { + "name": "canonical_ingredient_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "product_id": { + "name": "product_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "quantity": { + "name": "quantity", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "unit": { + "name": "unit", + "type": "unit", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "unit_price_minor": { + "name": "unit_price_minor", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "total_price_minor": { + "name": "total_price_minor", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "confidence": { + "name": "confidence", + "type": "double precision", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "verified_by_user": { + "name": "verified_by_user", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "added_to_inventory": { + "name": "added_to_inventory", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "receipt_lines_receipt_idx": { + "name": "receipt_lines_receipt_idx", + "columns": [ + { + "expression": "receipt_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "receipt_lines_receipt_id_receipts_id_fk": { + "name": "receipt_lines_receipt_id_receipts_id_fk", + "tableFrom": "receipt_lines", + "tableTo": "receipts", + "columnsFrom": [ + "receipt_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "receipt_lines_canonical_ingredient_id_canonical_ingredients_id_fk": { + "name": "receipt_lines_canonical_ingredient_id_canonical_ingredients_id_fk", + "tableFrom": "receipt_lines", + "tableTo": "canonical_ingredients", + "columnsFrom": [ + "canonical_ingredient_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "receipt_lines_product_id_products_id_fk": { + "name": "receipt_lines_product_id_products_id_fk", + "tableFrom": "receipt_lines", + "tableTo": "products", + "columnsFrom": [ + "product_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.receipts": { + "name": "receipts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "household_id": { + "name": "household_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "store_name": { + "name": "store_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "purchase_date": { + "name": "purchase_date", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "total_minor": { + "name": "total_minor", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "discount_minor": { + "name": "discount_minor", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "image_url": { + "name": "image_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scan_job_id": { + "name": "scan_job_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "receipts_household_idx": { + "name": "receipts_household_idx", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "purchase_date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "receipts_household_id_households_id_fk": { + "name": "receipts_household_id_households_id_fk", + "tableFrom": "receipts", + "tableTo": "households", + "columnsFrom": [ + "household_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ai_corrections": { + "name": "ai_corrections", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "scan_job_id": { + "name": "scan_job_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "task_type": { + "name": "task_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ai_output": { + "name": "ai_output", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "user_correction": { + "name": "user_correction", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "proposal": { + "name": "proposal", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "image_s3_key": { + "name": "image_s3_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model_version": { + "name": "model_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prompt_version": { + "name": "prompt_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "consent_snapshot": { + "name": "consent_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "exported_to_training": { + "name": "exported_to_training", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "training_batch_id": { + "name": "training_batch_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "ai_corrections_task_idx": { + "name": "ai_corrections_task_idx", + "columns": [ + { + "expression": "task_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ai_corrections_scan_job_id_scan_jobs_id_fk": { + "name": "ai_corrections_scan_job_id_scan_jobs_id_fk", + "tableFrom": "ai_corrections", + "tableTo": "scan_jobs", + "columnsFrom": [ + "scan_job_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "ai_corrections_user_id_users_id_fk": { + "name": "ai_corrections_user_id_users_id_fk", + "tableFrom": "ai_corrections", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ai_training_bank": { + "name": "ai_training_bank", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "correction_id": { + "name": "correction_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "scan_job_id": { + "name": "scan_job_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'v1'" + }, + "task_type": { + "name": "task_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "image_s3_key": { + "name": "image_s3_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "proposal": { + "name": "proposal", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "corrected": { + "name": "corrected", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "model_version": { + "name": "model_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prompt_version": { + "name": "prompt_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "consent_snapshot": { + "name": "consent_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "exported_at": { + "name": "exported_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "ai_training_bank_correction_unique": { + "name": "ai_training_bank_correction_unique", + "columns": [ + { + "expression": "correction_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "ai_training_bank_version_idx": { + "name": "ai_training_bank_version_idx", + "columns": [ + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "task_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "ai_training_bank_scan_job_idx": { + "name": "ai_training_bank_scan_job_idx", + "columns": [ + { + "expression": "scan_job_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "ai_training_bank_created_at_idx": { + "name": "ai_training_bank_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ai_training_bank_correction_id_ai_corrections_id_fk": { + "name": "ai_training_bank_correction_id_ai_corrections_id_fk", + "tableFrom": "ai_training_bank", + "tableTo": "ai_corrections", + "columnsFrom": [ + "correction_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ai_training_bank_scan_job_id_scan_jobs_id_fk": { + "name": "ai_training_bank_scan_job_id_scan_jobs_id_fk", + "tableFrom": "ai_training_bank", + "tableTo": "scan_jobs", + "columnsFrom": [ + "scan_job_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.scan_jobs": { + "name": "scan_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "household_id": { + "name": "household_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "scan_type": { + "name": "scan_type", + "type": "scan_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "job_type": { + "name": "job_type", + "type": "job_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "job_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "s3_keys": { + "name": "s3_keys", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "context": { + "name": "context", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "result": { + "name": "result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model_version": { + "name": "model_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prompt_version": { + "name": "prompt_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "latency_ms": { + "name": "latency_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "cost_usd": { + "name": "cost_usd", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "scan_jobs_user_idx": { + "name": "scan_jobs_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "scan_jobs_status_idx": { + "name": "scan_jobs_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "scan_jobs_user_id_users_id_fk": { + "name": "scan_jobs_user_id_users_id_fk", + "tableFrom": "scan_jobs", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "scan_jobs_household_id_households_id_fk": { + "name": "scan_jobs_household_id_households_id_fk", + "tableFrom": "scan_jobs", + "tableTo": "households", + "columnsFrom": [ + "household_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.food_memories": { + "name": "food_memories", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "household_id": { + "name": "household_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "recipe_id": { + "name": "recipe_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "title_sv": { + "name": "title_sv", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "summary_sv": { + "name": "summary_sv", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "holiday_tag": { + "name": "holiday_tag", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "photo_url": { + "name": "photo_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stars": { + "name": "stars", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "occurred_at": { + "name": "occurred_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "food_memories_household_idx": { + "name": "food_memories_household_idx", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "food_memories_holiday_idx": { + "name": "food_memories_holiday_idx", + "columns": [ + { + "expression": "holiday_tag", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "food_memories_user_id_users_id_fk": { + "name": "food_memories_user_id_users_id_fk", + "tableFrom": "food_memories", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "food_memories_household_id_households_id_fk": { + "name": "food_memories_household_id_households_id_fk", + "tableFrom": "food_memories", + "tableTo": "households", + "columnsFrom": [ + "household_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "food_memories_recipe_id_recipes_id_fk": { + "name": "food_memories_recipe_id_recipes_id_fk", + "tableFrom": "food_memories", + "tableTo": "recipes", + "columnsFrom": [ + "recipe_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.memory_items": { + "name": "memory_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "household_id": { + "name": "household_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "kind": { + "name": "kind", + "type": "memory_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "summary_sv": { + "name": "summary_sv", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "origin": { + "name": "origin", + "type": "signal_origin", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "confidence": { + "name": "confidence", + "type": "double precision", + "primaryKey": false, + "notNull": true, + "default": 0.5 + }, + "verified_by_user": { + "name": "verified_by_user", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "paused": { + "name": "paused", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "memory_items_user_idx": { + "name": "memory_items_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_items_household_idx": { + "name": "memory_items_household_idx", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_items_key_idx": { + "name": "memory_items_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "memory_items_user_id_users_id_fk": { + "name": "memory_items_user_id_users_id_fk", + "tableFrom": "memory_items", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "memory_items_household_id_households_id_fk": { + "name": "memory_items_household_id_households_id_fk", + "tableFrom": "memory_items", + "tableTo": "households", + "columnsFrom": [ + "household_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.taste_signals": { + "name": "taste_signals", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "axis": { + "name": "axis", + "type": "taste_axis", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "direction": { + "name": "direction", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "strength": { + "name": "strength", + "type": "double precision", + "primaryKey": false, + "notNull": true, + "default": 0.5 + }, + "origin": { + "name": "origin", + "type": "signal_origin", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "ref_recipe_id": { + "name": "ref_recipe_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "taste_signals_user_idx": { + "name": "taste_signals_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "axis", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "taste_signals_user_id_users_id_fk": { + "name": "taste_signals_user_id_users_id_fk", + "tableFrom": "taste_signals", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "taste_signals_ref_recipe_id_recipes_id_fk": { + "name": "taste_signals_ref_recipe_id_recipes_id_fk", + "tableFrom": "taste_signals", + "tableTo": "recipes", + "columnsFrom": [ + "ref_recipe_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.season_events": { + "name": "season_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name_sv": { + "name": "name_sv", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "market": { + "name": "market", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'SE'" + }, + "date_rule": { + "name": "date_rule", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "lead_days": { + "name": "lead_days", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 7 + }, + "food_tags": { + "name": "food_tags", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "recipe_slugs": { + "name": "recipe_slugs", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "season_events_slug_market_unique": { + "name": "season_events_slug_market_unique", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "market", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ai_usage_counters": { + "name": "ai_usage_counters", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "month": { + "name": "month", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ai_scans": { + "name": "ai_scans", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "ai_tokens_in": { + "name": "ai_tokens_in", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "ai_tokens_out": { + "name": "ai_tokens_out", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "ai_cost_usd_microcents": { + "name": "ai_cost_usd_microcents", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "ai_usage_user_month_unique": { + "name": "ai_usage_user_month_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "month", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ai_usage_counters_user_id_users_id_fk": { + "name": "ai_usage_counters_user_id_users_id_fk", + "tableFrom": "ai_usage_counters", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.store_notifications": { + "name": "store_notifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "provider": { + "name": "provider", + "type": "subscription_provider", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "notification_type": { + "name": "notification_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "raw_payload": { + "name": "raw_payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "signature_verified": { + "name": "signature_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "processed": { + "name": "processed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "processed_at": { + "name": "processed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "store_notifications_pending_idx": { + "name": "store_notifications_pending_idx", + "columns": [ + { + "expression": "processed", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.store_transactions": { + "name": "store_transactions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "provider": { + "name": "provider", + "type": "subscription_provider", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "transaction_id": { + "name": "transaction_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "original_transaction_id": { + "name": "original_transaction_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "pseudonym": { + "name": "pseudonym", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "product_id": { + "name": "product_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "raw_payload": { + "name": "raw_payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "processed_at": { + "name": "processed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "store_transactions_unique": { + "name": "store_transactions_unique", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "transaction_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "store_transactions_pseudonym_idx": { + "name": "store_transactions_pseudonym_idx", + "columns": [ + { + "expression": "pseudonym", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "store_transactions_user_id_users_id_fk": { + "name": "store_transactions_user_id_users_id_fk", + "tableFrom": "store_transactions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "store_transactions_pseudonym_gdpr_retention_pseudonyms_pseudonym_fk": { + "name": "store_transactions_pseudonym_gdpr_retention_pseudonyms_pseudonym_fk", + "tableFrom": "store_transactions", + "tableTo": "gdpr_retention_pseudonyms", + "columnsFrom": [ + "pseudonym" + ], + "columnsTo": [ + "pseudonym" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.subscription_events": { + "name": "subscription_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "subscription_id": { + "name": "subscription_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "pseudonym": { + "name": "pseudonym", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "subscription_events_sub_idx": { + "name": "subscription_events_sub_idx", + "columns": [ + { + "expression": "subscription_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "subscription_events_pseudonym_idx": { + "name": "subscription_events_pseudonym_idx", + "columns": [ + { + "expression": "pseudonym", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "subscription_events_subscription_id_subscriptions_id_fk": { + "name": "subscription_events_subscription_id_subscriptions_id_fk", + "tableFrom": "subscription_events", + "tableTo": "subscriptions", + "columnsFrom": [ + "subscription_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "subscription_events_user_id_users_id_fk": { + "name": "subscription_events_user_id_users_id_fk", + "tableFrom": "subscription_events", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "subscription_events_pseudonym_gdpr_retention_pseudonyms_pseudonym_fk": { + "name": "subscription_events_pseudonym_gdpr_retention_pseudonyms_pseudonym_fk", + "tableFrom": "subscription_events", + "tableTo": "gdpr_retention_pseudonyms", + "columnsFrom": [ + "pseudonym" + ], + "columnsTo": [ + "pseudonym" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.subscriptions": { + "name": "subscriptions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "pseudonym": { + "name": "pseudonym", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "household_id": { + "name": "household_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "subscription_provider", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "product_id": { + "name": "product_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "plan": { + "name": "plan", + "type": "subscription_plan", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "original_transaction_id": { + "name": "original_transaction_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "subscription_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "purchased_at": { + "name": "purchased_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "grace_period_expires_at": { + "name": "grace_period_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "canceled_at": { + "name": "canceled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_verified_at": { + "name": "last_verified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "subscriptions_user_idx": { + "name": "subscriptions_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "subscriptions_pseudonym_idx": { + "name": "subscriptions_pseudonym_idx", + "columns": [ + { + "expression": "pseudonym", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "subscriptions_original_tx_unique": { + "name": "subscriptions_original_tx_unique", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "original_transaction_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"subscriptions\".\"original_transaction_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "subscriptions_user_id_users_id_fk": { + "name": "subscriptions_user_id_users_id_fk", + "tableFrom": "subscriptions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "subscriptions_pseudonym_gdpr_retention_pseudonyms_pseudonym_fk": { + "name": "subscriptions_pseudonym_gdpr_retention_pseudonyms_pseudonym_fk", + "tableFrom": "subscriptions", + "tableTo": "gdpr_retention_pseudonyms", + "columnsFrom": [ + "pseudonym" + ], + "columnsTo": [ + "pseudonym" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "subscriptions_household_id_households_id_fk": { + "name": "subscriptions_household_id_households_id_fk", + "tableFrom": "subscriptions", + "tableTo": "households", + "columnsFrom": [ + "household_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.trials": { + "name": "trials", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "ends_at": { + "name": "ends_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "trials_user_id_users_id_fk": { + "name": "trials_user_id_users_id_fk", + "tableFrom": "trials", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.analytics_daily_snapshots": { + "name": "analytics_daily_snapshots", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "date": { + "name": "date", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true + }, + "household_id": { + "name": "household_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "event_name": { + "name": "event_name", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "unique_sessions": { + "name": "unique_sessions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "properties_fingerprint": { + "name": "properties_fingerprint", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "pa_daily_household_date_idx": { + "name": "pa_daily_household_date_idx", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pa_daily_date_event_idx": { + "name": "pa_daily_date_event_idx", + "columns": [ + { + "expression": "date", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "event_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "analytics_daily_snapshots_household_id_households_id_fk": { + "name": "analytics_daily_snapshots_household_id_households_id_fk", + "tableFrom": "analytics_daily_snapshots", + "tableTo": "households", + "columnsFrom": [ + "household_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.product_analytics_events": { + "name": "product_analytics_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "occurred_at": { + "name": "occurred_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "received_at": { + "name": "received_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "event_name": { + "name": "event_name", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "anonymous_id": { + "name": "anonymous_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "session_id": { + "name": "session_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "household_id": { + "name": "household_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "app_version": { + "name": "app_version", + "type": "varchar(32)", + "primaryKey": false, + "notNull": false + }, + "platform": { + "name": "platform", + "type": "varchar(16)", + "primaryKey": false, + "notNull": false + }, + "locale": { + "name": "locale", + "type": "varchar(16)", + "primaryKey": false, + "notNull": false + }, + "experiment_variant": { + "name": "experiment_variant", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "properties": { + "name": "properties", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + } + }, + "indexes": { + "pa_events_name_occurred_idx": { + "name": "pa_events_name_occurred_idx", + "columns": [ + { + "expression": "event_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pa_events_household_occurred_idx": { + "name": "pa_events_household_occurred_idx", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pa_events_user_occurred_idx": { + "name": "pa_events_user_occurred_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pa_events_session_idx": { + "name": "pa_events_session_idx", + "columns": [ + { + "expression": "session_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pa_events_received_idx": { + "name": "pa_events_received_idx", + "columns": [ + { + "expression": "received_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "product_analytics_events_user_id_users_id_fk": { + "name": "product_analytics_events_user_id_users_id_fk", + "tableFrom": "product_analytics_events", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "product_analytics_events_household_id_households_id_fk": { + "name": "product_analytics_events_household_id_households_id_fk", + "tableFrom": "product_analytics_events", + "tableTo": "households", + "columnsFrom": [ + "household_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ai_eval_cases": { + "name": "ai_eval_cases", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "task_type": { + "name": "task_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name_sv": { + "name": "name_sv", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "input_ref": { + "name": "input_ref", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "expected_output": { + "name": "expected_output", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'standard'" + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ai_eval_runs": { + "name": "ai_eval_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "task_type": { + "name": "task_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "model_version": { + "name": "model_version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "prompt_version": { + "name": "prompt_version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metrics": { + "name": "metrics", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "passed": { + "name": "passed", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "ai_eval_runs_task_idx": { + "name": "ai_eval_runs_task_idx", + "columns": [ + { + "expression": "task_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_logs": { + "name": "audit_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "actor_user_id": { + "name": "actor_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "actor_type": { + "name": "actor_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'user'" + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "ip": { + "name": "ip", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "correlation_id": { + "name": "correlation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_logs_actor_idx": { + "name": "audit_logs_actor_idx", + "columns": [ + { + "expression": "actor_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_logs_action_idx": { + "name": "audit_logs_action_idx", + "columns": [ + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.creator_follows": { + "name": "creator_follows", + "schema": "", + "columns": { + "follower_user_id": { + "name": "follower_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "creator_user_id": { + "name": "creator_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "creator_follows_follower_user_id_users_id_fk": { + "name": "creator_follows_follower_user_id_users_id_fk", + "tableFrom": "creator_follows", + "tableTo": "users", + "columnsFrom": [ + "follower_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "creator_follows_creator_user_id_users_id_fk": { + "name": "creator_follows_creator_user_id_users_id_fk", + "tableFrom": "creator_follows", + "tableTo": "users", + "columnsFrom": [ + "creator_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "creator_follows_follower_user_id_creator_user_id_pk": { + "name": "creator_follows_follower_user_id_creator_user_id_pk", + "columns": [ + "follower_user_id", + "creator_user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.creator_stats": { + "name": "creator_stats", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "visibility": { + "name": "visibility", + "type": "profile_visibility", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'private'" + }, + "level": { + "name": "level", + "type": "creator_level", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'beginner'" + }, + "published_recipes": { + "name": "published_recipes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "followers": { + "name": "followers", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_cooks": { + "name": "total_cooks", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_favorites": { + "name": "total_favorites", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "average_rating": { + "name": "average_rating", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "verified_recipes": { + "name": "verified_recipes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "badges": { + "name": "badges", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "creator_stats_user_id_users_id_fk": { + "name": "creator_stats_user_id_users_id_fk", + "tableFrom": "creator_stats", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.domain_events": { + "name": "domain_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "type": { + "name": "type", + "type": "event_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "household_id": { + "name": "household_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "correlation_id": { + "name": "correlation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "occurred_at": { + "name": "occurred_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "domain_events_unpublished_idx": { + "name": "domain_events_unpublished_idx", + "columns": [ + { + "expression": "published_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "domain_events_type_idx": { + "name": "domain_events_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "domain_events_household_idx": { + "name": "domain_events_household_idx", + "columns": [ + { + "expression": "household_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.feature_flags": { + "name": "feature_flags", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "description_sv": { + "name": "description_sv", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rollout_percent": { + "name": "rollout_percent", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 100 + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.idempotency_keys": { + "name": "idempotency_keys", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "endpoint": { + "name": "endpoint", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "response_status": { + "name": "response_status", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "response_body": { + "name": "response_body", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "idempotency_keys_user_id_users_id_fk": { + "name": "idempotency_keys_user_id_users_id_fk", + "tableFrom": "idempotency_keys", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "idempotency_keys_user_id_key_endpoint_pk": { + "name": "idempotency_keys_user_id_key_endpoint_pk", + "columns": [ + "user_id", + "key", + "endpoint" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notifications": { + "name": "notifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "notification_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "title_sv": { + "name": "title_sv", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body_sv": { + "name": "body_sv", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "template_key": { + "name": "template_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "variables": { + "name": "variables", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "locale": { + "name": "locale", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scheduled_for": { + "name": "scheduled_for", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "read_at": { + "name": "read_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "notifications_user_idx": { + "name": "notifications_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "notifications_user_id_users_id_fk": { + "name": "notifications_user_id_users_id_fk", + "tableFrom": "notifications", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.push_tokens": { + "name": "push_tokens", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "platform": { + "name": "platform", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "push_tokens_user_id_users_id_fk": { + "name": "push_tokens_user_id_users_id_fk", + "tableFrom": "push_tokens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "push_tokens_user_id_token_pk": { + "name": "push_tokens_user_id_token_pk", + "columns": [ + "user_id", + "token" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.waste_summaries": { + "name": "waste_summaries", + "schema": "", + "columns": { + "household_id": { + "name": "household_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "week_start_date": { + "name": "week_start_date", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "discarded_count": { + "name": "discarded_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "discarded_value_minor": { + "name": "discarded_value_minor", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "waste_summaries_household_id_households_id_fk": { + "name": "waste_summaries_household_id_households_id_fk", + "tableFrom": "waste_summaries", + "tableTo": "households", + "columnsFrom": [ + "household_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "waste_summaries_household_id_week_start_date_pk": { + "name": "waste_summaries_household_id_week_start_date_pk", + "columns": [ + "household_id", + "week_start_date" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.release_gates": { + "name": "release_gates", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "gate_key": { + "name": "gate_key", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "category": { + "name": "category", + "type": "release_gate_category", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "name_sv": { + "name": "name_sv", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name_en": { + "name": "name_en", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description_sv": { + "name": "description_sv", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description_en": { + "name": "description_en", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "target_value": { + "name": "target_value", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "comparison": { + "name": "comparison", + "type": "release_gate_comparison", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "evaluation_window_days": { + "name": "evaluation_window_days", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 7 + }, + "last_value": { + "name": "last_value", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "last_evaluated_at": { + "name": "last_evaluated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "release_gate_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "blocking": { + "name": "blocking", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "release_gates_category_idx": { + "name": "release_gates_category_idx", + "columns": [ + { + "expression": "category", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "release_gates_status_idx": { + "name": "release_gates_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "release_gates_gate_key_unique": { + "name": "release_gates_gate_key_unique", + "nullsNotDistinct": false, + "columns": [ + "gate_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.activity_level": { + "name": "activity_level", + "schema": "public", + "values": [ + "sedentary", + "light", + "moderate", + "active", + "very_active" + ] + }, + "public.allergen": { + "name": "allergen", + "schema": "public", + "values": [ + "gluten", + "crustaceans", + "eggs", + "fish", + "peanuts", + "soy", + "milk", + "tree_nuts", + "celery", + "mustard", + "sesame", + "sulphites", + "lupin", + "molluscs" + ] + }, + "public.consent_kind": { + "name": "consent_kind", + "schema": "public", + "values": [ + "personalization", + "anonymized_improvement", + "image_training", + "health_integration", + "location_weather", + "push_notifications", + "product_analytics" + ] + }, + "public.consent_status": { + "name": "consent_status", + "schema": "public", + "values": [ + "granted", + "denied", + "revoked" + ] + }, + "public.cooking_method": { + "name": "cooking_method", + "schema": "public", + "values": [ + "stovetop", + "oven", + "grill", + "airfryer", + "wok", + "slow_cooker", + "sous_vide", + "microwave", + "no_cook", + "pressure_cooker", + "deep_fry", + "steam" + ] + }, + "public.creator_level": { + "name": "creator_level", + "schema": "public", + "values": [ + "beginner", + "sous_chef", + "chef", + "master_chef", + "legend" + ] + }, + "public.cuisine": { + "name": "cuisine", + "schema": "public", + "values": [ + "swedish", + "nordic", + "italian", + "french", + "spanish", + "greek", + "thai", + "chinese", + "japanese", + "korean", + "vietnamese", + "indian", + "mexican", + "american", + "turkish", + "lebanese", + "moroccan", + "middle_eastern", + "international" + ] + }, + "public.date_kind": { + "name": "date_kind", + "schema": "public", + "values": [ + "best_before", + "use_by" + ] + }, + "public.diet_pattern": { + "name": "diet_pattern", + "schema": "public", + "values": [ + "omnivore", + "flexitarian", + "pescatarian", + "vegetarian", + "vegan", + "low_carb", + "keto", + "high_protein", + "mediterranean" + ] + }, + "public.event_type": { + "name": "event_type", + "schema": "public", + "values": [ + "PRODUCT_ADDED", + "PRODUCT_UPDATED", + "PRODUCT_CONSUMED", + "PRODUCT_DISCARDED", + "RECIPE_COOKED", + "RECIPE_RATED", + "RECIPE_CREATED", + "RECIPE_FORKED", + "MEAL_LOGGED", + "MEAL_PHOTO_ANALYZED", + "HOUSEHOLD_MEMBER_ADDED", + "SUBSCRIPTION_STARTED", + "SUBSCRIPTION_CHANGED", + "AI_CORRECTED", + "MEMORY_UPDATED", + "SHOPPING_COMPLETED", + "WEEK_PLAN_UPDATED", + "MEAL_BOX_CREATED", + "MEAL_BOX_UPDATED", + "MEAL_BOX_CONSUMED", + "MEAL_BOX_DISCARDED", + "MEAL_REMOVED", + "COOKING_SESSION_UNDONE" + ] + }, + "public.expiry_status": { + "name": "expiry_status", + "schema": "public", + "values": [ + "fresh", + "use_soon", + "expiring", + "expired", + "unknown" + ] + }, + "public.goal_type": { + "name": "goal_type", + "schema": "public", + "values": [ + "lose_weight", + "gain_weight", + "build_muscle", + "maintain_weight", + "more_protein", + "less_fat", + "more_fiber", + "more_variety", + "less_waste", + "lower_cost", + "cook_more" + ] + }, + "public.household_role": { + "name": "household_role", + "schema": "public", + "values": [ + "owner", + "adult", + "member", + "child" + ] + }, + "public.inventory_source": { + "name": "inventory_source", + "schema": "public", + "values": [ + "fridge_photo", + "freezer_photo", + "pantry_photo", + "ingredient_photo", + "barcode", + "receipt", + "digital_receipt", + "label_photo", + "manual_search", + "free_text", + "voice", + "cooked_recipe", + "connector", + "seed" + ] + }, + "public.inventory_transaction_type": { + "name": "inventory_transaction_type", + "schema": "public", + "values": [ + "purchase", + "consume", + "discard", + "adjust", + "cook_use", + "leftover_created", + "leftover_consumed", + "correction", + "freeze", + "thaw", + "move" + ] + }, + "public.job_status": { + "name": "job_status", + "schema": "public", + "values": [ + "queued", + "running", + "awaiting_confirmation", + "completed", + "failed", + "canceled" + ] + }, + "public.job_type": { + "name": "job_type", + "schema": "public", + "values": [ + "ANALYZE_FRIDGE_IMAGE", + "ANALYZE_PANTRY_IMAGE", + "ANALYZE_MEAL_IMAGE", + "READ_RECEIPT", + "READ_NUTRITION_LABEL", + "READ_EXPIRY_DATE", + "NORMALIZE_PRODUCTS", + "DEDUPLICATE_INVENTORY", + "CALCULATE_NUTRITION", + "GENERATE_RECIPE_OPTIONS", + "RANK_RECIPES", + "UPDATE_USER_MEMORY", + "GENERATE_WEEK_PLAN", + "SEND_EXPIRY_NOTIFICATION", + "VERIFY_SUBSCRIPTION", + "PROCESS_STORE_NOTIFICATION", + "BUILD_TRAINING_SAMPLE", + "RUN_AI_EVALUATION" + ] + }, + "public.meal_log_source": { + "name": "meal_log_source", + "schema": "public", + "values": [ + "cooked_recipe", + "plate_photo", + "barcode", + "product", + "free_text", + "voice", + "previous_meal", + "meal_box", + "manual" + ] + }, + "public.meal_type": { + "name": "meal_type", + "schema": "public", + "values": [ + "breakfast", + "lunch", + "dinner", + "snack", + "dessert", + "starter", + "buffet", + "party" + ] + }, + "public.memory_kind": { + "name": "memory_kind", + "schema": "public", + "values": [ + "structured_fact", + "event", + "semantic", + "profile_summary", + "recipe_memory" + ] + }, + "public.notification_type": { + "name": "notification_type", + "schema": "public", + "values": [ + "expiry_warning", + "meal_box_reminder", + "quick_dinner_suggestion", + "holiday_upcoming", + "creator_new_recipe", + "week_plan_change", + "pantry_forecast", + "subscription_status" + ] + }, + "public.precision_mode": { + "name": "precision_mode", + "schema": "public", + "values": [ + "simple", + "exact" + ] + }, + "public.profile_visibility": { + "name": "profile_visibility", + "schema": "public", + "values": [ + "private", + "friends", + "national", + "global" + ] + }, + "public.recipe_difficulty": { + "name": "recipe_difficulty", + "schema": "public", + "values": [ + "beginner", + "easy", + "medium", + "advanced", + "expert" + ] + }, + "public.recipe_similarity_class": { + "name": "recipe_similarity_class", + "schema": "public", + "values": [ + "duplicate", + "variant", + "inspired", + "independent" + ] + }, + "public.recipe_source_type": { + "name": "recipe_source_type", + "schema": "public", + "values": [ + "own_editorial", + "ai_assisted_reviewed", + "open_license", + "public_domain", + "licensed_database", + "creator_agreement", + "user_generated" + ] + }, + "public.recipe_status": { + "name": "recipe_status", + "schema": "public", + "values": [ + "draft", + "submitted", + "ai_checked", + "in_moderation", + "published", + "rejected", + "archived" + ] + }, + "public.recipe_variant_type": { + "name": "recipe_variant_type", + "schema": "public", + "values": [ + "standard", + "high_protein", + "low_calorie", + "low_fat", + "vegetarian", + "vegan", + "gluten_free", + "lactose_free", + "kid_friendly", + "airfryer", + "budget" + ] + }, + "public.recipe_verification_status": { + "name": "recipe_verification_status", + "schema": "public", + "values": [ + "unverified", + "community", + "verified", + "editorial" + ] + }, + "public.religious_rule": { + "name": "religious_rule", + "schema": "public", + "values": [ + "none", + "halal", + "kosher", + "hindu_no_beef", + "buddhist_vegetarian" + ] + }, + "public.scan_type": { + "name": "scan_type", + "schema": "public", + "values": [ + "fridge", + "freezer", + "pantry", + "ingredients", + "plate", + "receipt", + "barcode", + "expiry_date", + "nutrition_label", + "product_package" + ] + }, + "public.sex": { + "name": "sex", + "schema": "public", + "values": [ + "female", + "male", + "unspecified" + ] + }, + "public.signal_origin": { + "name": "signal_origin", + "schema": "public", + "values": [ + "user_stated", + "observed", + "ai_inferred" + ] + }, + "public.storage_location_type": { + "name": "storage_location_type", + "schema": "public", + "values": [ + "fridge", + "freezer", + "pantry", + "garage_freezer", + "wine_fridge", + "cellar", + "meal_box", + "custom" + ] + }, + "public.subscription_plan": { + "name": "subscription_plan", + "schema": "public", + "values": [ + "free", + "household", + "family", + "large_household" + ] + }, + "public.subscription_provider": { + "name": "subscription_provider", + "schema": "public", + "values": [ + "apple", + "google", + "promo", + "none" + ] + }, + "public.subscription_status": { + "name": "subscription_status", + "schema": "public", + "values": [ + "trial", + "active", + "in_grace", + "on_hold", + "paused", + "canceled", + "expired" + ] + }, + "public.taste_axis": { + "name": "taste_axis", + "schema": "public", + "values": [ + "spice", + "salt", + "acid", + "creaminess", + "garlic", + "sweetness", + "herbs", + "umami" + ] + }, + "public.unit": { + "name": "unit", + "schema": "public", + "values": [ + "GRAM", + "KILOGRAM", + "MILLILITER", + "DECILITER", + "LITER", + "TEASPOON", + "TABLESPOON", + "CUP_US", + "FLUID_OUNCE_US", + "OUNCE", + "POUND", + "COUNT", + "PORTION", + "PINCH", + "SLICE", + "CLOVE", + "CAN", + "PACKAGE" + ] + }, + "public.user_role": { + "name": "user_role", + "schema": "public", + "values": [ + "user", + "moderator", + "admin" + ] + }, + "public.verification_status": { + "name": "verification_status", + "schema": "public", + "values": [ + "unverified", + "user_verified", + "editorially_verified" + ] + }, + "public.measurement_system": { + "name": "measurement_system", + "schema": "public", + "values": [ + "METRIC", + "US_CUSTOMARY", + "MIXED" + ] + }, + "public.temperature_unit": { + "name": "temperature_unit", + "schema": "public", + "values": [ + "CELSIUS", + "FAHRENHEIT" + ] + }, + "public.translation_source": { + "name": "translation_source", + "schema": "public", + "values": [ + "seed", + "ai", + "human" + ] + }, + "public.translation_status": { + "name": "translation_status", + "schema": "public", + "values": [ + "draft_ai", + "in_review", + "published" + ] + }, + "public.release_gate_category": { + "name": "release_gate_category", + "schema": "public", + "values": [ + "product", + "quality", + "retention", + "economy" + ] + }, + "public.release_gate_comparison": { + "name": "release_gate_comparison", + "schema": "public", + "values": [ + "gte", + "lte", + "eq" + ] + }, + "public.release_gate_status": { + "name": "release_gate_status", + "schema": "public", + "values": [ + "pending", + "passed", + "failed", + "blocked", + "not_measurable" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/infrastructure/migrations/meta/_journal.json b/infrastructure/migrations/meta/_journal.json index 7773279..5a45c44 100644 --- a/infrastructure/migrations/meta/_journal.json +++ b/infrastructure/migrations/meta/_journal.json @@ -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 } ] } \ No newline at end of file diff --git a/packages/database/src/gdpr-erasure.ts b/packages/database/src/gdpr-erasure.ts new file mode 100644 index 0000000..0fb3a39 --- /dev/null +++ b/packages/database/src/gdpr-erasure.ts @@ -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; + +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 { + 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`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 { + 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`count(*)::int` }) + .from(schema.subscriptions) + .where(eq(schema.subscriptions.userId, userId)); + const txCountRow = await db + .select({ count: sql`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; +} diff --git a/packages/database/src/index.ts b/packages/database/src/index.ts index 38aaa00..15892eb 100644 --- a/packages/database/src/index.ts +++ b/packages/database/src/index.ts @@ -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"; diff --git a/packages/database/src/schema/gdpr.ts b/packages/database/src/schema/gdpr.ts new file mode 100644 index 0000000..451cea9 --- /dev/null +++ b/packages/database/src/schema/gdpr.ts @@ -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 }), +}); diff --git a/packages/database/src/schema/index.ts b/packages/database/src/schema/index.ts index 80f1ba4..87a052a 100644 --- a/packages/database/src/schema/index.ts +++ b/packages/database/src/schema/index.ts @@ -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"; diff --git a/packages/database/src/schema/recipes.ts b/packages/database/src/schema/recipes.ts index 3dcd17e..fdec0cd 100644 --- a/packages/database/src/schema/recipes.ts +++ b/packages/database/src/schema/recipes.ts @@ -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"), diff --git a/packages/database/src/schema/subscriptions.ts b/packages/database/src/schema/subscriptions.ts index ee0f79e..b67a49c 100644 --- a/packages/database/src/schema/subscriptions.ts +++ b/packages/database/src/schema/subscriptions.ts @@ -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. */