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:
+21
-15
@@ -32,8 +32,8 @@
|
||||
| Kategoritilldelning i admin-UI | ✅ Klart |
|
||||
| Taggning av produkter | ✅ Klart |
|
||||
| Näringsvärden på produkter | ✅ Klart (schema + API) |
|
||||
| Seed produktdata med kategoritilldelning | ✅ Klart (seed_all.sql) |
|
||||
| Användarspecifika produkter (UserProduct) | ⚠️ Schema klart, UI basic |
|
||||
| Seed produktdata med kategoritilldelning | ✅ Ersatt — seed innehåller nu enbart kategorier (2026-05-02) |
|
||||
| Användarspecifika produkter (UserProduct) | ✅ Klart (2026-05-02) — `Product.ownerId` obligatorisk, globala produkter borttagna |
|
||||
| Användarroller (user / admin) | ✅ Klart |
|
||||
| Användarhantering i admin-UI | ✅ Klart |
|
||||
| Profilsida med flikar (Min profil / Användare / Databas med undertabbar) | ✅ Klart |
|
||||
@@ -54,34 +54,40 @@
|
||||
| Avancerad AI-integration (veckoplanering, receptförslag) | ❌ 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
|
||||
- **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 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.
|
||||
- **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.
|
||||
|
||||
### Kända begränsningar
|
||||
- Kvittoimport (Fas 6b) är påbörjad men granskningssteg och bulk-spara återstår.
|
||||
- Bildimport kräver att containrar är uppdaterade med senaste kod — kontrollera att diagnostikloggar syns vid felsökning.
|
||||
- Kvittoimport: användare utan egna produkter får inga produktmatchningar — enbart kategorisuggestioner via regler/AI (förväntat beteende, byggs upp allt eftersom).
|
||||
- `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.
|
||||
|
||||
---
|
||||
|
||||
## Nästa steg
|
||||
|
||||
1. Kvittoimport steg 2: persistenta förpackningsfält i inventarie (packCount, packSizeQuantity, packSizeUnit) + visning/redigering i inventory-UI.
|
||||
2. Inför hybrid alias-modell för kvittoimport: user-scope alias som standard + global alias som admin-verifierad fallback.
|
||||
3. Uppdatera backend-matchordning för alias: user-alias -> global alias -> poängbaserat namnförslag -> AI-kategori.
|
||||
4. Implementera automatisk alias-inlärning vid manuell korrigering i importflödet (först user-scope).
|
||||
5. Deploy och smoke-test av kvittoimportflödet på server.
|
||||
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: '`).
|
||||
7. Smoke-test på testdomän och avstämning.
|
||||
8. Planera och påbörja avancerad AI-integration och EAN-skanning.
|
||||
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 user-scope för `ReceiptAlias` (lägg till `userId`, unika index, admin-godkänd global fallback).
|
||||
3. Implementera automatisk alias-inlärning vid manuell korrigering i importflödet.
|
||||
4. Kvittoimport steg 2: persistenta förpackningsfält i inventarie (packCount, packSizeQuantity, packSizeUnit) + visning/redigering i inventory-UI.
|
||||
5. Lokalisera `receipt_import_tab.dart` och `swipeable_inventory_tile.dart`.
|
||||
6. Smoke-test på testdomän och avstämning.
|
||||
7. Planera och påbörja avancerad AI-integration och EAN-skanning.
|
||||
|
||||
## Beslut 2026-05-02 - Aliasstrategi för kvittoimport
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
- **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;
|
||||
@@ -48,8 +48,8 @@ model Product {
|
||||
receiptAliases ReceiptAlias[]
|
||||
tags ProductTag[]
|
||||
nutrition Nutrition?
|
||||
ownerId Int?
|
||||
owner User? @relation(fields: [ownerId], references: [id], onDelete: SetNull)
|
||||
ownerId Int
|
||||
owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade)
|
||||
userProducts UserProduct[]
|
||||
categoryId Int?
|
||||
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';
|
||||
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,
|
||||
) {}
|
||||
|
||||
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
|
||||
const rawItems = await this.parseReceiptViaImporter(file);
|
||||
|
||||
// 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
|
||||
if (isPremium) {
|
||||
@@ -110,15 +110,21 @@ export class ReceiptImportService {
|
||||
|
||||
private async matchProducts(
|
||||
items: ParsedReceiptItem[],
|
||||
userId?: number,
|
||||
): 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([
|
||||
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({
|
||||
where: { isActive: true },
|
||||
select: { id: true, name: true, canonicalName: true },
|
||||
where: productFilter,
|
||||
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)
|
||||
const alias = aliases.find((a) => a.receiptName === raw);
|
||||
if (alias) {
|
||||
const cat = alias.product.categoryRef;
|
||||
return {
|
||||
...item,
|
||||
matchedProductId: alias.product.id,
|
||||
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)
|
||||
const suggestion = this.findWordMatch(raw, products);
|
||||
if (!suggestion) {
|
||||
return { ...item };
|
||||
}
|
||||
const cat = suggestion.categoryRef;
|
||||
return {
|
||||
...item,
|
||||
suggestedProductId: suggestion?.id,
|
||||
suggestedProductName: suggestion
|
||||
? (suggestion.canonicalName ?? suggestion.name)
|
||||
: undefined,
|
||||
suggestedProductId: suggestion.id,
|
||||
suggestedProductName: suggestion.canonicalName ?? suggestion.name,
|
||||
...(cat ? { categorySuggestion: { categoryId: cat.id, categoryName: cat.name, path: cat.path, confidence: 'medium' as const, usedFallback: false } } : {}),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private findWordMatch(
|
||||
raw: string,
|
||||
products: { id: number; name: string; canonicalName: string | null }[],
|
||||
): { id: number; name: string; canonicalName: string | null } | undefined {
|
||||
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; categoryId: number | null; categoryRef: { id: number; name: string; path: string } | null } | undefined {
|
||||
// Dela upp kvittonamnet i ord (min 3 tecken)
|
||||
const rawWords = tokenize(raw);
|
||||
if (rawWords.length === 0) return undefined;
|
||||
@@ -229,7 +240,8 @@ export class ReceiptImportService {
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
let categories: Awaited<ReturnType<CategoriesService['findFlattened']>>;
|
||||
|
||||
+525
-1077
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user