feat(migration): enforce ownerId requirement in Product table

- Removed all products without an owner to maintain data integrity.
- Updated ownerId column to be non-nullable.
- Modified foreign key constraint for ownerId to use ON DELETE CASCADE.
This commit is contained in:
Nils-Johan Gynther
2026-05-02 19:05:33 +02:00
parent ec24f49836
commit 4e568b4d2e
7 changed files with 652 additions and 1108 deletions
+21 -15
View File
@@ -32,8 +32,8 @@
| Kategoritilldelning i admin-UI | ✅ Klart | | Kategoritilldelning i admin-UI | ✅ Klart |
| Taggning av produkter | ✅ Klart | | Taggning av produkter | ✅ Klart |
| Näringsvärden på produkter | ✅ Klart (schema + API) | | Näringsvärden på produkter | ✅ Klart (schema + API) |
| Seed produktdata med kategoritilldelning | ✅ Klart (seed_all.sql) | | Seed produktdata med kategoritilldelning | ✅ Ersatt — seed innehåller nu enbart kategorier (2026-05-02) |
| Användarspecifika produkter (UserProduct) | ⚠️ Schema klart, UI basic | | Användarspecifika produkter (UserProduct) | ✅ Klart (2026-05-02) — `Product.ownerId` obligatorisk, globala produkter borttagna |
| Användarroller (user / admin) | ✅ Klart | | Användarroller (user / admin) | ✅ Klart |
| Användarhantering i admin-UI | ✅ Klart | | Användarhantering i admin-UI | ✅ Klart |
| Profilsida med flikar (Min profil / Användare / Databas med undertabbar) | ✅ Klart | | Profilsida med flikar (Min profil / Användare / Databas med undertabbar) | ✅ Klart |
@@ -54,34 +54,40 @@
| Avancerad AI-integration (veckoplanering, receptförslag) | ❌ Planerad | | Avancerad AI-integration (veckoplanering, receptförslag) | ❌ Planerad |
| EAN-skanning via Open Food Facts API | ❌ Planerad | | EAN-skanning via Open Food Facts API | ❌ Planerad |
## Status — senast genomgånget: 2026-05-01 ## Status — senast genomgånget: 2026-05-02
### Nyheter och förbättringar ### Nyheter och förbättringar
- **Produkter user-scoped — ny databasarkitektur (2026-05-02)** — `Product.ownerId` är nu obligatorisk (non-nullable). Alla globala seed-produkter är borttagna. Varje produkt ägs av en enskild användare och raderas vid kontoradering (CASCADE). `seed_all.sql` innehåller nu enbart kategorier. Kvittoimportens matchning filtrerar på `ownerId = userId` från JWT. Se TEKNISK_BESKRIVNING.md för fullständig beskrivning.
- **Kategorier utökade (2026-05-02)** — Nya L2/L3-noder: `Bröd & Kakor > Kondis & fika > Kaffebröd` (wienerbröd, donuts, munkar m.m.) och `Dryck > Te & choklad > Te` (chai, vanilla chai, ceylon te m.m.). Nya L3-noder under `Mejeri, ost & ägg > Allergi mejeri`: Laktosfri mjölk, Filmjölk & Yoghurt, Kvarg & Cottage cheese, Matfett, Allergi matlagning.
- **Regelbaserad kategoridetektion utökad (2026-05-02)** — `ruleBasedCategorySuggestion()` täcker nu Te (te, tea, chai, tepas) och Kaffebröd (wienerbröd, donut, munk, croissant, kanelbulle, bakelse, semla m.fl.) utöver befintliga mejeri-regler.
- **AI-guardrail (2026-05-02)** — `AiService.suggestCategory()` remappar nu `low`/`medium`-konfidenspoäng till L1-föräldern istället för att returnera potentiellt fel L2/L3-kategori.
- **Förbättrad produktmatchning (2026-05-02)** — `findWordMatch` normaliserar nu diakritik (ä→a, ö→o, å→a) före jämförelse och tillåter enstaka stark partiell matchning för ord ≥5 tecken (löser t.ex. "vispgrädde" → produkt "grädde").
- **Kategorisuggest för matchade produkter (2026-05-02)** — `matchProducts()` läser nu in `categoryRef` för matchade produkter och sätter `categorySuggestion` direkt. `enrichWithAiCategories()` körs för alla items utan `categorySuggestion`, inte bara ej matchade.
- **Kvittoimport Fas 6b klar (2026-05-01)** — Flutter-granskningsflödet färdigt: per-rad checkbox, redigeringsdialog med destination-väljare (Inventarie/Baslager), merge-förhandsvisning, parallell laddning av inventarie och baslager, snackbar med separat räkning. - **Kvittoimport Fas 6b klar (2026-05-01)** — Flutter-granskningsflödet färdigt: per-rad checkbox, redigeringsdialog med destination-väljare (Inventarie/Baslager), merge-förhandsvisning, parallell laddning av inventarie och baslager, snackbar med separat räkning.
- **Kvittoimport Fas 6c klar (2026-05-01)** — Separering av AI-chip och produktsuggestions-chip, produktnamns-normalisering, och validering av AI-kategorier. - **Kvittoimport Fas 6c klar (2026-05-01)** — Separering av AI-chip och produktsuggestions-chip, produktnamns-normalisering, och validering av AI-kategorier.
- **Microservice-importer integrerad (2026-04-30)** — All import-logik (URL-skrapning, OCR, PDF-parsning, AI-kvittoparsning) delegeras nu till `importer-api` som körs som intern Docker-tjänst. `recipe-api` behåller Levenshtein-matchning, produktdatabas och AI-kategorisering. Se [migrering-MSI.md](migrering-MSI.md) för detaljer. - **Microservice-importer integrerad (2026-04-30)** — All import-logik (URL-skrapning, OCR, PDF-parsning, AI-kvittoparsning) delegeras nu till `importer-api` som körs som intern Docker-tjänst. `recipe-api` behåller produktmatchning och AI-kategorisering. Se [migrering-MSI.md](migrering-MSI.md) för detaljer.
- **User-scope för pantry och matplan** — Alla baslager- och matplansdata är nu per användare. Backend och Prisma-schema är migrerade. - **User-scope för pantry och matplan** — Alla baslager- och matplansdata är nu per användare. Backend och Prisma-schema är migrerade.
- **Robust bildimport** — Bild-URL normaliseras, laddas ner och optimeras i backend. Bilden kopplas till receptet och raderas vid delete. Diagnostikloggning på alla steg. - **Robust bildimport** — Bild-URL normaliseras, laddas ner och optimeras i backend. Bilden kopplas till receptet och raderas vid delete. Diagnostikloggning på alla steg.
- **Importflöde** — Quick-import och receipt-import har förbättrats med robust multipart-hantering, timeout, och felhantering. Markdown och bild-url skickas hela vägen till UI. - **Importflöde** — Quick-import och receipt-import har förbättrats med robust multipart-hantering, timeout, och felhantering.
- **Flutter-parity** — Matplan, inventarie, baslager och receptflöden är nu fullt migrerade till Flutter med user-scope och robust felhantering. - **Flutter-parity** — Matplan, inventarie, baslager och receptflöden är nu fullt migrerade till Flutter med user-scope och robust felhantering.
### Kända begränsningar ### Kända begränsningar
- Kvittoimport (Fas 6b) är påbörjad men granskningssteg och bulk-spara återstår. - Kvittoimport: användare utan egna produkter får inga produktmatchningar — enbart kategorisuggestioner via regler/AI (förväntat beteende, byggs upp allt eftersom).
- Bildimport kräver att containrar är uppdaterade med senaste kod — kontrollera att diagnostikloggar syns vid felsökning. - `ReceiptAlias` är ännu inte user-scoped (alias är fortfarande globala — se aliasstrategin nedan).
- `receipt_import_tab.dart` (~1 400 rader) och `swipeable_inventory_tile.dart` är ännu inte lokaliserade.
- Vissa adminfunktioner och avancerad AI-integration är planerade men ej migrerade. - Vissa adminfunktioner och avancerad AI-integration är planerade men ej migrerade.
--- ---
## Nästa steg ## Nästa steg
1. Kvittoimport steg 2: persistenta förpackningsfält i inventarie (packCount, packSizeQuantity, packSizeUnit) + visning/redigering i inventory-UI. 1. Bygg upp UI-flöde för att skapa/spara egna produkter vid kvittobekräftelse (POST /products med `ownerId` från JWT).
2. Inför hybrid alias-modell för kvittoimport: user-scope alias som standard + global alias som admin-verifierad fallback. 2. Inför user-scope för `ReceiptAlias` (lägg till `userId`, unika index, admin-godkänd global fallback).
3. Uppdatera backend-matchordning för alias: user-alias -> global alias -> poängbaserat namnförslag -> AI-kategori. 3. Implementera automatisk alias-inlärning vid manuell korrigering i importflödet.
4. Implementera automatisk alias-inlärning vid manuell korrigering i importflödet (först user-scope). 4. Kvittoimport steg 2: persistenta förpackningsfält i inventarie (packCount, packSizeQuantity, packSizeUnit) + visning/redigering i inventory-UI.
5. Deploy och smoke-test av kvittoimportflödet på server. 5. Lokalisera `receipt_import_tab.dart` och `swipeable_inventory_tile.dart`.
6. ✅ Flutter-lokalisering (ARB) — alla huvudskärmar klara (2026-05-02). Kvarstår: `receipt_import_tab.dart` (~1 400 rader) och `swipeable_inventory_tile.dart` (`'Bäst före: '`). 6. Smoke-test på testdomän och avstämning.
7. Smoke-test på testdomän och avstämning. 7. Planera och påbörja avancerad AI-integration och EAN-skanning.
8. Planera och påbörja avancerad AI-integration och EAN-skanning.
## Beslut 2026-05-02 - Aliasstrategi för kvittoimport ## Beslut 2026-05-02 - Aliasstrategi för kvittoimport
+60
View File
@@ -15,6 +15,66 @@ sker på remote server. Säkerställ att inga absoluta Windows-sökvägar anv
--- ---
## Nyheter och förbättringar (2026-05-02)
### Ny databasarkitektur: user-scoped produkter
Produkttabellen är omgjord till ett fullständigt user-scope-modell. Beslutet grundar sig på att en global produktkatalog skapade falska matchningar i kvittoimport för nya användare (produkter "hittades" fast användaren aldrig lagt till dem).
**Vad som ändrades:**
| Komponent | Förändring |
|---|---|
| `Product.ownerId` | `Int?``Int` (obligatorisk, non-nullable) |
| `Product.owner` | `onDelete: SetNull``onDelete: Cascade` |
| `db/seeds/seed_all.sql` | Innehåller nu enbart kategorier — inga `INSERT INTO Product` |
| Migration `20260502160000` | Raderar alla globala produkter (`ownerId IS NULL`), gör FK non-null |
| `receipt-import.service.ts` | `matchProducts(items, userId)` filtrerar på `ownerId = userId` |
| `receipt-import.controller.ts` | Extraherar `userId` från JWT och skickar till service |
**Flöde för nya användare:**
1. Kvittoimport → AI/OCR parsar kvitto → inga produktmatcher (user har inga produkter ännu)
2. Regelbaserad kategoridetektion + AI-kategorisering körs för alla rader
3. Användaren bekräftar i Flutter → produkten skapas via `POST /products` med `ownerId = userId`
4. Nästa kvittoimport med samma vara → alias/ordmatch hittar den user-ägda produkten
**Framtida mallar (planerat):** En mallhanterare i UI kan låta användare seeda sin produktkatalog från fördefinierade livsmedelsmallar utan att det kräver global data i databasen.
### Kategorisystem utökat
Nya noder sedan 2026-05-01:
| Nivå | Kategori |
|---|---|
| L2 under Bröd & Kakor | Kondis & fika |
| L3 under Kondis & fika | Kaffebröd (wienerbröd, donuts, munkar, kanelbullar m.m.) |
| L2 under Dryck | Te & choklad |
| L3 under Te & choklad | Te (chai, vanilla chai, ceylon te m.m.) |
| L3 under Allergi mejeri | Laktosfri mjölk |
| L3 under Allergi mejeri | Filmjölk & Yoghurt |
| L3 under Allergi mejeri | Kvarg & Cottage cheese |
| L3 under Allergi mejeri | Matfett |
| L3 under Allergi mejeri | Allergi matlagning |
### Regelbaserad kategoridetektion (`ruleBasedCategorySuggestion`)
Funktionen i `receipt-import.service.ts` matchar kvittonamn mot nyckelord och returnerar rätt kategori direkt — utan AI-anrop. Täcker:
- **Te** — `te`, `tea`, `chai`, `tepas`, `tepak`
- **Kaffebröd** — `wienerbrod`, `donut`, `munk`, `croissant`, `kanelbulle`, `bakelse`, `semla`, `dammsugare`, `kladdkaka`, `muffin`, `cupcake`, `chokladboll`
- **Allergi mejeri** — kombinationer av mejeri-markörer + allergen/växtbaserade markörer
### AI-guardrail
`AiService.suggestCategory()` remappar `low`/`medium`-konfidenspoäng till L1-föräldern istället för att returnera ett potentiellt fel L2/L3. Loggning sker via NestJS Logger.
### Förbättrad produktmatchning
`findWordMatch()` i `receipt-import.service.ts`:
- **Diakritiksnormalisering** — `normalizeToken()` konverterar å→a, ä→a, ö→o före jämförelse. Löser t.ex. `gradde` == `grädde`.
- **Enstaka lång partiell matchning** — ett produktord på ≥5 tecken som är en delmatchning räcker nu som stark signal. Löser t.ex. "Vispgrädde 5dl" → produkt "grädde".
---
## Nyheter och förbättringar (2026-04-30) ## Nyheter och förbättringar (2026-04-30)
- **Microservice-importer integrerad** — `importer-api` körs nu som intern Docker-tjänst i `recipe-app/compose.yml`. All URL-skrapning, OCR, PDF-parsning och AI-kvittoparsning delegeras dit. `recipe-api` behåller Levenshtein-matchning, produktdatabas och AI-kategorisering. Se [migrering-MSI.md](migrering-MSI.md) för fullständig lista över ändrade filer. - **Microservice-importer integrerad** — `importer-api` körs nu som intern Docker-tjänst i `recipe-app/compose.yml`. All URL-skrapning, OCR, PDF-parsning och AI-kvittoparsning delegeras dit. `recipe-api` behåller Levenshtein-matchning, produktdatabas och AI-kategorisering. Se [migrering-MSI.md](migrering-MSI.md) för fullständig lista över ändrade filer.
@@ -0,0 +1,17 @@
-- Steg 1: Ta bort alla produkter utan ägare (globala seed-produkter)
-- Detta tar automatiskt bort relaterade rader via ON DELETE CASCADE
-- för: InventoryItem, PantryItem, ReceiptAlias, ProductTag, Nutrition, UserProduct
DELETE FROM `Product` WHERE `ownerId` IS NULL;
-- Steg 2: Gör ownerId obligatoriskt
ALTER TABLE `Product`
MODIFY COLUMN `ownerId` INT NOT NULL;
-- Steg 3: Uppdatera foreign key constraint till CASCADE (ta bort gammal, lägg till ny)
ALTER TABLE `Product`
DROP FOREIGN KEY IF EXISTS `Product_ownerId_fkey`;
ALTER TABLE `Product`
ADD CONSTRAINT `Product_ownerId_fkey`
FOREIGN KEY (`ownerId`) REFERENCES `User`(`id`)
ON DELETE CASCADE ON UPDATE CASCADE;
+2 -2
View File
@@ -48,8 +48,8 @@ model Product {
receiptAliases ReceiptAlias[] receiptAliases ReceiptAlias[]
tags ProductTag[] tags ProductTag[]
nutrition Nutrition? nutrition Nutrition?
ownerId Int? ownerId Int
owner User? @relation(fields: [ownerId], references: [id], onDelete: SetNull) owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade)
userProducts UserProduct[] userProducts UserProduct[]
categoryId Int? categoryId Int?
categoryRef Category? @relation(fields: [categoryId], references: [id], onDelete: SetNull) categoryRef Category? @relation(fields: [categoryId], references: [id], onDelete: SetNull)
@@ -49,6 +49,7 @@ export class ReceiptImportController {
); );
} }
const isPremium = req?.user?.isPremium === true || req?.user?.role === 'admin'; const isPremium = req?.user?.isPremium === true || req?.user?.role === 'admin';
return this.receiptImportService.parseReceipt(file, isPremium); const userId = typeof req?.user?.id === 'number' ? req.user.id : undefined;
return this.receiptImportService.parseReceipt(file, isPremium, userId);
} }
} }
@@ -55,12 +55,12 @@ export class ReceiptImportService {
private readonly categoriesService: CategoriesService, private readonly categoriesService: CategoriesService,
) {} ) {}
async parseReceipt(file: Express.Multer.File, isPremium = false): Promise<ParsedReceiptItem[]> { async parseReceipt(file: Express.Multer.File, isPremium = false, userId?: number): Promise<ParsedReceiptItem[]> {
// Steg 1: Delegera AI-parsning till microservice-importer // Steg 1: Delegera AI-parsning till microservice-importer
const rawItems = await this.parseReceiptViaImporter(file); const rawItems = await this.parseReceiptViaImporter(file);
// Steg 2: Matchning mot produktdatabas (kräver DB — stannar i recipe-app) // Steg 2: Matchning mot produktdatabas (kräver DB — stannar i recipe-app)
const matched = await this.matchProducts(rawItems); const matched = await this.matchProducts(rawItems, userId);
// Steg 3: AI-kategorisering för premium-användare // Steg 3: AI-kategorisering för premium-användare
if (isPremium) { if (isPremium) {
@@ -110,15 +110,21 @@ export class ReceiptImportService {
private async matchProducts( private async matchProducts(
items: ParsedReceiptItem[], items: ParsedReceiptItem[],
userId?: number,
): Promise<ParsedReceiptItem[]> { ): Promise<ParsedReceiptItem[]> {
// Hämta alias och produkter parallellt // Hämta alias och produkter parallellt — filtrera på userId om angivet
const productFilter = userId ? { isActive: true, ownerId: userId } : { isActive: true };
const aliasFilter = userId
? { product: { ownerId: userId } }
: {};
const [aliases, products] = await Promise.all([ const [aliases, products] = await Promise.all([
this.prisma.receiptAlias.findMany({ this.prisma.receiptAlias.findMany({
select: { receiptName: true, productId: true, product: { select: { id: true, name: true, canonicalName: true } } }, where: aliasFilter,
select: { receiptName: true, productId: true, product: { select: { id: true, name: true, canonicalName: true, categoryId: true, categoryRef: { select: { id: true, name: true, path: true } } } } },
}), }),
this.prisma.product.findMany({ this.prisma.product.findMany({
where: { isActive: true }, where: productFilter,
select: { id: true, name: true, canonicalName: true }, select: { id: true, name: true, canonicalName: true, categoryId: true, categoryRef: { select: { id: true, name: true, path: true } } },
}), }),
]); ]);
@@ -129,29 +135,34 @@ export class ReceiptImportService {
// 1. Alias-match (säker, användaren behöver inte bekräfta) // 1. Alias-match (säker, användaren behöver inte bekräfta)
const alias = aliases.find((a) => a.receiptName === raw); const alias = aliases.find((a) => a.receiptName === raw);
if (alias) { if (alias) {
const cat = alias.product.categoryRef;
return { return {
...item, ...item,
matchedProductId: alias.product.id, matchedProductId: alias.product.id,
matchedProductName: alias.product.canonicalName ?? alias.product.name, matchedProductName: alias.product.canonicalName ?? alias.product.name,
...(cat ? { categorySuggestion: { categoryId: cat.id, categoryName: cat.name, path: cat.path, confidence: 'high' as const, usedFallback: false } } : {}),
}; };
} }
// 2. Ordbaserad matchning (förslag, kräver bekräftelse) // 2. Ordbaserad matchning (förslag, kräver bekräftelse)
const suggestion = this.findWordMatch(raw, products); const suggestion = this.findWordMatch(raw, products);
if (!suggestion) {
return { ...item };
}
const cat = suggestion.categoryRef;
return { return {
...item, ...item,
suggestedProductId: suggestion?.id, suggestedProductId: suggestion.id,
suggestedProductName: suggestion suggestedProductName: suggestion.canonicalName ?? suggestion.name,
? (suggestion.canonicalName ?? suggestion.name) ...(cat ? { categorySuggestion: { categoryId: cat.id, categoryName: cat.name, path: cat.path, confidence: 'medium' as const, usedFallback: false } } : {}),
: undefined,
}; };
}); });
} }
private findWordMatch( private findWordMatch(
raw: string, raw: string,
products: { id: number; name: string; canonicalName: string | null }[], products: { id: number; name: string; canonicalName: string | null; categoryId: number | null; categoryRef: { id: number; name: string; path: string } | null }[],
): { id: number; name: string; canonicalName: string | null } | undefined { ): { id: number; name: string; canonicalName: string | null; categoryId: number | null; categoryRef: { id: number; name: string; path: string } | null } | undefined {
// Dela upp kvittonamnet i ord (min 3 tecken) // Dela upp kvittonamnet i ord (min 3 tecken)
const rawWords = tokenize(raw); const rawWords = tokenize(raw);
if (rawWords.length === 0) return undefined; if (rawWords.length === 0) return undefined;
@@ -229,7 +240,8 @@ export class ReceiptImportService {
} }
private async enrichWithAiCategories(items: ParsedReceiptItem[]): Promise<ParsedReceiptItem[]> { private async enrichWithAiCategories(items: ParsedReceiptItem[]): Promise<ParsedReceiptItem[]> {
const unmatched = items.filter((i) => !i.matchedProductId && !i.suggestedProductId && i.rawName); // Kör regler/AI för alla items som saknar categorySuggestion och har ett rawName
const unmatched = items.filter((i) => !i.categorySuggestion && i.rawName);
if (unmatched.length === 0) return items; if (unmatched.length === 0) return items;
let categories: Awaited<ReturnType<CategoriesService['findFlattened']>>; let categories: Awaited<ReturnType<CategoriesService['findFlattened']>>;
+525 -1077
View File
File diff suppressed because it is too large Load Diff