Initial commit (unpacked platform)
This commit is contained in:
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* Delade kolumnhjälpare och pg-enums för hela schemat.
|
||||
* Alla enum-värden kommer från @app/shared-types så att databas,
|
||||
* API och app aldrig glider isär.
|
||||
*/
|
||||
import { pgEnum, timestamp } from "drizzle-orm/pg-core";
|
||||
import {
|
||||
ACTIVITY_LEVELS,
|
||||
ALLERGENS,
|
||||
CONSENT_KINDS,
|
||||
CONSENT_STATUSES,
|
||||
COOKING_METHODS,
|
||||
CREATOR_LEVELS,
|
||||
CUISINES,
|
||||
DATE_KINDS,
|
||||
DIET_PATTERNS,
|
||||
EVENT_TYPES,
|
||||
EXPIRY_STATUSES,
|
||||
GOAL_TYPES,
|
||||
HOUSEHOLD_ROLES,
|
||||
INVENTORY_SOURCES,
|
||||
INVENTORY_TRANSACTION_TYPES,
|
||||
JOB_STATUSES,
|
||||
JOB_TYPES,
|
||||
MEAL_LOG_SOURCES,
|
||||
MEAL_TYPES,
|
||||
MEMORY_KINDS,
|
||||
NOTIFICATION_TYPES,
|
||||
PRECISION_MODES,
|
||||
PROFILE_VISIBILITIES,
|
||||
RECIPE_DIFFICULTIES,
|
||||
RECIPE_SIMILARITY_CLASSES,
|
||||
RECIPE_SOURCE_TYPES,
|
||||
RECIPE_STATUSES,
|
||||
RECIPE_VARIANT_TYPES,
|
||||
RECIPE_VERIFICATION_STATUSES,
|
||||
RELIGIOUS_RULES,
|
||||
SCAN_TYPES,
|
||||
SEXES,
|
||||
SIGNAL_ORIGINS,
|
||||
STORAGE_LOCATION_TYPES,
|
||||
SUBSCRIPTION_PLANS,
|
||||
SUBSCRIPTION_PROVIDERS,
|
||||
SUBSCRIPTION_STATUSES,
|
||||
TASTE_AXES,
|
||||
UNITS,
|
||||
USER_ROLES,
|
||||
VERIFICATION_STATUSES,
|
||||
tuple,
|
||||
} from "@app/shared-types";
|
||||
|
||||
// --- Tidsstämplar som återanvänds av alla tabeller ---
|
||||
export const createdAt = () =>
|
||||
timestamp("created_at", { withTimezone: true }).notNull().defaultNow();
|
||||
export const updatedAt = () =>
|
||||
timestamp("updated_at", { withTimezone: true }).notNull().defaultNow();
|
||||
|
||||
// --- pg-enums ---
|
||||
export const userRoleEnum = pgEnum("user_role", tuple(USER_ROLES));
|
||||
export const sexEnum = pgEnum("sex", tuple(SEXES));
|
||||
export const activityLevelEnum = pgEnum("activity_level", tuple(ACTIVITY_LEVELS));
|
||||
export const goalTypeEnum = pgEnum("goal_type", tuple(GOAL_TYPES));
|
||||
export const dietPatternEnum = pgEnum("diet_pattern", tuple(DIET_PATTERNS));
|
||||
export const religiousRuleEnum = pgEnum("religious_rule", tuple(RELIGIOUS_RULES));
|
||||
export const precisionModeEnum = pgEnum("precision_mode", tuple(PRECISION_MODES));
|
||||
export const allergenEnum = pgEnum("allergen", tuple(ALLERGENS));
|
||||
export const consentKindEnum = pgEnum("consent_kind", tuple(CONSENT_KINDS));
|
||||
export const consentStatusEnum = pgEnum("consent_status", tuple(CONSENT_STATUSES));
|
||||
export const householdRoleEnum = pgEnum("household_role", tuple(HOUSEHOLD_ROLES));
|
||||
export const storageLocationTypeEnum = pgEnum(
|
||||
"storage_location_type",
|
||||
tuple(STORAGE_LOCATION_TYPES),
|
||||
);
|
||||
export const unitEnum = pgEnum("unit", tuple(UNITS));
|
||||
export const inventorySourceEnum = pgEnum("inventory_source", tuple(INVENTORY_SOURCES));
|
||||
export const inventoryTransactionTypeEnum = pgEnum(
|
||||
"inventory_transaction_type",
|
||||
tuple(INVENTORY_TRANSACTION_TYPES),
|
||||
);
|
||||
export const expiryStatusEnum = pgEnum("expiry_status", tuple(EXPIRY_STATUSES));
|
||||
export const dateKindEnum = pgEnum("date_kind", tuple(DATE_KINDS));
|
||||
export const cuisineEnum = pgEnum("cuisine", tuple(CUISINES));
|
||||
export const mealTypeEnum = pgEnum("meal_type", tuple(MEAL_TYPES));
|
||||
export const cookingMethodEnum = pgEnum("cooking_method", tuple(COOKING_METHODS));
|
||||
export const recipeDifficultyEnum = pgEnum("recipe_difficulty", tuple(RECIPE_DIFFICULTIES));
|
||||
export const recipeStatusEnum = pgEnum("recipe_status", tuple(RECIPE_STATUSES));
|
||||
export const recipeVerificationStatusEnum = pgEnum(
|
||||
"recipe_verification_status",
|
||||
tuple(RECIPE_VERIFICATION_STATUSES),
|
||||
);
|
||||
export const recipeVariantTypeEnum = pgEnum("recipe_variant_type", tuple(RECIPE_VARIANT_TYPES));
|
||||
export const recipeSourceTypeEnum = pgEnum("recipe_source_type", tuple(RECIPE_SOURCE_TYPES));
|
||||
export const recipeSimilarityClassEnum = pgEnum(
|
||||
"recipe_similarity_class",
|
||||
tuple(RECIPE_SIMILARITY_CLASSES),
|
||||
);
|
||||
export const scanTypeEnum = pgEnum("scan_type", tuple(SCAN_TYPES));
|
||||
export const jobTypeEnum = pgEnum("job_type", tuple(JOB_TYPES));
|
||||
export const jobStatusEnum = pgEnum("job_status", tuple(JOB_STATUSES));
|
||||
export const eventTypeEnum = pgEnum("event_type", tuple(EVENT_TYPES));
|
||||
export const mealLogSourceEnum = pgEnum("meal_log_source", tuple(MEAL_LOG_SOURCES));
|
||||
export const memoryKindEnum = pgEnum("memory_kind", tuple(MEMORY_KINDS));
|
||||
export const signalOriginEnum = pgEnum("signal_origin", tuple(SIGNAL_ORIGINS));
|
||||
export const tasteAxisEnum = pgEnum("taste_axis", tuple(TASTE_AXES));
|
||||
export const subscriptionPlanEnum = pgEnum("subscription_plan", tuple(SUBSCRIPTION_PLANS));
|
||||
export const subscriptionStatusEnum = pgEnum("subscription_status", tuple(SUBSCRIPTION_STATUSES));
|
||||
export const subscriptionProviderEnum = pgEnum(
|
||||
"subscription_provider",
|
||||
tuple(SUBSCRIPTION_PROVIDERS),
|
||||
);
|
||||
export const creatorLevelEnum = pgEnum("creator_level", tuple(CREATOR_LEVELS));
|
||||
export const profileVisibilityEnum = pgEnum("profile_visibility", tuple(PROFILE_VISIBILITIES));
|
||||
export const notificationTypeEnum = pgEnum("notification_type", tuple(NOTIFICATION_TYPES));
|
||||
export const verificationStatusEnum = pgEnum("verification_status", tuple(VERIFICATION_STATUSES));
|
||||
@@ -0,0 +1,67 @@
|
||||
import {
|
||||
doublePrecision,
|
||||
index,
|
||||
integer,
|
||||
pgTable,
|
||||
primaryKey,
|
||||
text,
|
||||
timestamp,
|
||||
varchar,
|
||||
uniqueIndex,
|
||||
uuid,
|
||||
} from "drizzle-orm/pg-core";
|
||||
import { createdAt, householdRoleEnum, storageLocationTypeEnum, updatedAt } from "./_shared.js";
|
||||
import { users } from "./users.js";
|
||||
|
||||
/** Hushåll delar lager, inköpslista, plan, matlådor och budget (spec §7). */
|
||||
export const households = pgTable(
|
||||
"households",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
name: text("name").notNull(),
|
||||
inviteCode: text("invite_code").notNull(),
|
||||
weeklyBudgetMinor: integer("weekly_budget_minor"),
|
||||
/** ISO 4217. Pengagränsen går vid hushållet (i18n-spec §20) – alla belopp i hushållet tolkas i denna valuta. */
|
||||
currencyCode: varchar("currency_code", { length: 3 }).notNull().default("SEK"),
|
||||
createdAt: createdAt(),
|
||||
updatedAt: updatedAt(),
|
||||
},
|
||||
(t) => [uniqueIndex("households_invite_code_unique").on(t.inviteCode)],
|
||||
);
|
||||
|
||||
export const householdMembers = pgTable(
|
||||
"household_members",
|
||||
{
|
||||
householdId: uuid("household_id")
|
||||
.notNull()
|
||||
.references(() => households.id, { onDelete: "cascade" }),
|
||||
userId: uuid("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
role: householdRoleEnum("role").notNull().default("member"),
|
||||
/** Individuell portionsfaktor (spec §7: samma rätt, anpassad per person). */
|
||||
portionFactor: doublePrecision("portion_factor").notNull().default(1),
|
||||
joinedAt: timestamp("joined_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(t) => [
|
||||
primaryKey({ columns: [t.householdId, t.userId] }),
|
||||
index("household_members_user_idx").on(t.userId),
|
||||
],
|
||||
);
|
||||
|
||||
/** Kyl, frys, skafferi, garagefrys, vinkyl, matkällare, matlådor, egna platser (spec §8). */
|
||||
export const storageLocations = pgTable(
|
||||
"storage_locations",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
householdId: uuid("household_id")
|
||||
.notNull()
|
||||
.references(() => households.id, { onDelete: "cascade" }),
|
||||
type: storageLocationTypeEnum("type").notNull(),
|
||||
name: text("name").notNull(),
|
||||
sublocations: text("sublocations").array().notNull().default([]),
|
||||
sortOrder: integer("sort_order").notNull().default(0),
|
||||
createdAt: createdAt(),
|
||||
},
|
||||
(t) => [index("storage_locations_household_idx").on(t.householdId)],
|
||||
);
|
||||
@@ -0,0 +1,18 @@
|
||||
export * from "./_shared.js";
|
||||
export * from "./users.js";
|
||||
export * from "./locale.js";
|
||||
export * from "./households.js";
|
||||
export * from "./ingredients.js";
|
||||
export * from "./products.js";
|
||||
export * from "./inventory.js";
|
||||
export * from "./recipes.js";
|
||||
export * from "./translations.js";
|
||||
export * from "./markets.js";
|
||||
export * from "./meals.js";
|
||||
export * from "./planning.js";
|
||||
export * from "./receipts.js";
|
||||
export * from "./scans.js";
|
||||
export * from "./memory.js";
|
||||
export * from "./seasons.js";
|
||||
export * from "./subscriptions.js";
|
||||
export * from "./platform.js";
|
||||
@@ -0,0 +1,81 @@
|
||||
import {
|
||||
boolean,
|
||||
doublePrecision,
|
||||
index,
|
||||
integer,
|
||||
jsonb,
|
||||
pgTable,
|
||||
text,
|
||||
uniqueIndex,
|
||||
} from "drizzle-orm/pg-core";
|
||||
import type {
|
||||
NutritionDeclaration,
|
||||
NutritionProvenance,
|
||||
Season,
|
||||
StorageLocationType,
|
||||
} from "@app/shared-types";
|
||||
import { allergenEnum, unitEnum, updatedAt, createdAt } from "./_shared.js";
|
||||
|
||||
/**
|
||||
* Kanoniska ingredienser – navet som alla datakällor normaliseras mot (spec §9).
|
||||
* Näringsvärden per 100 g/ml med spårbar källa. AI hittar aldrig på värden här:
|
||||
* produktion importerar Livsmedelsverkets öppna livsmedelsdatabas via connector.
|
||||
*/
|
||||
export const canonicalIngredients = pgTable(
|
||||
"canonical_ingredients",
|
||||
{
|
||||
/** Stabilt slug-id, t.ex. "chicken_breast". */
|
||||
id: text("id").primaryKey(),
|
||||
nameSv: text("name_sv").notNull(),
|
||||
nameEn: text("name_en").notNull(),
|
||||
/** Sökhjälp: synonymer och vanliga kvitto-/OCR-varianter. */
|
||||
aliases: text("aliases").array().notNull().default([]),
|
||||
category: text("category").notNull(),
|
||||
defaultUnit: unitEnum("default_unit").notNull(),
|
||||
densityGPerMl: doublePrecision("density_g_per_ml"),
|
||||
gramsPerPiece: doublePrecision("grams_per_piece"),
|
||||
allergens: allergenEnum("allergens").array().notNull().default([]),
|
||||
isVegan: boolean("is_vegan").notNull().default(false),
|
||||
isVegetarian: boolean("is_vegetarian").notNull().default(false),
|
||||
containsGluten: boolean("contains_gluten").notNull().default(false),
|
||||
containsLactose: boolean("contains_lactose").notNull().default(false),
|
||||
isPork: boolean("is_pork").notNull().default(false),
|
||||
isBeef: boolean("is_beef").notNull().default(false),
|
||||
isAlcohol: boolean("is_alcohol").notNull().default(false),
|
||||
nutritionPer100: jsonb("nutrition_per_100").$type<NutritionDeclaration>().notNull(),
|
||||
nutritionProvenance: jsonb("nutrition_provenance").$type<NutritionProvenance>().notNull(),
|
||||
peakSeasons: text("peak_seasons").array().$type<Season[]>().notNull().default([]),
|
||||
/** Riktvärden i dagar per förvaringsplats – vägledning, aldrig garanti (spec §13). */
|
||||
shelfLifeGuidance:
|
||||
jsonb("shelf_life_guidance").$type<Partial<Record<StorageLocationType, number>>>(),
|
||||
defaultPriceMinorPerKg: integer("default_price_minor_per_kg"),
|
||||
createdAt: createdAt(),
|
||||
updatedAt: updatedAt(),
|
||||
},
|
||||
(t) => [index("canonical_ingredients_category_idx").on(t.category)],
|
||||
);
|
||||
|
||||
/** Substitutionsmotor (spec §20): mängdfaktor + påverkan + begränsningar. */
|
||||
export const substitutions = pgTable(
|
||||
"substitutions",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
fromIngredientId: text("from_ingredient_id")
|
||||
.notNull()
|
||||
.references(() => canonicalIngredients.id),
|
||||
toIngredientId: text("to_ingredient_id")
|
||||
.notNull()
|
||||
.references(() => canonicalIngredients.id),
|
||||
ratio: doublePrecision("ratio").notNull().default(1),
|
||||
instructionsSv: text("instructions_sv"),
|
||||
bestFor: text("best_for").array().notNull().default([]),
|
||||
notRecommendedFor: text("not_recommended_for").array().notNull().default([]),
|
||||
flavorImpactSv: text("flavor_impact_sv"),
|
||||
textureImpactSv: text("texture_impact_sv"),
|
||||
priority: integer("priority").notNull().default(0),
|
||||
},
|
||||
(t) => [
|
||||
index("substitutions_from_idx").on(t.fromIngredientId),
|
||||
uniqueIndex("substitutions_pair_unique").on(t.fromIngredientId, t.toIngredientId),
|
||||
],
|
||||
);
|
||||
@@ -0,0 +1,107 @@
|
||||
import {
|
||||
boolean,
|
||||
date,
|
||||
doublePrecision,
|
||||
index,
|
||||
jsonb,
|
||||
pgTable,
|
||||
text,
|
||||
timestamp,
|
||||
uuid,
|
||||
integer,
|
||||
} from "drizzle-orm/pg-core";
|
||||
import type { NutritionDeclaration } from "@app/shared-types";
|
||||
import {
|
||||
createdAt,
|
||||
dateKindEnum,
|
||||
expiryStatusEnum,
|
||||
inventorySourceEnum,
|
||||
inventoryTransactionTypeEnum,
|
||||
unitEnum,
|
||||
updatedAt,
|
||||
} from "./_shared.js";
|
||||
import { households, storageLocations } from "./households.js";
|
||||
import { canonicalIngredients } from "./ingredients.js";
|
||||
import { products } from "./products.js";
|
||||
import { users } from "./users.js";
|
||||
|
||||
/**
|
||||
* Food Twin (spec §8): lagerposter med fullständig spårbarhet.
|
||||
* `quantity` är ett cachat saldo – sanningen är inventory_transactions.
|
||||
*/
|
||||
export const inventoryItems = pgTable(
|
||||
"inventory_items",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
householdId: uuid("household_id")
|
||||
.notNull()
|
||||
.references(() => households.id, { onDelete: "cascade" }),
|
||||
canonicalIngredientId: text("canonical_ingredient_id").references(
|
||||
() => canonicalIngredients.id,
|
||||
),
|
||||
productId: uuid("product_id").references(() => products.id),
|
||||
displayName: text("display_name").notNull(),
|
||||
brand: text("brand"),
|
||||
quantity: doublePrecision("quantity").notNull().default(0),
|
||||
unit: unitEnum("unit").notNull(),
|
||||
storageLocationId: uuid("storage_location_id")
|
||||
.notNull()
|
||||
.references(() => storageLocations.id),
|
||||
sublocation: text("sublocation"),
|
||||
purchasedAt: date("purchased_at"),
|
||||
openedAt: date("opened_at"),
|
||||
bestBeforeDate: date("best_before_date"),
|
||||
useByDate: date("use_by_date"),
|
||||
dateKind: dateKindEnum("date_kind"),
|
||||
frozenAt: date("frozen_at"),
|
||||
thawedAt: date("thawed_at"),
|
||||
priceMinor: integer("price_minor"),
|
||||
nutritionPer100: jsonb("nutrition_per_100").$type<NutritionDeclaration>(),
|
||||
source: inventorySourceEnum("source").notNull(),
|
||||
confidence: doublePrecision("confidence").notNull().default(1),
|
||||
verifiedByUser: boolean("verified_by_user").notNull().default(false),
|
||||
lastVerifiedAt: timestamp("last_verified_at", { withTimezone: true }),
|
||||
expiryStatus: expiryStatusEnum("expiry_status").notNull().default("unknown"),
|
||||
/** Sätts när saldot nått 0 och posten arkiverats. */
|
||||
depletedAt: timestamp("depleted_at", { withTimezone: true }),
|
||||
modelVersion: text("model_version"),
|
||||
promptVersion: text("prompt_version"),
|
||||
createdAt: createdAt(),
|
||||
updatedAt: updatedAt(),
|
||||
},
|
||||
(t) => [
|
||||
index("inventory_items_household_idx").on(t.householdId),
|
||||
index("inventory_items_household_bb_idx").on(t.householdId, t.bestBeforeDate),
|
||||
index("inventory_items_location_idx").on(t.storageLocationId),
|
||||
index("inventory_items_canonical_idx").on(t.canonicalIngredientId),
|
||||
],
|
||||
);
|
||||
|
||||
/** Transaktionsloggen är källan till sanning (spec §8): + inköp, − använt, − kasserat … */
|
||||
export const inventoryTransactions = pgTable(
|
||||
"inventory_transactions",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
householdId: uuid("household_id")
|
||||
.notNull()
|
||||
.references(() => households.id, { onDelete: "cascade" }),
|
||||
inventoryItemId: uuid("inventory_item_id")
|
||||
.notNull()
|
||||
.references(() => inventoryItems.id, { onDelete: "cascade" }),
|
||||
type: inventoryTransactionTypeEnum("type").notNull(),
|
||||
quantityDelta: doublePrecision("quantity_delta").notNull(),
|
||||
unit: unitEnum("unit").notNull(),
|
||||
refType: text("ref_type"),
|
||||
refId: uuid("ref_id"),
|
||||
actorUserId: uuid("actor_user_id").references(() => users.id, { onDelete: "set null" }),
|
||||
note: text("note"),
|
||||
/** Värde i SEK för matsvinnsberäkning vid discard (spec §12, §26). */
|
||||
valueMinor: integer("value_minor"),
|
||||
createdAt: createdAt(),
|
||||
},
|
||||
(t) => [
|
||||
index("inventory_tx_item_idx").on(t.inventoryItemId),
|
||||
index("inventory_tx_household_time_idx").on(t.householdId, t.createdAt),
|
||||
index("inventory_tx_type_idx").on(t.type),
|
||||
],
|
||||
);
|
||||
@@ -0,0 +1,32 @@
|
||||
import { boolean, integer, pgEnum, pgTable, text, uuid } from "drizzle-orm/pg-core";
|
||||
import { MEASUREMENT_SYSTEMS, TEMPERATURE_UNITS, tuple } from "@app/shared-types";
|
||||
import { updatedAt } from "./_shared.js";
|
||||
import { users } from "./users.js";
|
||||
|
||||
export const measurementSystemEnum = pgEnum("measurement_system", tuple(MEASUREMENT_SYSTEMS));
|
||||
export const temperatureUnitEnum = pgEnum("temperature_unit", tuple(TEMPERATURE_UNITS));
|
||||
|
||||
/**
|
||||
* Locale-preferenser per användare (i18n-spec §6, §29):
|
||||
* språk, region, tidszon, måttsystem, temperatur, valuta, veckostart, 12/24 h –
|
||||
* alla oberoende av varandra. Saknas rad gäller regiondefaults (SE).
|
||||
*/
|
||||
export const userLocalePreferences = pgTable("user_locale_preferences", {
|
||||
userId: uuid("user_id")
|
||||
.primaryKey()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
/** BCP 47, t.ex. sv-SE, en-US. */
|
||||
languageTag: text("language_tag").notNull().default("sv-SE"),
|
||||
/** ISO 3166-1 alpha-2. */
|
||||
regionCode: text("region_code").notNull().default("SE"),
|
||||
/** IANA-tidszon. */
|
||||
timeZone: text("time_zone").notNull().default("Europe/Stockholm"),
|
||||
measurementSystem: measurementSystemEnum("measurement_system").notNull().default("METRIC"),
|
||||
temperatureUnit: temperatureUnitEnum("temperature_unit").notNull().default("CELSIUS"),
|
||||
/** ISO 4217. */
|
||||
currencyCode: text("currency_code").notNull().default("SEK"),
|
||||
/** 0 = söndag … 6 = lördag. */
|
||||
firstDayOfWeek: integer("first_day_of_week").notNull().default(1),
|
||||
use24HourTime: boolean("use_24_hour_time").notNull().default(true),
|
||||
updatedAt: updatedAt(),
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Marknadsprofiler (i18n-spec §18–19, M6).
|
||||
*
|
||||
* Näringsdeklaration och allergenframhävning skiljer sig per marknad:
|
||||
* - EU/EES: energi i kJ + kcal, "salt" (inte natrium), 14 deklarationspliktiga allergener.
|
||||
* - US: kcal ("Calories"), natrium i mg, FDA "Big 9".
|
||||
* - CA: kcal, natrium, Health Canada-lista (senap ingår, selleri ingår inte).
|
||||
*
|
||||
* Beräkningen är ALLTID samma deterministiska motor – profilerna styr bara
|
||||
* VISNING och VILKA allergener som måste framhävas per marknad. Appens interna
|
||||
* allergensäkerhet (spec §61.2) filtrerar alltid på användarens egna allergier,
|
||||
* oavsett marknad – profilen kan aldrig slå av en spärr.
|
||||
*/
|
||||
import { boolean, pgTable, primaryKey, text, varchar } from "drizzle-orm/pg-core";
|
||||
import { allergenEnum, createdAt, updatedAt } from "./_shared.js";
|
||||
|
||||
export const nutritionDisplayProfiles = pgTable("nutrition_display_profiles", {
|
||||
/** ISO 3166-1 alpha-2, "EU" som samlingsprofil och fallback. */
|
||||
regionCode: varchar("region_code", { length: 2 }).primaryKey(),
|
||||
/** Visa energi som: kcal, kj eller båda. */
|
||||
energyDisplay: text("energy_display", { enum: ["kcal", "kj", "both"] }).notNull(),
|
||||
/** Visa salt (g) eller natrium (mg). */
|
||||
saltDisplay: text("salt_display", { enum: ["salt", "sodium"] }).notNull(),
|
||||
/** Etikettnyckel för energi ("Energi", "Calories" …) – texten bor i i18n-resurser. */
|
||||
energyLabelKey: text("energy_label_key").notNull(),
|
||||
createdAt: createdAt(),
|
||||
updatedAt: updatedAt(),
|
||||
});
|
||||
|
||||
export const allergenMarketRules = pgTable(
|
||||
"allergen_market_rules",
|
||||
{
|
||||
regionCode: varchar("region_code", { length: 2 }).notNull(),
|
||||
allergen: allergenEnum("allergen").notNull(),
|
||||
/** Måste framhävas i deklaration på denna marknad. */
|
||||
mustHighlight: boolean("must_highlight").notNull().default(true),
|
||||
},
|
||||
(t) => [primaryKey({ columns: [t.regionCode, t.allergen] })],
|
||||
);
|
||||
@@ -0,0 +1,81 @@
|
||||
import {
|
||||
boolean,
|
||||
date,
|
||||
doublePrecision,
|
||||
index,
|
||||
integer,
|
||||
jsonb,
|
||||
pgTable,
|
||||
text,
|
||||
timestamp,
|
||||
uuid,
|
||||
} from "drizzle-orm/pg-core";
|
||||
import type { NutritionValues } from "@app/shared-types";
|
||||
import { createdAt, mealLogSourceEnum, mealTypeEnum } from "./_shared.js";
|
||||
import { households, storageLocations } from "./households.js";
|
||||
import { recipes } from "./recipes.js";
|
||||
import { users } from "./users.js";
|
||||
|
||||
/**
|
||||
* Måltidslogg (spec §23). Näringsvärden är alltid deterministiskt härledda
|
||||
* (recept/produkt/användarinmatning) – aldrig påhittade av AI (spec §61.1).
|
||||
*/
|
||||
export const meals = pgTable(
|
||||
"meals",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
userId: uuid("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
householdId: uuid("household_id").references(() => households.id, { onDelete: "set null" }),
|
||||
date: date("date").notNull(),
|
||||
mealType: mealTypeEnum("meal_type").notNull(),
|
||||
source: mealLogSourceEnum("source").notNull(),
|
||||
recipeId: uuid("recipe_id").references(() => recipes.id, { onDelete: "set null" }),
|
||||
titleSv: text("title_sv").notNull(),
|
||||
portionFraction: doublePrecision("portion_fraction").notNull().default(1),
|
||||
nutrition: jsonb("nutrition").$type<NutritionValues>().notNull(),
|
||||
nutritionIsEstimate: boolean("nutrition_is_estimate").notNull().default(false),
|
||||
estimateMinKcal: doublePrecision("estimate_min_kcal"),
|
||||
estimateMaxKcal: doublePrecision("estimate_max_kcal"),
|
||||
items: jsonb("items"),
|
||||
photoUrl: text("photo_url"),
|
||||
scanJobId: uuid("scan_job_id"),
|
||||
loggedAt: timestamp("logged_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(t) => [
|
||||
index("meals_user_date_idx").on(t.userId, t.date),
|
||||
index("meals_household_idx").on(t.householdId),
|
||||
],
|
||||
);
|
||||
|
||||
/** Matlådor (spec §24) – rekommenderas före ny matlagning när rimligt. */
|
||||
export const mealBoxes = pgTable(
|
||||
"meal_boxes",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
householdId: uuid("household_id")
|
||||
.notNull()
|
||||
.references(() => households.id, { onDelete: "cascade" }),
|
||||
recipeId: uuid("recipe_id").references(() => recipes.id, { onDelete: "set null" }),
|
||||
titleSv: text("title_sv").notNull(),
|
||||
portions: integer("portions").notNull(),
|
||||
portionsRemaining: integer("portions_remaining").notNull(),
|
||||
nutritionPerPortion: jsonb("nutrition_per_portion").$type<NutritionValues>(),
|
||||
cookedAt: date("cooked_at").notNull(),
|
||||
storageLocationId: uuid("storage_location_id")
|
||||
.notNull()
|
||||
.references(() => storageLocations.id),
|
||||
frozen: boolean("frozen").notNull().default(false),
|
||||
recommendedUseBy: date("recommended_use_by").notNull(),
|
||||
reservedForUserId: uuid("reserved_for_user_id").references(() => users.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
status: text("status").notNull().default("available"),
|
||||
createdAt: createdAt(),
|
||||
},
|
||||
(t) => [
|
||||
index("meal_boxes_household_idx").on(t.householdId),
|
||||
index("meal_boxes_use_by_idx").on(t.householdId, t.recommendedUseBy),
|
||||
],
|
||||
);
|
||||
@@ -0,0 +1,91 @@
|
||||
import {
|
||||
boolean,
|
||||
doublePrecision,
|
||||
index,
|
||||
jsonb,
|
||||
pgTable,
|
||||
text,
|
||||
timestamp,
|
||||
uuid,
|
||||
} from "drizzle-orm/pg-core";
|
||||
import {
|
||||
createdAt,
|
||||
memoryKindEnum,
|
||||
signalOriginEnum,
|
||||
tasteAxisEnum,
|
||||
updatedAt,
|
||||
} from "./_shared.js";
|
||||
import { households } from "./households.js";
|
||||
import { recipes } from "./recipes.js";
|
||||
import { users } from "./users.js";
|
||||
|
||||
/**
|
||||
* AAMOS Memory-spegel (spec §32). Varje post är transparent, korrigerbar,
|
||||
* pausbar och raderbar via "Vad plattformen vet om mig".
|
||||
* Personligt minne är INTE automatiskt träningsdata (spec §32, §33).
|
||||
*/
|
||||
export const memoryItems = pgTable(
|
||||
"memory_items",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
userId: uuid("user_id").references(() => users.id, { onDelete: "cascade" }),
|
||||
householdId: uuid("household_id").references(() => households.id, { onDelete: "cascade" }),
|
||||
kind: memoryKindEnum("kind").notNull(),
|
||||
key: text("key").notNull(),
|
||||
summarySv: text("summary_sv").notNull(),
|
||||
value: jsonb("value"),
|
||||
origin: signalOriginEnum("origin").notNull(),
|
||||
confidence: doublePrecision("confidence").notNull().default(0.5),
|
||||
verifiedByUser: boolean("verified_by_user").notNull().default(false),
|
||||
paused: boolean("paused").notNull().default(false),
|
||||
lastUsedAt: timestamp("last_used_at", { withTimezone: true }),
|
||||
expiresAt: timestamp("expires_at", { withTimezone: true }),
|
||||
createdAt: createdAt(),
|
||||
updatedAt: updatedAt(),
|
||||
},
|
||||
(t) => [
|
||||
index("memory_items_user_idx").on(t.userId, t.kind),
|
||||
index("memory_items_household_idx").on(t.householdId, t.kind),
|
||||
index("memory_items_key_idx").on(t.key),
|
||||
],
|
||||
);
|
||||
|
||||
/** Smaksignaler (spec §30): skilj user_stated / observed / ai_inferred. */
|
||||
export const tasteSignals = pgTable(
|
||||
"taste_signals",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
userId: uuid("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
axis: tasteAxisEnum("axis").notNull(),
|
||||
direction: doublePrecision("direction").notNull(),
|
||||
strength: doublePrecision("strength").notNull().default(0.5),
|
||||
origin: signalOriginEnum("origin").notNull(),
|
||||
refRecipeId: uuid("ref_recipe_id").references(() => recipes.id, { onDelete: "set null" }),
|
||||
createdAt: createdAt(),
|
||||
},
|
||||
(t) => [index("taste_signals_user_idx").on(t.userId, t.axis)],
|
||||
);
|
||||
|
||||
/** Food Memory (spec §39): långsiktiga matminnen kopplade till högtider och betyg. */
|
||||
export const foodMemories = pgTable(
|
||||
"food_memories",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
userId: uuid("user_id").references(() => users.id, { onDelete: "cascade" }),
|
||||
householdId: uuid("household_id").references(() => households.id, { onDelete: "cascade" }),
|
||||
recipeId: uuid("recipe_id").references(() => recipes.id, { onDelete: "set null" }),
|
||||
titleSv: text("title_sv").notNull(),
|
||||
summarySv: text("summary_sv").notNull(),
|
||||
holidayTag: text("holiday_tag"),
|
||||
photoUrl: text("photo_url"),
|
||||
stars: doublePrecision("stars"),
|
||||
occurredAt: timestamp("occurred_at", { withTimezone: true }).notNull(),
|
||||
createdAt: createdAt(),
|
||||
},
|
||||
(t) => [
|
||||
index("food_memories_household_idx").on(t.householdId, t.occurredAt),
|
||||
index("food_memories_holiday_idx").on(t.holidayTag),
|
||||
],
|
||||
);
|
||||
@@ -0,0 +1,96 @@
|
||||
import {
|
||||
boolean,
|
||||
date,
|
||||
doublePrecision,
|
||||
index,
|
||||
integer,
|
||||
pgTable,
|
||||
text,
|
||||
uuid,
|
||||
} from "drizzle-orm/pg-core";
|
||||
import { createdAt, mealTypeEnum, unitEnum, updatedAt } from "./_shared.js";
|
||||
import { households } from "./households.js";
|
||||
import { mealBoxes } from "./meals.js";
|
||||
import { canonicalIngredients } from "./ingredients.js";
|
||||
import { recipes } from "./recipes.js";
|
||||
import { users } from "./users.js";
|
||||
|
||||
/** Veckoplanering (spec §25). */
|
||||
export const weekPlans = pgTable(
|
||||
"week_plans",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
householdId: uuid("household_id")
|
||||
.notNull()
|
||||
.references(() => households.id, { onDelete: "cascade" }),
|
||||
weekStartDate: date("week_start_date").notNull(),
|
||||
status: text("status").notNull().default("draft"),
|
||||
generatedBy: text("generated_by").notNull().default("user"),
|
||||
notes: text("notes"),
|
||||
createdAt: createdAt(),
|
||||
updatedAt: updatedAt(),
|
||||
},
|
||||
(t) => [index("week_plans_household_week_idx").on(t.householdId, t.weekStartDate)],
|
||||
);
|
||||
|
||||
export const weekPlanEntries = pgTable(
|
||||
"week_plan_entries",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
weekPlanId: uuid("week_plan_id")
|
||||
.notNull()
|
||||
.references(() => weekPlans.id, { onDelete: "cascade" }),
|
||||
date: date("date").notNull(),
|
||||
mealType: mealTypeEnum("meal_type").notNull(),
|
||||
recipeId: uuid("recipe_id").references(() => recipes.id, { onDelete: "set null" }),
|
||||
mealBoxId: uuid("meal_box_id").references(() => mealBoxes.id, { onDelete: "set null" }),
|
||||
titleSv: text("title_sv").notNull(),
|
||||
portions: integer("portions").notNull().default(2),
|
||||
status: text("status").notNull().default("planned"),
|
||||
/** Förklaring vid dynamisk omplanering (spec §25). */
|
||||
rescheduleReasonSv: text("reschedule_reason_sv"),
|
||||
sortOrder: integer("sort_order").notNull().default(0),
|
||||
},
|
||||
(t) => [index("week_plan_entries_plan_idx").on(t.weekPlanId)],
|
||||
);
|
||||
|
||||
/** Inköpslista (spec §27) – delas i hushållet, fungerar offline, uppdaterar lagret. */
|
||||
export const shoppingLists = pgTable(
|
||||
"shopping_lists",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
householdId: uuid("household_id")
|
||||
.notNull()
|
||||
.references(() => households.id, { onDelete: "cascade" }),
|
||||
name: text("name").notNull().default("Inköpslista"),
|
||||
status: text("status").notNull().default("active"),
|
||||
weekPlanId: uuid("week_plan_id").references(() => weekPlans.id, { onDelete: "set null" }),
|
||||
createdAt: createdAt(),
|
||||
updatedAt: updatedAt(),
|
||||
},
|
||||
(t) => [index("shopping_lists_household_idx").on(t.householdId)],
|
||||
);
|
||||
|
||||
export const shoppingListItems = pgTable(
|
||||
"shopping_list_items",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
shoppingListId: uuid("shopping_list_id")
|
||||
.notNull()
|
||||
.references(() => shoppingLists.id, { onDelete: "cascade" }),
|
||||
canonicalIngredientId: text("canonical_ingredient_id").references(
|
||||
() => canonicalIngredients.id,
|
||||
),
|
||||
displayName: text("display_name").notNull(),
|
||||
quantity: doublePrecision("quantity").notNull().default(1),
|
||||
unit: unitEnum("unit").notNull().default("COUNT"),
|
||||
storeSection: text("store_section").notNull().default("hygien_ovrigt"),
|
||||
suggestedPackageSize: text("suggested_package_size"),
|
||||
estimatedPriceMinor: integer("estimated_price_minor"),
|
||||
checked: boolean("checked").notNull().default(false),
|
||||
addedByUserId: uuid("added_by_user_id").references(() => users.id, { onDelete: "set null" }),
|
||||
origin: text("origin").notNull().default("manual"),
|
||||
sortOrder: integer("sort_order").notNull().default(0),
|
||||
},
|
||||
(t) => [index("shopping_list_items_list_idx").on(t.shoppingListId)],
|
||||
);
|
||||
@@ -0,0 +1,202 @@
|
||||
import {
|
||||
boolean,
|
||||
doublePrecision,
|
||||
index,
|
||||
integer,
|
||||
jsonb,
|
||||
pgTable,
|
||||
primaryKey,
|
||||
text,
|
||||
timestamp,
|
||||
uuid,
|
||||
} from "drizzle-orm/pg-core";
|
||||
import {
|
||||
createdAt,
|
||||
creatorLevelEnum,
|
||||
eventTypeEnum,
|
||||
notificationTypeEnum,
|
||||
profileVisibilityEnum,
|
||||
updatedAt,
|
||||
} from "./_shared.js";
|
||||
import { households } from "./households.js";
|
||||
import { users } from "./users.js";
|
||||
|
||||
/**
|
||||
* Domänhändelser (spec §55) i outbox-mönster: skrivs transaktionellt med
|
||||
* affärsdata och publiceras asynkront till konsumenter (analytics, memory,
|
||||
* recommendations, training).
|
||||
*/
|
||||
export const domainEvents = pgTable(
|
||||
"domain_events",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
type: eventTypeEnum("type").notNull(),
|
||||
userId: uuid("user_id"),
|
||||
householdId: uuid("household_id"),
|
||||
payload: jsonb("payload").notNull(),
|
||||
correlationId: text("correlation_id"),
|
||||
occurredAt: timestamp("occurred_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
publishedAt: timestamp("published_at", { withTimezone: true }),
|
||||
},
|
||||
(t) => [
|
||||
index("domain_events_unpublished_idx").on(t.publishedAt, t.occurredAt),
|
||||
index("domain_events_type_idx").on(t.type, t.occurredAt),
|
||||
index("domain_events_household_idx").on(t.householdId, t.occurredAt),
|
||||
],
|
||||
);
|
||||
|
||||
export const featureFlags = pgTable("feature_flags", {
|
||||
key: text("key").primaryKey(),
|
||||
enabled: boolean("enabled").notNull().default(false),
|
||||
descriptionSv: text("description_sv"),
|
||||
rolloutPercent: integer("rollout_percent").notNull().default(100),
|
||||
updatedAt: updatedAt(),
|
||||
});
|
||||
|
||||
/** Audit log (spec §56): vem gjorde vad, när, mot vad. */
|
||||
export const auditLogs = pgTable(
|
||||
"audit_logs",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
actorUserId: uuid("actor_user_id"),
|
||||
actorType: text("actor_type").notNull().default("user"),
|
||||
action: text("action").notNull(),
|
||||
targetType: text("target_type"),
|
||||
targetId: text("target_id"),
|
||||
metadata: jsonb("metadata"),
|
||||
ip: text("ip"),
|
||||
correlationId: text("correlation_id"),
|
||||
createdAt: createdAt(),
|
||||
},
|
||||
(t) => [
|
||||
index("audit_logs_actor_idx").on(t.actorUserId, t.createdAt),
|
||||
index("audit_logs_action_idx").on(t.action, t.createdAt),
|
||||
],
|
||||
);
|
||||
|
||||
/** Idempotency-nycklar för säkra POST-retries (spec §56, §59). */
|
||||
export const idempotencyKeys = pgTable(
|
||||
"idempotency_keys",
|
||||
{
|
||||
userId: uuid("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
key: text("key").notNull(),
|
||||
endpoint: text("endpoint").notNull(),
|
||||
responseStatus: integer("response_status"),
|
||||
responseBody: jsonb("response_body"),
|
||||
createdAt: createdAt(),
|
||||
},
|
||||
(t) => [primaryKey({ columns: [t.userId, t.key, t.endpoint] })],
|
||||
);
|
||||
|
||||
/** Notiser (spec §40). */
|
||||
export const notifications = pgTable(
|
||||
"notifications",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
userId: uuid("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
type: notificationTypeEnum("type").notNull(),
|
||||
titleSv: text("title_sv").notNull(),
|
||||
bodySv: text("body_sv").notNull(),
|
||||
data: jsonb("data"),
|
||||
/** i18n-spec §26: mall + variabler; titleSv/bodySv är renderad cache. */
|
||||
templateKey: text("template_key"),
|
||||
variables: jsonb("variables"),
|
||||
locale: text("locale"),
|
||||
scheduledFor: timestamp("scheduled_for", { withTimezone: true }),
|
||||
sentAt: timestamp("sent_at", { withTimezone: true }),
|
||||
readAt: timestamp("read_at", { withTimezone: true }),
|
||||
createdAt: createdAt(),
|
||||
},
|
||||
(t) => [index("notifications_user_idx").on(t.userId, t.createdAt)],
|
||||
);
|
||||
|
||||
/** Push-tokens för Expo push. */
|
||||
export const pushTokens = pgTable(
|
||||
"push_tokens",
|
||||
{
|
||||
userId: uuid("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
token: text("token").notNull(),
|
||||
platform: text("platform").notNull(),
|
||||
updatedAt: updatedAt(),
|
||||
},
|
||||
(t) => [primaryKey({ columns: [t.userId, t.token] })],
|
||||
);
|
||||
|
||||
/** Creator-statistik (spec §37). */
|
||||
export const creatorStats = pgTable("creator_stats", {
|
||||
userId: uuid("user_id")
|
||||
.primaryKey()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
visibility: profileVisibilityEnum("visibility").notNull().default("private"),
|
||||
level: creatorLevelEnum("level").notNull().default("beginner"),
|
||||
publishedRecipes: integer("published_recipes").notNull().default(0),
|
||||
followers: integer("followers").notNull().default(0),
|
||||
totalCooks: integer("total_cooks").notNull().default(0),
|
||||
totalFavorites: integer("total_favorites").notNull().default(0),
|
||||
averageRating: doublePrecision("average_rating"),
|
||||
verifiedRecipes: integer("verified_recipes").notNull().default(0),
|
||||
badges: text("badges").array().notNull().default([]),
|
||||
updatedAt: updatedAt(),
|
||||
});
|
||||
|
||||
export const creatorFollows = pgTable(
|
||||
"creator_follows",
|
||||
{
|
||||
followerUserId: uuid("follower_user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
creatorUserId: uuid("creator_user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
createdAt: createdAt(),
|
||||
},
|
||||
(t) => [primaryKey({ columns: [t.followerUserId, t.creatorUserId] })],
|
||||
);
|
||||
|
||||
/** AI-evals (spec §34): fast testbibliotek + körningar. Ingen modelländring utan eval. */
|
||||
export const aiEvalCases = pgTable("ai_eval_cases", {
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
taskType: text("task_type").notNull(),
|
||||
nameSv: text("name_sv").notNull(),
|
||||
inputRef: jsonb("input_ref").notNull(),
|
||||
expectedOutput: jsonb("expected_output").notNull(),
|
||||
category: text("category").notNull().default("standard"),
|
||||
active: boolean("active").notNull().default(true),
|
||||
createdAt: createdAt(),
|
||||
});
|
||||
|
||||
export const aiEvalRuns = pgTable(
|
||||
"ai_eval_runs",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
taskType: text("task_type").notNull(),
|
||||
modelVersion: text("model_version").notNull(),
|
||||
promptVersion: text("prompt_version").notNull(),
|
||||
metrics: jsonb("metrics").notNull(),
|
||||
passed: boolean("passed").notNull(),
|
||||
notes: text("notes"),
|
||||
createdAt: createdAt(),
|
||||
},
|
||||
(t) => [index("ai_eval_runs_task_idx").on(t.taskType, t.createdAt)],
|
||||
);
|
||||
|
||||
/** Householdsvinn per vecka – aggregat för budget/matsvinnsvyer (spec §26). */
|
||||
export const wasteSummaries = pgTable(
|
||||
"waste_summaries",
|
||||
{
|
||||
householdId: uuid("household_id")
|
||||
.notNull()
|
||||
.references(() => households.id, { onDelete: "cascade" }),
|
||||
weekStartDate: text("week_start_date").notNull(),
|
||||
discardedCount: integer("discarded_count").notNull().default(0),
|
||||
discardedValueMinor: integer("discarded_value_minor").notNull().default(0),
|
||||
updatedAt: updatedAt(),
|
||||
},
|
||||
(t) => [primaryKey({ columns: [t.householdId, t.weekStartDate] })],
|
||||
);
|
||||
@@ -0,0 +1,61 @@
|
||||
import { sql } from "drizzle-orm";
|
||||
import {
|
||||
doublePrecision,
|
||||
index,
|
||||
integer,
|
||||
jsonb,
|
||||
pgTable,
|
||||
text,
|
||||
timestamp,
|
||||
uniqueIndex,
|
||||
uuid,
|
||||
} from "drizzle-orm/pg-core";
|
||||
import type { NutritionDeclaration } from "@app/shared-types";
|
||||
import { allergenEnum, createdAt, unitEnum, updatedAt, verificationStatusEnum } from "./_shared.js";
|
||||
import { canonicalIngredients } from "./ingredients.js";
|
||||
|
||||
/**
|
||||
* Produktdatabas med versionshantering (spec §11): innehåll och näringsvärden
|
||||
* ändras över tid, därför är (gtin, version) unik och endast en rad är aktuell
|
||||
* (valid_to IS NULL).
|
||||
*/
|
||||
export const products = pgTable(
|
||||
"products",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
gtin: text("gtin"),
|
||||
name: text("name").notNull(),
|
||||
brand: text("brand"),
|
||||
canonicalIngredientId: text("canonical_ingredient_id").references(
|
||||
() => canonicalIngredients.id,
|
||||
),
|
||||
packageSizeValue: doublePrecision("package_size_value"),
|
||||
packageSizeUnit: unitEnum("package_size_unit"),
|
||||
ingredientsText: text("ingredients_text"),
|
||||
allergens: allergenEnum("allergens").array().notNull().default([]),
|
||||
mayContainAllergens: allergenEnum("may_contain_allergens").array().notNull().default([]),
|
||||
nutrition: jsonb("nutrition").$type<NutritionDeclaration>(),
|
||||
imageUrls: text("image_urls").array().notNull().default([]),
|
||||
language: text("language").notNull().default("sv"),
|
||||
market: text("market").notNull().default("SE"),
|
||||
dataSource: text("data_source").notNull(),
|
||||
verificationStatus: verificationStatusEnum("verification_status")
|
||||
.notNull()
|
||||
.default("unverified"),
|
||||
version: integer("version").notNull().default(1),
|
||||
validFrom: timestamp("valid_from", { withTimezone: true }).notNull().defaultNow(),
|
||||
validTo: timestamp("valid_to", { withTimezone: true }),
|
||||
createdByUserId: uuid("created_by_user_id"),
|
||||
createdAt: createdAt(),
|
||||
updatedAt: updatedAt(),
|
||||
},
|
||||
(t) => [
|
||||
uniqueIndex("products_gtin_version_unique")
|
||||
.on(t.gtin, t.version)
|
||||
.where(sql`${t.gtin} IS NOT NULL`),
|
||||
index("products_gtin_current_idx")
|
||||
.on(t.gtin)
|
||||
.where(sql`${t.validTo} IS NULL`),
|
||||
index("products_canonical_idx").on(t.canonicalIngredientId),
|
||||
],
|
||||
);
|
||||
@@ -0,0 +1,79 @@
|
||||
import {
|
||||
boolean,
|
||||
date,
|
||||
doublePrecision,
|
||||
index,
|
||||
pgTable,
|
||||
text,
|
||||
uuid,
|
||||
integer,
|
||||
} from "drizzle-orm/pg-core";
|
||||
import { createdAt, unitEnum } from "./_shared.js";
|
||||
import { households } from "./households.js";
|
||||
import { canonicalIngredients } from "./ingredients.js";
|
||||
import { products } from "./products.js";
|
||||
|
||||
/** Kvitton (spec §12): lager + budget + prishistorik + matsvinnsvärde. */
|
||||
export const receipts = pgTable(
|
||||
"receipts",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
householdId: uuid("household_id")
|
||||
.notNull()
|
||||
.references(() => households.id, { onDelete: "cascade" }),
|
||||
storeName: text("store_name"),
|
||||
purchaseDate: date("purchase_date"),
|
||||
totalMinor: integer("total_minor"),
|
||||
discountMinor: integer("discount_minor"),
|
||||
imageUrl: text("image_url"),
|
||||
scanJobId: uuid("scan_job_id"),
|
||||
status: text("status").notNull().default("pending"),
|
||||
createdAt: createdAt(),
|
||||
},
|
||||
(t) => [index("receipts_household_idx").on(t.householdId, t.purchaseDate)],
|
||||
);
|
||||
|
||||
export const receiptLines = pgTable(
|
||||
"receipt_lines",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
receiptId: uuid("receipt_id")
|
||||
.notNull()
|
||||
.references(() => receipts.id, { onDelete: "cascade" }),
|
||||
rawText: text("raw_text").notNull(),
|
||||
normalizedName: text("normalized_name"),
|
||||
canonicalIngredientId: text("canonical_ingredient_id").references(
|
||||
() => canonicalIngredients.id,
|
||||
),
|
||||
productId: uuid("product_id").references(() => products.id),
|
||||
quantity: doublePrecision("quantity"),
|
||||
unit: unitEnum("unit"),
|
||||
unitPriceMinor: integer("unit_price_minor"),
|
||||
totalPriceMinor: integer("total_price_minor"),
|
||||
confidence: doublePrecision("confidence").notNull().default(0),
|
||||
verifiedByUser: boolean("verified_by_user").notNull().default(false),
|
||||
addedToInventory: boolean("added_to_inventory").notNull().default(false),
|
||||
},
|
||||
(t) => [index("receipt_lines_receipt_idx").on(t.receiptId)],
|
||||
);
|
||||
|
||||
/** Prishistorik per ingrediens/produkt – grund för budget och prognoser (spec §12, §26). */
|
||||
export const priceObservations = pgTable(
|
||||
"price_observations",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
canonicalIngredientId: text("canonical_ingredient_id").references(
|
||||
() => canonicalIngredients.id,
|
||||
),
|
||||
productId: uuid("product_id").references(() => products.id),
|
||||
storeName: text("store_name"),
|
||||
priceMinor: integer("price_minor").notNull(),
|
||||
quantity: doublePrecision("quantity"),
|
||||
unit: unitEnum("unit"),
|
||||
pricePerKgMinor: integer("price_per_kg_minor"),
|
||||
observedAt: date("observed_at").notNull(),
|
||||
source: text("source").notNull().default("receipt"),
|
||||
createdAt: createdAt(),
|
||||
},
|
||||
(t) => [index("price_observations_ingredient_idx").on(t.canonicalIngredientId, t.observedAt)],
|
||||
);
|
||||
@@ -0,0 +1,231 @@
|
||||
import {
|
||||
boolean,
|
||||
doublePrecision,
|
||||
index,
|
||||
integer,
|
||||
jsonb,
|
||||
pgTable,
|
||||
primaryKey,
|
||||
text,
|
||||
timestamp,
|
||||
uniqueIndex,
|
||||
uuid,
|
||||
} from "drizzle-orm/pg-core";
|
||||
import type { NutritionValues, RecipeDNA, Season } from "@app/shared-types";
|
||||
import {
|
||||
allergenEnum,
|
||||
createdAt,
|
||||
cuisineEnum,
|
||||
mealTypeEnum,
|
||||
recipeDifficultyEnum,
|
||||
recipeSimilarityClassEnum,
|
||||
recipeSourceTypeEnum,
|
||||
recipeStatusEnum,
|
||||
recipeVariantTypeEnum,
|
||||
recipeVerificationStatusEnum,
|
||||
unitEnum,
|
||||
updatedAt,
|
||||
} from "./_shared.js";
|
||||
import { canonicalIngredients } from "./ingredients.js";
|
||||
import { users } from "./users.js";
|
||||
|
||||
/** Juridiskt source registry (spec §15). */
|
||||
export const recipeSourceRegistry = pgTable("recipe_source_registry", {
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
sourceName: text("source_name").notNull(),
|
||||
license: text("license").notNull(),
|
||||
rightToStore: boolean("right_to_store").notNull(),
|
||||
rightToModify: boolean("right_to_modify").notNull(),
|
||||
rightToDisplay: boolean("right_to_display").notNull(),
|
||||
attributionRequired: boolean("attribution_required").notNull().default(false),
|
||||
attributionText: text("attribution_text"),
|
||||
commercialUse: boolean("commercial_use").notNull(),
|
||||
validFrom: timestamp("valid_from", { withTimezone: true }).notNull().defaultNow(),
|
||||
validTo: timestamp("valid_to", { withTimezone: true }),
|
||||
notes: text("notes"),
|
||||
createdAt: createdAt(),
|
||||
});
|
||||
|
||||
export const recipes = pgTable(
|
||||
"recipes",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
slug: text("slug").notNull(),
|
||||
titleSv: text("title_sv").notNull(),
|
||||
descriptionSv: text("description_sv").notNull().default(""),
|
||||
country: text("country"),
|
||||
region: text("region"),
|
||||
cuisine: cuisineEnum("cuisine").notNull(),
|
||||
mealTypes: mealTypeEnum("meal_types").array().notNull().default([]),
|
||||
tags: text("tags").array().notNull().default([]),
|
||||
methods: text("methods").array().notNull().default([]),
|
||||
equipment: text("equipment").array().notNull().default([]),
|
||||
difficulty: recipeDifficultyEnum("difficulty").notNull().default("easy"),
|
||||
prepTimeMinutes: integer("prep_time_minutes").notNull().default(0),
|
||||
cookTimeMinutes: integer("cook_time_minutes").notNull().default(0),
|
||||
totalTimeMinutes: integer("total_time_minutes").notNull().default(0),
|
||||
portions: integer("portions").notNull().default(4),
|
||||
/** Beräknas ALLTID deterministiskt av nutrition-engine (spec §61.1). */
|
||||
nutritionPerPortion: jsonb("nutrition_per_portion").$type<NutritionValues>().notNull(),
|
||||
/** Härledda ur ingrediensernas allergener – deterministiskt (spec §61.2). */
|
||||
allergens: allergenEnum("allergens").array().notNull().default([]),
|
||||
spiceLevel: integer("spice_level").notNull().default(0),
|
||||
estimatedCostMinorPerPortion: integer("estimated_cost_minor_per_portion"),
|
||||
storageGuidanceSv: text("storage_guidance_sv"),
|
||||
mealPrepFriendly: boolean("meal_prep_friendly").notNull().default(false),
|
||||
freezerFriendly: boolean("freezer_friendly").notNull().default(false),
|
||||
peakSeasons: text("peak_seasons").array().$type<Season[]>().notNull().default([]),
|
||||
holidayTags: text("holiday_tags").array().notNull().default([]),
|
||||
dna: jsonb("dna").$type<RecipeDNA>().notNull(),
|
||||
variantType: recipeVariantTypeEnum("variant_type").notNull().default("standard"),
|
||||
variantOfRecipeId: uuid("variant_of_recipe_id"),
|
||||
forkedFromRecipeId: uuid("forked_from_recipe_id"),
|
||||
status: recipeStatusEnum("status").notNull().default("draft"),
|
||||
verificationStatus: recipeVerificationStatusEnum("verification_status")
|
||||
.notNull()
|
||||
.default("unverified"),
|
||||
sourceType: recipeSourceTypeEnum("source_type").notNull(),
|
||||
sourceRegistryId: uuid("source_registry_id").references(() => recipeSourceRegistry.id),
|
||||
creatorUserId: uuid("creator_user_id").references(() => users.id, { onDelete: "set null" }),
|
||||
creatorDisplayName: text("creator_display_name"),
|
||||
imageUrls: text("image_urls").array().notNull().default([]),
|
||||
version: integer("version").notNull().default(1),
|
||||
ratingAverage: doublePrecision("rating_average"),
|
||||
ratingCount: integer("rating_count").notNull().default(0),
|
||||
cookCount: integer("cook_count").notNull().default(0),
|
||||
favoriteCount: integer("favorite_count").notNull().default(0),
|
||||
moderationNote: text("moderation_note"),
|
||||
createdAt: createdAt(),
|
||||
updatedAt: updatedAt(),
|
||||
},
|
||||
(t) => [
|
||||
uniqueIndex("recipes_slug_unique").on(t.slug),
|
||||
index("recipes_status_idx").on(t.status),
|
||||
index("recipes_cuisine_idx").on(t.cuisine),
|
||||
index("recipes_variant_of_idx").on(t.variantOfRecipeId),
|
||||
index("recipes_creator_idx").on(t.creatorUserId),
|
||||
index("recipes_total_time_idx").on(t.totalTimeMinutes),
|
||||
],
|
||||
);
|
||||
|
||||
export const recipeIngredients = pgTable(
|
||||
"recipe_ingredients",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
recipeId: uuid("recipe_id")
|
||||
.notNull()
|
||||
.references(() => recipes.id, { onDelete: "cascade" }),
|
||||
canonicalIngredientId: text("canonical_ingredient_id")
|
||||
.notNull()
|
||||
.references(() => canonicalIngredients.id),
|
||||
displayNameSv: text("display_name_sv").notNull(),
|
||||
quantity: doublePrecision("quantity").notNull(),
|
||||
unit: unitEnum("unit").notNull(),
|
||||
note: text("note"),
|
||||
optional: boolean("optional").notNull().default(false),
|
||||
groupName: text("group_name"),
|
||||
sortOrder: integer("sort_order").notNull().default(0),
|
||||
},
|
||||
(t) => [
|
||||
index("recipe_ingredients_recipe_idx").on(t.recipeId),
|
||||
index("recipe_ingredients_canonical_idx").on(t.canonicalIngredientId),
|
||||
],
|
||||
);
|
||||
|
||||
export const recipeSteps = pgTable(
|
||||
"recipe_steps",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
recipeId: uuid("recipe_id")
|
||||
.notNull()
|
||||
.references(() => recipes.id, { onDelete: "cascade" }),
|
||||
stepNumber: integer("step_number").notNull(),
|
||||
instructionSv: text("instruction_sv").notNull(),
|
||||
timerSeconds: integer("timer_seconds"),
|
||||
temperatureC: integer("temperature_c"),
|
||||
tip: text("tip"),
|
||||
},
|
||||
(t) => [
|
||||
index("recipe_steps_recipe_idx").on(t.recipeId),
|
||||
uniqueIndex("recipe_steps_recipe_step_unique").on(t.recipeId, t.stepNumber),
|
||||
],
|
||||
);
|
||||
|
||||
export const recipeRatings = pgTable(
|
||||
"recipe_ratings",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
recipeId: uuid("recipe_id")
|
||||
.notNull()
|
||||
.references(() => recipes.id, { onDelete: "cascade" }),
|
||||
userId: uuid("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
stars: integer("stars").notNull(),
|
||||
feedbackTags: text("feedback_tags").array().notNull().default([]),
|
||||
comment: text("comment"),
|
||||
createdAt: createdAt(),
|
||||
updatedAt: updatedAt(),
|
||||
},
|
||||
(t) => [
|
||||
uniqueIndex("recipe_ratings_user_recipe_unique").on(t.recipeId, t.userId),
|
||||
index("recipe_ratings_recipe_idx").on(t.recipeId),
|
||||
],
|
||||
);
|
||||
|
||||
export const recipeFavorites = pgTable(
|
||||
"recipe_favorites",
|
||||
{
|
||||
recipeId: uuid("recipe_id")
|
||||
.notNull()
|
||||
.references(() => recipes.id, { onDelete: "cascade" }),
|
||||
userId: uuid("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
createdAt: createdAt(),
|
||||
},
|
||||
(t) => [
|
||||
primaryKey({ columns: [t.recipeId, t.userId] }),
|
||||
index("recipe_favorites_user_idx").on(t.userId),
|
||||
],
|
||||
);
|
||||
|
||||
/** Logg över lagningar – grund för betyg, ranking, Food Memory (spec §37–39). */
|
||||
export const recipeCooks = pgTable(
|
||||
"recipe_cooks",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
recipeId: uuid("recipe_id")
|
||||
.notNull()
|
||||
.references(() => recipes.id, { onDelete: "cascade" }),
|
||||
userId: uuid("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
householdId: uuid("household_id"),
|
||||
portionsCooked: integer("portions_cooked").notNull(),
|
||||
cookedAt: timestamp("cooked_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(t) => [
|
||||
index("recipe_cooks_recipe_idx").on(t.recipeId),
|
||||
index("recipe_cooks_user_idx").on(t.userId),
|
||||
],
|
||||
);
|
||||
|
||||
/** Dubblett-/variantklassning mellan recept (spec §36). */
|
||||
export const recipeSimilarities = pgTable(
|
||||
"recipe_similarities",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
recipeAId: uuid("recipe_a_id")
|
||||
.notNull()
|
||||
.references(() => recipes.id, { onDelete: "cascade" }),
|
||||
recipeBId: uuid("recipe_b_id")
|
||||
.notNull()
|
||||
.references(() => recipes.id, { onDelete: "cascade" }),
|
||||
similarityScore: doublePrecision("similarity_score").notNull(),
|
||||
classification: recipeSimilarityClassEnum("classification").notNull(),
|
||||
details: jsonb("details"),
|
||||
createdAt: createdAt(),
|
||||
},
|
||||
(t) => [uniqueIndex("recipe_similarities_pair_unique").on(t.recipeAId, t.recipeBId)],
|
||||
);
|
||||
@@ -0,0 +1,73 @@
|
||||
import {
|
||||
doublePrecision,
|
||||
index,
|
||||
integer,
|
||||
jsonb,
|
||||
pgTable,
|
||||
text,
|
||||
timestamp,
|
||||
uuid,
|
||||
} from "drizzle-orm/pg-core";
|
||||
import { createdAt, jobStatusEnum, jobTypeEnum, scanTypeEnum, updatedAt } from "./_shared.js";
|
||||
import { households } from "./households.js";
|
||||
import { users } from "./users.js";
|
||||
|
||||
/**
|
||||
* Asynkrona skannings-/AI-jobb (spec §50, §54):
|
||||
* App → signed S3 upload → API job → worker → AAMOS → result → app.
|
||||
*/
|
||||
export const scanJobs = pgTable(
|
||||
"scan_jobs",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
userId: uuid("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
householdId: uuid("household_id").references(() => households.id, { onDelete: "set null" }),
|
||||
scanType: scanTypeEnum("scan_type").notNull(),
|
||||
jobType: jobTypeEnum("job_type").notNull(),
|
||||
status: jobStatusEnum("status").notNull().default("queued"),
|
||||
s3Keys: text("s3_keys").array().notNull().default([]),
|
||||
context: jsonb("context"),
|
||||
/** Strukturerat AI-resultat validerat mot ai-contracts innan lagring. */
|
||||
result: jsonb("result"),
|
||||
error: text("error"),
|
||||
modelVersion: text("model_version"),
|
||||
promptVersion: text("prompt_version"),
|
||||
latencyMs: integer("latency_ms"),
|
||||
costUsd: doublePrecision("cost_usd"),
|
||||
attempts: integer("attempts").notNull().default(0),
|
||||
completedAt: timestamp("completed_at", { withTimezone: true }),
|
||||
createdAt: createdAt(),
|
||||
updatedAt: updatedAt(),
|
||||
},
|
||||
(t) => [
|
||||
index("scan_jobs_user_idx").on(t.userId, t.createdAt),
|
||||
index("scan_jobs_status_idx").on(t.status),
|
||||
],
|
||||
);
|
||||
|
||||
/**
|
||||
* Verifierade korrigeringar (spec §33): AI sa X, användaren sa Y.
|
||||
* Grund för träningsdata – används ENDAST enligt samtycke.
|
||||
*/
|
||||
export const aiCorrections = pgTable(
|
||||
"ai_corrections",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
scanJobId: uuid("scan_job_id").references(() => scanJobs.id, { onDelete: "set null" }),
|
||||
userId: uuid("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
taskType: text("task_type").notNull(),
|
||||
aiOutput: jsonb("ai_output").notNull(),
|
||||
userCorrection: jsonb("user_correction").notNull(),
|
||||
modelVersion: text("model_version"),
|
||||
promptVersion: text("prompt_version"),
|
||||
/** Snapshot av samtyckesläget när korrigeringen skapades. */
|
||||
consentSnapshot: jsonb("consent_snapshot").notNull(),
|
||||
exportedToTraining: timestamp("exported_to_training", { withTimezone: true }),
|
||||
createdAt: createdAt(),
|
||||
},
|
||||
(t) => [index("ai_corrections_task_idx").on(t.taskType, t.createdAt)],
|
||||
);
|
||||
@@ -0,0 +1,30 @@
|
||||
import { boolean, integer, jsonb, pgTable, text, uniqueIndex } from "drizzle-orm/pg-core";
|
||||
import { createdAt, updatedAt } from "./_shared.js";
|
||||
|
||||
type DateRule =
|
||||
| { kind: "fixed"; monthDay: string }
|
||||
| { kind: "range"; startMonthDay: string; endMonthDay: string }
|
||||
| { kind: "computed"; algorithm: "midsummer" | "easter" | "advent" | "custom" };
|
||||
|
||||
/**
|
||||
* Datadriven Season & Events Engine (spec §28): midsommar, jul, påsk, kräftskiva,
|
||||
* Ramadan, Eid, Thanksgiving m.fl. – per marknad, utan hårdkodning i motorerna.
|
||||
*/
|
||||
export const seasonEvents = pgTable(
|
||||
"season_events",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
slug: text("slug").notNull(),
|
||||
nameSv: text("name_sv").notNull(),
|
||||
market: text("market").notNull().default("SE"),
|
||||
dateRule: jsonb("date_rule").$type<DateRule>().notNull(),
|
||||
leadDays: integer("lead_days").notNull().default(7),
|
||||
foodTags: text("food_tags").array().notNull().default([]),
|
||||
recipeSlugs: text("recipe_slugs").array().notNull().default([]),
|
||||
priority: integer("priority").notNull().default(0),
|
||||
active: boolean("active").notNull().default(true),
|
||||
createdAt: createdAt(),
|
||||
updatedAt: updatedAt(),
|
||||
},
|
||||
(t) => [uniqueIndex("season_events_slug_market_unique").on(t.slug, t.market)],
|
||||
);
|
||||
@@ -0,0 +1,127 @@
|
||||
import {
|
||||
boolean,
|
||||
index,
|
||||
integer,
|
||||
jsonb,
|
||||
pgTable,
|
||||
text,
|
||||
timestamp,
|
||||
uniqueIndex,
|
||||
uuid,
|
||||
} from "drizzle-orm/pg-core";
|
||||
import { sql } from "drizzle-orm";
|
||||
import {
|
||||
createdAt,
|
||||
subscriptionPlanEnum,
|
||||
subscriptionProviderEnum,
|
||||
subscriptionStatusEnum,
|
||||
updatedAt,
|
||||
} from "./_shared.js";
|
||||
import { households } from "./households.js";
|
||||
import { users } from "./users.js";
|
||||
|
||||
/** 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" }),
|
||||
householdId: uuid("household_id").references(() => households.id, { onDelete: "set null" }),
|
||||
provider: subscriptionProviderEnum("provider").notNull(),
|
||||
productId: text("product_id").notNull(),
|
||||
plan: subscriptionPlanEnum("plan").notNull(),
|
||||
originalTransactionId: text("original_transaction_id"),
|
||||
status: subscriptionStatusEnum("status").notNull(),
|
||||
purchasedAt: timestamp("purchased_at", { withTimezone: true }),
|
||||
expiresAt: timestamp("expires_at", { withTimezone: true }),
|
||||
gracePeriodExpiresAt: timestamp("grace_period_expires_at", { withTimezone: true }),
|
||||
canceledAt: timestamp("canceled_at", { withTimezone: true }),
|
||||
lastVerifiedAt: timestamp("last_verified_at", { withTimezone: true }),
|
||||
createdAt: createdAt(),
|
||||
updatedAt: updatedAt(),
|
||||
},
|
||||
(t) => [
|
||||
index("subscriptions_user_idx").on(t.userId),
|
||||
uniqueIndex("subscriptions_original_tx_unique")
|
||||
.on(t.provider, t.originalTransactionId)
|
||||
.where(sql`${t.originalTransactionId} IS NOT NULL`),
|
||||
],
|
||||
);
|
||||
|
||||
/** Händelselogg: köp, förnyelse, grace, churn (spec §47). */
|
||||
export const subscriptionEvents = pgTable(
|
||||
"subscription_events",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
subscriptionId: uuid("subscription_id").references(() => subscriptions.id, {
|
||||
onDelete: "cascade",
|
||||
}),
|
||||
userId: uuid("user_id").references(() => users.id, { onDelete: "set null" }),
|
||||
eventType: text("event_type").notNull(),
|
||||
payload: jsonb("payload"),
|
||||
createdAt: createdAt(),
|
||||
},
|
||||
(t) => [index("subscription_events_sub_idx").on(t.subscriptionId, t.createdAt)],
|
||||
);
|
||||
|
||||
/** Råa store-transaktioner för revision (spec §47). */
|
||||
export const storeTransactions = pgTable(
|
||||
"store_transactions",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
provider: subscriptionProviderEnum("provider").notNull(),
|
||||
transactionId: text("transaction_id").notNull(),
|
||||
originalTransactionId: text("original_transaction_id"),
|
||||
userId: uuid("user_id").references(() => users.id, { onDelete: "set null" }),
|
||||
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)],
|
||||
);
|
||||
|
||||
/** App Store Server Notifications / Play RTDN – tas emot rått, processas av worker. */
|
||||
export const storeNotifications = pgTable(
|
||||
"store_notifications",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
provider: subscriptionProviderEnum("provider").notNull(),
|
||||
notificationType: text("notification_type"),
|
||||
rawPayload: jsonb("raw_payload").notNull(),
|
||||
signatureVerified: boolean("signature_verified").notNull().default(false),
|
||||
processed: boolean("processed").notNull().default(false),
|
||||
processedAt: timestamp("processed_at", { withTimezone: true }),
|
||||
error: text("error"),
|
||||
createdAt: createdAt(),
|
||||
},
|
||||
(t) => [index("store_notifications_pending_idx").on(t.processed, t.createdAt)],
|
||||
);
|
||||
|
||||
/** Trial-spårning: en trial per användare (spec §46). */
|
||||
export const trials = pgTable("trials", {
|
||||
userId: uuid("user_id")
|
||||
.primaryKey()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
startedAt: timestamp("started_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
endsAt: timestamp("ends_at", { withTimezone: true }).notNull(),
|
||||
});
|
||||
|
||||
/** Månatlig AI-användning för fair use / gratiskvot (spec §45–46). */
|
||||
export const aiUsageCounters = pgTable(
|
||||
"ai_usage_counters",
|
||||
{
|
||||
userId: uuid("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
/** Format YYYY-MM. */
|
||||
month: text("month").notNull(),
|
||||
aiScans: integer("ai_scans").notNull().default(0),
|
||||
aiTokensIn: integer("ai_tokens_in").notNull().default(0),
|
||||
aiTokensOut: integer("ai_tokens_out").notNull().default(0),
|
||||
updatedAt: updatedAt(),
|
||||
},
|
||||
(t) => [uniqueIndex("ai_usage_user_month_unique").on(t.userId, t.month)],
|
||||
);
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* Översättningstabeller (i18n-spec §11–14, M2–M3).
|
||||
*
|
||||
* Principer:
|
||||
* - Svenska källfält (nameSv, titleSv, instructionSv …) är alltid sanningen.
|
||||
* - Översättningar är RADER per språk – aldrig nya kolumner per språk.
|
||||
* - languageTag är BCP 47 (vanligen bara primärt språk: "en", "de"; regional
|
||||
* variant tillåts när det behövs: "en-US" vinner över "en" vid upplösning).
|
||||
* - Recept-/stegöversättningar följer AI-utkastflödet draft_ai → in_review →
|
||||
* published med deterministisk verifiering (samma stegantal, bevarade tal).
|
||||
*/
|
||||
import { integer, jsonb, pgEnum, pgTable, text, uuid, varchar } from "drizzle-orm/pg-core";
|
||||
import { index, primaryKey, uniqueIndex } from "drizzle-orm/pg-core";
|
||||
import { TRANSLATION_SOURCES, TRANSLATION_STATUSES, tuple } from "@app/shared-types";
|
||||
import { createdAt, unitEnum, updatedAt } from "./_shared.js";
|
||||
import { canonicalIngredients } from "./ingredients.js";
|
||||
import { recipes } from "./recipes.js";
|
||||
|
||||
export const translationStatusEnum = pgEnum("translation_status", tuple(TRANSLATION_STATUSES));
|
||||
export const translationSourceEnum = pgEnum("translation_source", tuple(TRANSLATION_SOURCES));
|
||||
|
||||
/** Ingrediensnamn + alias per språk (i18n-spec §11). Seed flyttar nameEn hit. */
|
||||
export const ingredientTranslations = pgTable(
|
||||
"ingredient_translations",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
ingredientId: text("ingredient_id")
|
||||
.notNull()
|
||||
.references(() => canonicalIngredients.id, { onDelete: "cascade" }),
|
||||
languageTag: varchar("language_tag", { length: 35 }).notNull(),
|
||||
name: text("name").notNull(),
|
||||
aliases: text("aliases").array().notNull().default([]),
|
||||
source: translationSourceEnum("source").notNull().default("seed"),
|
||||
status: translationStatusEnum("status").notNull().default("published"),
|
||||
createdAt: createdAt(),
|
||||
updatedAt: updatedAt(),
|
||||
},
|
||||
(t) => [
|
||||
uniqueIndex("ingredient_translations_unique").on(t.ingredientId, t.languageTag),
|
||||
index("ingredient_translations_lang_idx").on(t.languageTag),
|
||||
],
|
||||
);
|
||||
|
||||
/** Enhetsetiketter per språk (i18n-spec §9, §12). Visning – aldrig lagringsformat. */
|
||||
export const unitTranslations = pgTable(
|
||||
"unit_translations",
|
||||
{
|
||||
unitCode: unitEnum("unit_code").notNull(),
|
||||
languageTag: varchar("language_tag", { length: 35 }).notNull(),
|
||||
abbreviation: text("abbreviation").notNull(),
|
||||
name: text("name").notNull(),
|
||||
},
|
||||
(t) => [primaryKey({ columns: [t.unitCode, t.languageTag] })],
|
||||
);
|
||||
|
||||
/** Recepttitel/-beskrivning per språk med AI-utkastflöde (i18n-spec §13–14). */
|
||||
export const recipeTranslations = pgTable(
|
||||
"recipe_translations",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
recipeId: uuid("recipe_id")
|
||||
.notNull()
|
||||
.references(() => recipes.id, { onDelete: "cascade" }),
|
||||
languageTag: varchar("language_tag", { length: 35 }).notNull(),
|
||||
title: text("title").notNull(),
|
||||
description: text("description"),
|
||||
storageGuidance: text("storage_guidance"),
|
||||
status: translationStatusEnum("status").notNull().default("draft_ai"),
|
||||
source: translationSourceEnum("source").notNull().default("ai"),
|
||||
/** Resultat av de deterministiska verifieringarna (stegantal, bevarade tal …). */
|
||||
verification: jsonb("verification").$type<{
|
||||
ok: boolean;
|
||||
checks: Record<string, boolean>;
|
||||
notes?: string[];
|
||||
}>(),
|
||||
createdAt: createdAt(),
|
||||
updatedAt: updatedAt(),
|
||||
},
|
||||
(t) => [
|
||||
uniqueIndex("recipe_translations_unique").on(t.recipeId, t.languageTag),
|
||||
index("recipe_translations_lang_idx").on(t.languageTag, t.status),
|
||||
],
|
||||
);
|
||||
|
||||
/** Stegtexter per språk. Struktur (timer, temperatur) bor kvar i recipe_steps. */
|
||||
export const recipeStepTranslations = pgTable(
|
||||
"recipe_step_translations",
|
||||
{
|
||||
recipeId: uuid("recipe_id")
|
||||
.notNull()
|
||||
.references(() => recipes.id, { onDelete: "cascade" }),
|
||||
languageTag: varchar("language_tag", { length: 35 }).notNull(),
|
||||
stepNumber: integer("step_number").notNull(),
|
||||
instruction: text("instruction").notNull(),
|
||||
tip: text("tip"),
|
||||
},
|
||||
(t) => [primaryKey({ columns: [t.recipeId, t.languageTag, t.stepNumber] })],
|
||||
);
|
||||
@@ -0,0 +1,194 @@
|
||||
import {
|
||||
boolean,
|
||||
doublePrecision,
|
||||
index,
|
||||
integer,
|
||||
pgTable,
|
||||
primaryKey,
|
||||
text,
|
||||
timestamp,
|
||||
uniqueIndex,
|
||||
uuid,
|
||||
} from "drizzle-orm/pg-core";
|
||||
import {
|
||||
activityLevelEnum,
|
||||
allergenEnum,
|
||||
consentKindEnum,
|
||||
consentStatusEnum,
|
||||
createdAt,
|
||||
dietPatternEnum,
|
||||
goalTypeEnum,
|
||||
precisionModeEnum,
|
||||
religiousRuleEnum,
|
||||
sexEnum,
|
||||
updatedAt,
|
||||
userRoleEnum,
|
||||
cuisineEnum,
|
||||
} from "./_shared.js";
|
||||
|
||||
/** Kontodata. Hälsodata ligger i user_health_profiles (separationskrav, spec §56). */
|
||||
export const users = pgTable(
|
||||
"users",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
email: text("email").notNull(),
|
||||
/** null = e-posten ej verifierad ännu (icke-blockerande – appen fungerar ändå). */
|
||||
emailVerifiedAt: timestamp("email_verified_at", { withTimezone: true }),
|
||||
displayName: text("display_name").notNull(),
|
||||
role: userRoleEnum("role").notNull().default("user"),
|
||||
locale: text("locale").notNull().default("sv-SE"),
|
||||
precisionMode: precisionModeEnum("precision_mode").notNull().default("simple"),
|
||||
onboardingCompleted: boolean("onboarding_completed").notNull().default(false),
|
||||
deletedAt: timestamp("deleted_at", { withTimezone: true }),
|
||||
createdAt: createdAt(),
|
||||
updatedAt: updatedAt(),
|
||||
},
|
||||
(t) => [uniqueIndex("users_email_unique").on(t.email)],
|
||||
);
|
||||
|
||||
/** Autentiseringsuppgifter separerade från kontot (byts utan att röra users). */
|
||||
export const userCredentials = pgTable("user_credentials", {
|
||||
userId: uuid("user_id")
|
||||
.primaryKey()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
/** scrypt-hash i formatet scrypt$N$r$p$salt$hash (ingen extern native-dependency). */
|
||||
passwordHash: text("password_hash").notNull(),
|
||||
passwordUpdatedAt: timestamp("password_updated_at", { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow(),
|
||||
});
|
||||
|
||||
/** Admin-2FA (TOTP, RFC 6238). Endast admin-konton; secret per användare. */
|
||||
export const adminTotp = pgTable("admin_totp", {
|
||||
userId: uuid("user_id")
|
||||
.primaryKey()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
secretBase32: text("secret_base32").notNull(),
|
||||
/** null = setup påbörjad men inte bekräftad med kod ännu. */
|
||||
enabledAt: timestamp("enabled_at", { withTimezone: true }),
|
||||
/** Senast accepterade TOTP-steg – förhindrar återanvändning inom fönstret. */
|
||||
lastUsedStep: integer("last_used_step"),
|
||||
createdAt: createdAt(),
|
||||
});
|
||||
|
||||
/** E-postverifiering av konton. Samma säkerhetsmodell som lösenordsåterställning. */
|
||||
export const emailVerificationTokens = pgTable(
|
||||
"email_verification_tokens",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
userId: uuid("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
tokenHash: text("token_hash").notNull(),
|
||||
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
|
||||
usedAt: timestamp("used_at", { withTimezone: true }),
|
||||
createdAt: createdAt(),
|
||||
},
|
||||
(t) => [
|
||||
index("email_verification_tokens_user_idx").on(t.userId),
|
||||
uniqueIndex("email_verification_tokens_hash_unique").on(t.tokenHash),
|
||||
],
|
||||
);
|
||||
|
||||
/**
|
||||
* Lösenordsåterställning via e-post. Token lagras ENDAST hashad (sha256),
|
||||
* 30 min TTL, engångsbruk; alla tidigare oanvända tokens ogiltigförklaras
|
||||
* när en ny begärs. Svaret på forgot-password avslöjar aldrig om kontot finns.
|
||||
*/
|
||||
export const passwordResetTokens = pgTable(
|
||||
"password_reset_tokens",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
userId: uuid("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
tokenHash: text("token_hash").notNull(),
|
||||
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
|
||||
usedAt: timestamp("used_at", { withTimezone: true }),
|
||||
createdAt: createdAt(),
|
||||
},
|
||||
(t) => [
|
||||
index("password_reset_tokens_user_idx").on(t.userId),
|
||||
uniqueIndex("password_reset_tokens_hash_unique").on(t.tokenHash),
|
||||
],
|
||||
);
|
||||
|
||||
/** Roterande refresh-tokens, lagrade hashade (spec §56: säker tokenhantering). */
|
||||
export const refreshTokens = pgTable(
|
||||
"refresh_tokens",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
userId: uuid("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
tokenHash: text("token_hash").notNull(),
|
||||
familyId: uuid("family_id").notNull(),
|
||||
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
|
||||
revokedAt: timestamp("revoked_at", { withTimezone: true }),
|
||||
replacedByTokenId: uuid("replaced_by_token_id"),
|
||||
userAgent: text("user_agent"),
|
||||
ip: text("ip"),
|
||||
createdAt: createdAt(),
|
||||
},
|
||||
(t) => [
|
||||
index("refresh_tokens_user_idx").on(t.userId),
|
||||
uniqueIndex("refresh_tokens_hash_unique").on(t.tokenHash),
|
||||
],
|
||||
);
|
||||
|
||||
/**
|
||||
* Hälsoprofil – logiskt och behörighetsmässigt separerad från hushållsdata
|
||||
* (spec §7, §56). Delas aldrig med andra hushållsmedlemmar.
|
||||
*/
|
||||
export const userHealthProfiles = pgTable("user_health_profiles", {
|
||||
userId: uuid("user_id")
|
||||
.primaryKey()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
birthYear: integer("birth_year"),
|
||||
sex: sexEnum("sex"),
|
||||
heightCm: doublePrecision("height_cm"),
|
||||
weightKg: doublePrecision("weight_kg"),
|
||||
targetWeightKg: doublePrecision("target_weight_kg"),
|
||||
activityLevel: activityLevelEnum("activity_level").notNull().default("moderate"),
|
||||
trainingSessionsPerWeek: integer("training_sessions_per_week"),
|
||||
trainingTypes: text("training_types").array().notNull().default([]),
|
||||
updatedAt: updatedAt(),
|
||||
});
|
||||
|
||||
export const userPreferences = pgTable("user_preferences", {
|
||||
userId: uuid("user_id")
|
||||
.primaryKey()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
primaryGoal: goalTypeEnum("primary_goal"),
|
||||
goals: goalTypeEnum("goals").array().notNull().default([]),
|
||||
dietPattern: dietPatternEnum("diet_pattern").notNull().default("omnivore"),
|
||||
religiousRule: religiousRuleEnum("religious_rule").notNull().default("none"),
|
||||
/** Deterministisk allergifiltrering utgår härifrån (spec §61.2). */
|
||||
allergens: allergenEnum("allergens").array().notNull().default([]),
|
||||
intolerances: text("intolerances").array().notNull().default([]),
|
||||
avoidIngredientIds: text("avoid_ingredient_ids").array().notNull().default([]),
|
||||
favoriteCuisines: cuisineEnum("favorite_cuisines").array().notNull().default([]),
|
||||
dislikedDishes: text("disliked_dishes").array().notNull().default([]),
|
||||
spiceLevelMax: integer("spice_level_max").notNull().default(3),
|
||||
weeklyBudgetMinor: integer("weekly_budget_minor"),
|
||||
maxCookingMinutesWeekday: integer("max_cooking_minutes_weekday"),
|
||||
equipment: text("equipment").array().notNull().default([]),
|
||||
defaultPortions: integer("default_portions").notNull().default(2),
|
||||
updatedAt: updatedAt(),
|
||||
});
|
||||
|
||||
/** Separata samtycken (spec §33): personlig funktion ≠ anonymiserad förbättring ≠ bildträning. */
|
||||
export const userConsents = pgTable(
|
||||
"user_consents",
|
||||
{
|
||||
userId: uuid("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
kind: consentKindEnum("kind").notNull(),
|
||||
status: consentStatusEnum("status").notNull(),
|
||||
grantedAt: timestamp("granted_at", { withTimezone: true }),
|
||||
revokedAt: timestamp("revoked_at", { withTimezone: true }),
|
||||
updatedAt: updatedAt(),
|
||||
},
|
||||
(t) => [primaryKey({ columns: [t.userId, t.kind] })],
|
||||
);
|
||||
Reference in New Issue
Block a user