a1a2c33427
This commit introduces a comprehensive shopping list feature with the following key changes: Backend: - Added ShoppingListItem model with relations to User, Product, and Category - Added new fields to FlyerSession for source file metadata - Added categoryId field to FlyerItem model - Implemented session source file retrieval endpoint - Added endpoint for updating flyer session items with category assignment - Added endpoint for planning flyer selections to shopping list - Implemented backfillCategoriesMine for AI-assisted category assignment - Added ShoppingListModule and integrated with FlyerSelectionModule Frontend: - Added ShoppingListScreen and navigation route - Implemented API paths and client methods for shopping list operations - Added category tree loading for shopping list item creation - Integrated shopping list functionality in flyer import tab - Added category backfill trigger in inventory screen - Updated FlyerImportItem model with categoryId support - Added methods for updating flyer session items and retrieving source files Database: - Added new Prisma migration for flyer source metadata and shopping list items - Updated schema with new relations and indexes The shopping list feature allows users to: 1. Plan flyer selections directly to their shopping list 2. View and manage their shopping list items 3. Update flyer session items with proper categorization 4. Retrieve original flyer source files 5. Automatically backfill categories for uncategorized products
386 lines
12 KiB
Plaintext
386 lines
12 KiB
Plaintext
generator client {
|
|
provider = "prisma-client-js"
|
|
}
|
|
|
|
datasource db {
|
|
provider = "mysql"
|
|
url = env("DATABASE_URL")
|
|
}
|
|
|
|
model User {
|
|
id Int @id @default(autoincrement())
|
|
username String @unique
|
|
email String @unique
|
|
firstName String?
|
|
lastName String?
|
|
passwordHash String
|
|
role String @default("user")
|
|
isPremium Boolean @default(false)
|
|
canShareRecipes Boolean @default(true)
|
|
aiEngineEnabled Boolean @default(false)
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
userProducts UserProduct[]
|
|
ownedRecipes Recipe[] @relation("RecipeOwner")
|
|
sharedRecipes RecipeShare[]
|
|
ownedProducts Product[]
|
|
inventoryItems InventoryItem[]
|
|
pantryItems PantryItem[]
|
|
mealPlanEntries MealPlanEntry[]
|
|
receiptAliases ReceiptAlias[]
|
|
unitMappings UnitMapping[]
|
|
flyerSessions FlyerSession[]
|
|
flyerSelections FlyerSelection[]
|
|
shoppingListItems ShoppingListItem[]
|
|
}
|
|
|
|
model Product {
|
|
id Int @id @default(autoincrement())
|
|
name String
|
|
normalizedName String @unique
|
|
canonicalName String?
|
|
isActive Boolean @default(true)
|
|
status String @default("active")
|
|
deletedAt DateTime?
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
inventoryItems InventoryItem[]
|
|
recipeIngredients RecipeIngredient[]
|
|
pantryItems PantryItem[]
|
|
receiptAliases ReceiptAlias[]
|
|
tags ProductTag[]
|
|
nutrition Nutrition?
|
|
ownerId Int
|
|
owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade)
|
|
userProducts UserProduct[]
|
|
categoryId Int?
|
|
categoryRef Category? @relation(fields: [categoryId], references: [id], onDelete: SetNull)
|
|
isPrivate Boolean @default(false)
|
|
unitMappings UnitMapping[]
|
|
shoppingListItems ShoppingListItem[]
|
|
}
|
|
|
|
model Category {
|
|
id Int @id @default(autoincrement())
|
|
name String
|
|
parentId Int?
|
|
parent Category? @relation("CategoryTree", fields: [parentId], references: [id], onDelete: SetNull)
|
|
children Category[] @relation("CategoryTree")
|
|
products Product[]
|
|
flyerItems FlyerItem[]
|
|
shoppingListItems ShoppingListItem[]
|
|
|
|
@@unique([name, parentId])
|
|
@@index([parentId])
|
|
}
|
|
|
|
model UserProduct {
|
|
id Int @id @default(autoincrement())
|
|
userId Int
|
|
productId Int
|
|
note String? @db.Text
|
|
preferredBrand String?
|
|
preferredStore String?
|
|
isPrivate Boolean @default(false)
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
|
|
|
|
@@unique([userId, productId])
|
|
@@index([userId])
|
|
@@index([productId])
|
|
}
|
|
|
|
model InventoryItem {
|
|
id Int @id @default(autoincrement())
|
|
userId Int
|
|
productId Int
|
|
quantity Decimal @db.Decimal(10, 2)
|
|
unit String
|
|
brand String?
|
|
origin String?
|
|
receiptName String?
|
|
location String?
|
|
purchaseDate DateTime?
|
|
opened Boolean?
|
|
suitableFor String?
|
|
bestBeforeDate DateTime?
|
|
comment String?
|
|
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
|
|
consumptions InventoryConsumption[]
|
|
|
|
@@index([userId])
|
|
@@index([productId])
|
|
}
|
|
|
|
model InventoryConsumption {
|
|
id Int @id @default(autoincrement())
|
|
inventoryItem InventoryItem @relation(fields: [inventoryItemId], references: [id])
|
|
inventoryItemId Int
|
|
amountUsed Decimal @db.Decimal(10, 2)
|
|
comment String?
|
|
createdAt DateTime @default(now())
|
|
}
|
|
|
|
model Recipe {
|
|
id Int @id @default(autoincrement())
|
|
name String
|
|
description String? @db.Text
|
|
instructions String? @db.Text
|
|
imageUrl String?
|
|
servings Int?
|
|
isPublic Boolean @default(false)
|
|
ownerId Int?
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
owner User? @relation("RecipeOwner", fields: [ownerId], references: [id], onDelete: SetNull)
|
|
ingredients RecipeIngredient[]
|
|
mealPlanEntries MealPlanEntry[]
|
|
shares RecipeShare[]
|
|
}
|
|
|
|
model RecipeShare {
|
|
recipeId Int
|
|
userId Int
|
|
recipe Recipe @relation(fields: [recipeId], references: [id], onDelete: Cascade)
|
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
|
|
@@id([recipeId, userId])
|
|
@@index([userId])
|
|
}
|
|
|
|
model RecipeIngredient {
|
|
id Int @id @default(autoincrement())
|
|
recipe Recipe @relation(fields: [recipeId], references: [id])
|
|
recipeId Int
|
|
product Product? @relation(fields: [productId], references: [id])
|
|
productId Int?
|
|
rawName String @default("")
|
|
rawLine String? @db.Text
|
|
quantity Decimal? @db.Decimal(10, 2)
|
|
unit String?
|
|
note String?
|
|
alternativeProductIds Json? // [id, id, ...] — alternativa produkter (t.ex. "ris eller couscous")
|
|
matchConfidence Float?
|
|
matchSource String?
|
|
analysisStatus String?
|
|
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
}
|
|
|
|
model PantryItem {
|
|
id Int @id @default(autoincrement())
|
|
userId Int
|
|
productId Int
|
|
location String?
|
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
@@unique([userId, productId])
|
|
@@index([userId])
|
|
}
|
|
|
|
model ReceiptAlias {
|
|
id Int @id @default(autoincrement())
|
|
receiptName String // normaliserat kvittonamn (lowercase, trim)
|
|
ownerId Int?
|
|
owner User? @relation(fields: [ownerId], references: [id], onDelete: Cascade)
|
|
isGlobal Boolean @default(false)
|
|
productId Int
|
|
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
|
|
createdAt DateTime @default(now())
|
|
|
|
@@unique([receiptName, ownerId, isGlobal])
|
|
@@index([ownerId])
|
|
@@index([isGlobal])
|
|
@@index([receiptName])
|
|
}
|
|
|
|
model MealPlanEntry {
|
|
id Int @id @default(autoincrement())
|
|
userId Int
|
|
date DateTime @db.Date
|
|
recipe Recipe @relation(fields: [recipeId], references: [id], onDelete: Cascade)
|
|
recipeId Int
|
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
servings Int?
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
// Bara ett recept per dag och anvandare.
|
|
@@unique([userId, date])
|
|
@@index([userId])
|
|
@@index([date])
|
|
}
|
|
|
|
model Tag {
|
|
id Int @id @default(autoincrement())
|
|
name String @unique
|
|
products ProductTag[]
|
|
}
|
|
|
|
model ProductTag {
|
|
productId Int
|
|
tagId Int
|
|
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
|
|
tag Tag @relation(fields: [tagId], references: [id], onDelete: Cascade)
|
|
|
|
@@id([productId, tagId])
|
|
@@index([tagId])
|
|
}
|
|
|
|
model Nutrition {
|
|
id Int @id @default(autoincrement())
|
|
productId Int @unique
|
|
calories Float?
|
|
protein Float?
|
|
fat Float?
|
|
carbohydrates Float?
|
|
salt Float?
|
|
sugar Float?
|
|
fiber Float?
|
|
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
|
|
}
|
|
|
|
model UnitMapping {
|
|
id Int @id @default(autoincrement())
|
|
productId Int
|
|
originalUnit String
|
|
preferredUnit String
|
|
userId Int
|
|
|
|
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
|
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
|
|
@@unique([productId, originalUnit, userId])
|
|
@@index([productId])
|
|
@@index([userId])
|
|
}
|
|
|
|
model HelpText {
|
|
id Int @id @default(autoincrement())
|
|
key String
|
|
scope String @default("default")
|
|
title String
|
|
content String @db.Text
|
|
isActive Boolean @default(true)
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
@@unique([key, scope])
|
|
@@index([key, isActive])
|
|
}
|
|
|
|
model FlyerSession {
|
|
id Int @id @default(autoincrement())
|
|
userId Int
|
|
retailer String
|
|
weekKey String
|
|
status String @default("draft")
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
expiresAt DateTime?
|
|
sourceFileName String?
|
|
sourceMimeType String?
|
|
sourceFileSize Int?
|
|
sourceStorageKey String?
|
|
sourceData Bytes?
|
|
|
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
items FlyerItem[]
|
|
selections FlyerSelection[]
|
|
|
|
@@index([userId])
|
|
@@index([weekKey])
|
|
@@index([status])
|
|
}
|
|
|
|
model FlyerItem {
|
|
id Int @id @default(autoincrement())
|
|
sessionId Int
|
|
rawName String
|
|
normalizedName String
|
|
categoryHint String?
|
|
categoryId Int?
|
|
price Decimal? @db.Decimal(10, 2)
|
|
priceUnit String?
|
|
comparisonPrice Decimal? @db.Decimal(10, 2)
|
|
comparisonUnit String?
|
|
offerText String?
|
|
parseConfidence Float
|
|
parseReasons Json?
|
|
matchedProductId Int?
|
|
matchedProductName String?
|
|
matchedVia String?
|
|
matchConfidence Float?
|
|
matchReasons Json?
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
session FlyerSession @relation(fields: [sessionId], references: [id], onDelete: Cascade)
|
|
categoryRef Category? @relation(fields: [categoryId], references: [id], onDelete: SetNull)
|
|
selections FlyerSelection[]
|
|
|
|
@@index([sessionId])
|
|
@@index([normalizedName])
|
|
@@index([categoryId])
|
|
}
|
|
|
|
model FlyerSelection {
|
|
id Int @id @default(autoincrement())
|
|
sessionId Int
|
|
itemId Int
|
|
userId Int
|
|
plannedQuantity Decimal? @db.Decimal(10, 2)
|
|
plannedUnit String?
|
|
priority String @default("normal")
|
|
note String?
|
|
status String @default("planned")
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
session FlyerSession @relation(fields: [sessionId], references: [id], onDelete: Cascade)
|
|
item FlyerItem @relation(fields: [itemId], references: [id], onDelete: Cascade)
|
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
|
|
@@unique([sessionId, itemId])
|
|
@@index([sessionId])
|
|
@@index([userId, status])
|
|
}
|
|
|
|
model ShoppingListItem {
|
|
id Int @id @default(autoincrement())
|
|
userId Int
|
|
name String
|
|
productId Int?
|
|
categoryId Int?
|
|
quantity Decimal? @db.Decimal(10, 2)
|
|
unit String?
|
|
source String @default("manual")
|
|
status String @default("open")
|
|
checkedAt DateTime?
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
product Product? @relation(fields: [productId], references: [id], onDelete: SetNull)
|
|
categoryRef Category? @relation(fields: [categoryId], references: [id], onDelete: SetNull)
|
|
|
|
@@index([userId, status])
|
|
@@index([productId, unit, status])
|
|
@@index([categoryId])
|
|
}
|