From 054a19ed7cb1e693a9efa872ae0d0d524286f13d Mon Sep 17 00:00:00 2001 From: Nils-Johan Gynther Date: Sun, 19 Apr 2026 10:34:21 +0200 Subject: [PATCH] MAJOR UPPDATE: "First Ai" feat: add AI categorization for products and enhance user management - Integrated AI service for category suggestions in receipt import and product management. - Added premium subscription feature for users with corresponding API endpoints. - Implemented admin interface for managing pending product suggestions. - Enhanced user management to include premium status and corresponding UI updates. - Updated database schema to support new fields for premium status and product status. --- AI-FUNKTIONER.md | 34 +++-- NEXT_STEPS.md | 20 ++- README.md | 13 +- TEKNISK_BESKRIVNING.md | 88 +++++++---- .../migration.sql | 8 + backend/prisma/schema.prisma | 2 + backend/src/ai/ai.module.ts | 8 + backend/src/ai/ai.service.ts | 128 ++++++++++++++++ backend/src/app.module.ts | 2 + backend/src/auth/auth.service.ts | 9 +- backend/src/auth/jwt.strategy.ts | 4 +- backend/src/categories/categories.service.ts | 17 +++ backend/src/products/products.controller.ts | 71 ++++++++- backend/src/products/products.module.ts | 3 + backend/src/products/products.service.ts | 23 +++ .../dto/parsed-receipt-item.dto.ts | 4 + .../receipt-import.controller.ts | 8 +- .../receipt-import/receipt-import.module.ts | 4 +- .../receipt-import/receipt-import.service.ts | 42 +++++- backend/src/users/users.controller.ts | 17 ++- backend/src/users/users.service.ts | 6 +- frontend/app/Navigation.tsx | 1 + .../app/admin/products/AdminProductList.tsx | 138 +++++++++++++++++- .../app/admin/products/EditProductForm.tsx | 70 +++++++-- frontend/app/admin/products/actions.ts | 66 +++++++++ .../pending/PendingProductsClient.tsx | 109 ++++++++++++++ frontend/app/admin/products/pending/page.tsx | 27 ++++ frontend/app/api/admin-users/[id]/route.ts | 16 ++ frontend/app/kvitto/ReceiptImportClient.tsx | 18 +++ frontend/app/profil/tabs/AnvandareClient.tsx | 38 ++++- 30 files changed, 917 insertions(+), 77 deletions(-) create mode 100644 backend/prisma/migrations/20260419000000_add_is_premium_and_product_status/migration.sql create mode 100644 backend/src/ai/ai.module.ts create mode 100644 backend/src/ai/ai.service.ts create mode 100644 frontend/app/admin/products/pending/PendingProductsClient.tsx create mode 100644 frontend/app/admin/products/pending/page.tsx diff --git a/AI-FUNKTIONER.md b/AI-FUNKTIONER.md index defb0b36..71f4cad0 100644 --- a/AI-FUNKTIONER.md +++ b/AI-FUNKTIONER.md @@ -2,23 +2,25 @@ > Se [README.md](README.md) för funktionsöversikt, [TEKNISK_BESKRIVNING.md](TEKNISK_BESKRIVNING.md) för teknisk arkitektur och [NEXT_STEPS.md](NEXT_STEPS.md) för prioriterade nästa steg. -Detta dokument beskriver de AI-funktioner som kommer att implementeras som **premium-funktioner** i **recipe-app**. Varje funktion är kopplad till en rekommenderad Mistral-modell, med fokus på att använda de enklaste och mest kostnadseffektiva alternativen. +Detta dokument beskriver de AI-funktioner som implementeras eller planeras som **premium-funktioner** i **recipe-app**. Varje funktion är kopplad till en rekommenderad Mistral-modell, med fokus på att använda de enklaste och mest kostnadseffektiva alternativen. --- ## Översikt över AI-funktioner - Funktion | Beskrivning | Rekommenderad Modell | - |-----------------------------------|-------------------------------------------------------------------------------------------------|----------------------------| - | **Receptförslag utifrån hemmalager** | AI analyserar användarens inventory och föreslår recept baserat på tillgängliga ingredienser. | `mistral-small-2603` | - | **Veckoplanering med AI** | AI genererar en veckoplan baserat på inventory, recept och användarpreferenser. | `mistral-small-2603` | - | **Smart inköpslista** | AI skapar en inköpslista baserat på saknade ingredienser och historisk förbrukning. | `mistral-small-2603` | - | **Automatisk kategorisering** | AI kategoriserar nya produkter i inventory baserat på namn och beskrivning. | `mistral-small-2603` | - | **"Vad ska jag laga idag?"** | AI ger snabba receptförslag baserat på vad användaren har hemma. | `mistral-tiny-2603` | - | **Enhetskonvertering** | AI hjälper till att konvertera enheter (t.ex. gram till msk) och hanterar osäkerheter. | `labs-leanstral-2603` | - | **AI-assisterad lageravräkning** | AI hjälper till att dra av rätt mängder från inventory när ett recept lagas. | `mistral-small-2603` | - | **Personliga matlagningsråd** | AI ger personliga tips baserat på användarens matlagningshistorik och inventory. | `mistral-tiny-2603` | - | **AI-assisterad import av PDF/länkar** | AI extraherar recept och prisdata från PDF-filer och länkar för att underlätta importen. | `mistral-small-2603` | - | **Kostnadseffektiv inköpslista** | AI skapar en kostnadseffektiv inköpslista baserat på inventory och aktuella butikspriser. | `mistral-small-2603` | + +| Funktion | Beskrivning | Modell | Status | +|---|---|---|---| +| **Automatisk kategorisering** | AI kategoriserar produkter baserat på namn mot systemets kategoriträd. Admins kör bulk-kategorisering; premium-användare får förslag per produkt. | `mistral-small-2603` | ✅ Klart | +| **Kvittoimport — kategorisuggestion** | Ej matchade kvittorader får ett AI-kategoriförslag för premium-användare, visas som ledtråd i gränssnittet. | `mistral-small-2603` | ✅ Klart | +| **Receptförslag utifrån hemmalager** | AI analyserar användarens inventory och föreslår recept baserat på tillgängliga ingredienser. | `mistral-small-2603` | ❌ Planerad | +| **Veckoplanering med AI** | AI genererar en veckoplan baserat på inventory, recept och användarpreferenser. | `mistral-small-2603` | ❌ Planerad | +| **Smart inköpslista** | AI skapar en inköpslista baserat på saknade ingredienser och historisk förbrukning. | `mistral-small-2603` | ❌ Planerad | +| **"Vad ska jag laga idag?"** | AI ger snabba receptförslag baserat på vad användaren har hemma. | `mistral-small-2603` | ❌ Planerad | +| **Enhetskonvertering** | AI hjälper till att konvertera enheter (t.ex. gram till msk) och hanterar osäkerheter. | `mistral-small-2603` | ❌ Planerad | +| **AI-assisterad lageravräkning** | AI hjälper till att dra av rätt mängder från inventory när ett recept lagas. | `mistral-small-2603` | ❌ Planerad | +| **Personliga matlagningsråd** | AI ger personliga tips baserat på användarens matlagningshistorik och inventory. | `mistral-small-2603` | ❌ Planerad | +| **AI-assisterad import av PDF/länkar** | AI extraherar recept och prisdata från PDF-filer och länkar för att underlätta importen. | `mistral-small-2603` | ❌ Planerad | +| **Kostnadseffektiv inköpslista** | AI skapar en kostnadseffektiv inköpslista baserat på inventory och aktuella butikspriser. | `mistral-small-2603` | ❌ Planerad | --- @@ -51,9 +53,9 @@ Detta dokument beskriver de AI-funktioner som kommer att implementeras som **pre ## Nästa steg -1. **Prioritera funktioner**: Börja med de funktioner som ger mest värde för användaren, i denna ordning **Automatiska kategorisering** och **Receptförslag utifrån hemmalager** samt **Vad ska jag laga idag?"**. -2. **Implementera validering**: Se till att AI-output alltid valideras mot strukturerade scheman. -3. **Testa och iterera**: Börja med enklare modeller och utvärdera resultatet innan du skalar upp. +1. **Receptförslag** — "Vad ska jag laga idag?" är nästa naturliga premium-funktion; bygger direkt på inventory och recept som redan finns i systemet. +2. **Veckoplanering med AI** — kräver att receptförslag fungerar; planera mot kampanjpriser kräver extern datakälla. +3. **Validering av AI-output** — säkerställ att AI-svar alltid valideras mot strukturerade scheman (t.ex. Zod) för datakvalitet. --- diff --git a/NEXT_STEPS.md b/NEXT_STEPS.md index 1de80827..313dacbb 100644 --- a/NEXT_STEPS.md +++ b/NEXT_STEPS.md @@ -6,7 +6,7 @@ --- -## Status — senast genomgånget: 2026-04-18 +## Status — senast genomgånget: 2026-04-19 | Funktion | Status | |---|---| @@ -37,7 +37,14 @@ | Profilsida med flikar (Min profil / Användare / Databas) | ✅ Klart | | Teknisk skuld — oanvända InventoryItem-fält | ✅ Klart (migration 20260418) | | Teknisk skuld — redirect-routes städade | ✅ Klart | -| Avancerad AI-integration (veckoplanering, kampanjdata) | ❌ Planerad | +| Premium-plan (isPremium på User, Free/Paid-dropdown) | ✅ Klart | +| AI-modul (AiService, Mistral-kategorisering, fallback) | ✅ Klart | +| Admin: AI-kategorisering per produkt ("Fråga AI") | ✅ Klart | +| Admin: AI-bulk-kategorisering av okategoriserade produkter | ✅ Klart | +| Produktstatus (pending / active / rejected) | ✅ Klart | +| Admin: Väntande produktförslag (pending-sida) | ✅ Klart | +| Kvittoimport — AI-kategorisuggestion för premium-användare | ✅ Klart | +| Avancerad AI-integration (veckoplanering, receptförslag) | ❌ Planerad | | EAN-skanning via Open Food Facts API | ❌ Planerad | --- @@ -117,10 +124,11 @@ Redan implementerat — `trim()` + max 100 tecken på alla fält i `actions.ts`. ### 5. Avancerad AI-integration **Mål:** Smarta receptförslag och veckoplanering baserat på inventarie och kampanjdata. -Nuvarande AI-funktionalitet (Mistral för kvittotolkning) är ett bra fundament. Nästa steg: -- Receptförslag utifrån vad som finns hemma ("Vad ska jag laga idag?") -- Veckoplanering med hänsyn till kampanjpriser (kräver extern datakälla) -- Kräver: tydlig API-design, kostnadskontroll och eventuellt modellval per use-case +AI-infrastrukturen är nu på plats (`AiService`, `mistral-small-2603`, premium-plan). Kategorisering för produkter och kvittoimport är implementerat. Nästa steg: + +- **"Vad ska jag laga idag?"** — Receptförslag baserat på vad som finns i inventariet; kan byggas direkt ovanpå befintliga inventory- och recipe-endpoints +- **Veckoplanering med AI** — Generera ett veckoschemat baserat på inventarie, preferenser och ev. kampanjpriser (kräver extern datakälla) +- Kräver: tydlig API-design, kostnadskontroll och modellval per use-case --- diff --git a/README.md b/README.md index 6e5a96d9..4c23962e 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,7 @@ En fullstack-applikation för hantering av hemmavaror och recept. Håll koll på - **AI-tolkning via Mistral** — Mistral AI extraherar varunamn och mängder direkt från kvittobilden - **Alias-matchning** — kvittots produktnamn matchas mot kända alias (t.ex. "ICA Kvarg Jordg" → "Kvarg") och mot befintliga produkter - **Granska och lägg till** — se tolkningsresultatet, justera kvantitet och enhet, och lägg till direkt i inventariet +- **AI-kategorisuggestion (premium)** — för varor som inte matchas mot befintliga produkter visas ett AI-förslag på kategori (t.ex. "✨ Mejeri och ägg > Kvarg och fil") som hjälp när användaren väljer produkt manuellt ### Baslager - **Ständigt lager** — markera produkter du alltid räknar med att ha hemma @@ -46,7 +47,9 @@ En fullstack-applikation för hantering av hemmavaror och recept. Håll koll på ### Admin: Produkter - **Redigera produkter** — uppdatera visningsnamn, canonical name, kategori (hierarkisk dropdown) och varumärke inline direkt i listan - **Kategoritilldelning** — välj kategori ur ett 3-nivåträd (huvudkategori → underkategori → typ) som laddas dynamiskt från API:et -- **Bulk-kategorisering** — filtrera fram okategoriserade produkter, markera flera (eller "välj alla synliga") och sätt kategori på alla markerade på en gång — effektivt för att kategorisera många produkter i ett svep +- **Bulk-kategorisering** — filtrera fram okategoriserade produkter, markera flera (eller "välj alla synliga") och sätt kategori på alla markerade på en gång +- **AI-kategorisering per produkt** — klicka "✨ Fråga AI" bredvid kategori-dropdown för att få ett AI-förslag med säkerhetsindikation (hög/medel/låg); godkänn eller avfärda med ett klick +- **AI-bulk-kategorisering** — knappen "✨ AI-kategorisera okategoriserade" analyserar alla produkter utan kategori via Mistral AI och presenterar ett bekräftelsemodal; admin väljer vilka förslag som ska tillämpas - **Hitta dubbletter** — identifiera produkter med samma normaliserade namn - **Slå ihop produkter** — merge av två produktposter: alla inventarieföremål och receptreferenser flyttas till målprodukten, källan soft-deleteras - **Förhandsvisning** — granska vad som händer (inventarieräkningar, utfall) innan merge genomförs @@ -55,6 +58,10 @@ En fullstack-applikation för hantering av hemmavaror och recept. Håll koll på > Obs: Destruktiva åtgärder (merge, ta bort, återställ, bulk-uppdatera, återställ all data) kräver admin-roll. +### Väntande produktförslag +- **Produktförslags-kö** — produkter med status `pending` samlas på sidan `/admin/products/pending` (länk "⏳ Förslag" i navigeringen) +- **Godkänn eller avvisa** — admin kan godkänna (status → `active`) eller avvisa (status → `rejected`) varje förslag med ett klick + ### Användarprofil och administration (fliksida) Profilsidan `/profil` är en flikbaserad administrationsyta. Antalet flikar beror på rollen: @@ -65,6 +72,7 @@ Profilsidan `/profil` är en flikbaserad administrationsyta. Antalet flikar bero - **Användare** — fullständig användarhantering: - Skapa ny användare (användarnamn, e-post, lösenord, roll) - Ändra roll via dropdown direkt i tabellen + - Ändra **plan** (Free / Paid ✨) via dropdown — styr tillgång till premium-AI-funktioner - Ändra e-postadress inline - Återställ lösenord — genererar ett tillfälligt lösenord och visar ett kopierings-redo meddelande - Ta bort användare (skyddad: kan inte ta bort sig själv) @@ -72,10 +80,11 @@ Profilsidan `/profil` är en flikbaserad administrationsyta. Antalet flikar bero ### Autentisering och roller - **Rollbaserad åtkomstkontroll** — systemet har två roller: `user` (standard) och `admin` +- **Premium-plan** — `isPremium`-flagga på användaren styr tillgång till AI-funktioner (kvittoimport AI-hints m.fl.); administreras via Plan-kolumnen i användarhanteringen - **Automatisk bootstrap** — fyra användare skapas eller uppdateras automatiskt när backend startar, baserat på miljövariabler: - `Nadmin` (admin), `Padmin` (admin), `user1` (user), `user2` (user) - **Skyddade admin-endpoints** — destruktiva produkt-endpoints och all användarhantering kräver `admin`-roll; försök utan rätt roll ger 403 Förbjuden -- **Navigering** — admin-länkarna "👥 Användare" och admin-flikarna i profilen visas enbart för inloggade administratörer +- **Navigering** — admin-länkarna "⚙️ Admin", "⏳ Förslag" och "👥 Användare" visas enbart för inloggade administratörer --- diff --git a/TEKNISK_BESKRIVNING.md b/TEKNISK_BESKRIVNING.md index 1374a8cc..fe888603 100644 --- a/TEKNISK_BESKRIVNING.md +++ b/TEKNISK_BESKRIVNING.md @@ -66,13 +66,13 @@ docker exec recipe-db mariadb -uroot -p"LÖSENORD" recipe_app -e "SHOW TABLES;" | Sida | Fil | Funktionalitet | |------|-----|---| | **Hem** | `app/page.tsx` | Startsida | -| **Navigering** | `app/Navigation.tsx` | Huvudmeny, inloggad användare, länk till profil; länkarna "⚙️ Admin" och "👥 Användare" visas enbart om sessionens roll är `admin` | +| **Navigering** | `app/Navigation.tsx` | Huvudmeny, inloggad användare, länk till profil; länkarna "⚙️ Admin", "⏳ Förslag" och "👥 Användare" visas enbart om sessionens roll är `admin` | | **Inloggning** | `app/login/page.tsx` | Inloggningssida med Auth.js Credentials | | **Profil** | `app/profil/page.tsx` | Flikbaserad profil-/adminsida (server component): läser `?tab=`-param, kontrollerar admin-roll via `auth()`, laddar rätt flik dynamiskt | | | `app/profil/ProfileTabs.tsx` | Klientkomponent: fliknavigering med Link-basad URL-routing (`?tab=profil\|anvandare\|databas`) | | | `app/profil/tabs/MinProfilTab.tsx` | Profilformulär (förnamn, efternamn, e-post) | | | `app/profil/tabs/AnvandareTab.tsx` | Server component: hämtar användarlista, renderar AnvandareClient | -| | `app/profil/tabs/AnvandareClient.tsx` | Klientkomponent: skapa/ta bort användare, rollbyte, e-postbyte, lösenordsåterställning med kopierings-modal | +| | `app/profil/tabs/AnvandareClient.tsx` | Klientkomponent: skapa/ta bort användare, rollbyte, plan-byte (Free/Paid ✨ → isPremium), e-postbyte, lösenordsåterställning med kopierings-modal | | | `app/profil/tabs/DatabsTab.tsx` | Server component: produktdatabas (importerar admin/products-komponenter) | | **Inventorie** | `app/inventory/page.tsx` | Lista, filtrera, sortera varor | | | `InventoryList.tsx` | Ritning av inventarieföremål | @@ -96,11 +96,13 @@ docker exec recipe-db mariadb -uroot -p"LÖSENORD" recipe_app -e "SHOW TABLES;" | | `app/import/ImportTabsClient.tsx` | Klientkomponent: kvitto/recept-flikar | | **Admin: Användare** | `app/admin/users/page.tsx` | Redirect till `/profil?tab=anvandare` | | **Admin: Produkter** | `app/admin/products/page.tsx` | Produktadmin-panel | -| | `AdminProductList.tsx` | Lista produkter, sök, sortera, filter okategoriserade, bulk-select + bulk-kategorisering | -| | `EditProductForm.tsx` | Inline redigering: name, canonicalName, kategori (hierarkisk dropdown), brand, taggar | +| | `AdminProductList.tsx` | Lista produkter, sök, sortera, filter okategoriserade, bulk-select + bulk-kategorisering; AI-bulk-knapp ("✨ AI-kategorisera") med bekräftelsemodal | +| | `EditProductForm.tsx` | Inline redigering: name, canonicalName, kategori (hierarkisk dropdown), brand, taggar; "✨ Fråga AI"-knapp med suggestion-chip (grön = hög, gul = fallback) | | | `ResetProductsButton.tsx` | Knapp för att rensa all produktdata | | | `MergePreviewForm.tsx` | Förhandsgranska merge | -| | `actions.ts` | Server actions: updateProduct, deleteProduct, resetAllProducts, bulkSetCategory | +| | `actions.ts` | Server actions: updateProduct, deleteProduct, resetAllProducts, bulkSetCategory, suggestProductCategory, suggestBulkCategories, setProductStatus | +| **Admin: Väntande produkter** | `app/admin/products/pending/page.tsx` | Server component, auth-skyddad, hämtar pending-produkter | +| | `PendingProductsClient.tsx` | Tabell: Produkt / Kategori (AI) / Föreslagen av / Datum / Åtgärd; "✓ Godkänn" / "✕ Avvisa"-knappar | | **Baslager** | `app/baslager/page.tsx` | Visa och hantera baslager (server component) | | | `AddToPantryForm.tsx` | Lägg till produkt i baslager (dropdown) | | | `PantryList.tsx` | Visa baslager grupperat per kategori | @@ -113,7 +115,7 @@ Alla proxy-routes läser auth-token via `auth()` (Auth.js v5) och vidarebefordra | Route | Metod | Syfte | |-------|-------|-------| | `/api/admin-users` | GET, POST | Hämtar alla användare / skapar ny användare (kräver admin-roll i session) | -| `/api/admin-users/[id]` | PATCH, DELETE, PUT | Ändrar roll / tar bort användare / byter e-post (kräver admin-roll i session) | +| `/api/admin-users/[id]` | PATCH, DELETE, PUT | Ändrar roll / tar bort användare / byter e-post; om body innehåller `isPremium` → anropar `PATCH /api/users/:id/premium` (kräver admin-roll i session) | | `/api/admin-users/[id]/reset-password` | POST | Återställer lösenord och returnerar tillfälligt lösenord + meddelandetext (kräver admin-roll) | | `/api/auth/[...nextauth]` | GET, POST | Auth.js handlers (login, logout, session) | | `/api/products` | GET | Produktlista (auth-wrappat med `auth(req)`) | @@ -166,27 +168,31 @@ backend/src/ ├── main.ts # Startpunkt (port 8080, global prefix "api") ├── auth/ │ ├── auth.controller.ts # POST /api/auth/login, POST /api/auth/register -│ ├── auth.service.ts # validateUser, login (JWT-signering inkl. role) +│ ├── auth.service.ts # validateUser, login (JWT-signering inkl. role + isPremium) │ ├── auth.module.ts -│ ├── jwt.strategy.ts # Passport JWT-strategi (returnerar userId, username, role) +│ ├── jwt.strategy.ts # Passport JWT-strategi (returnerar userId, username, role, isPremium) │ ├── jwt-auth.guard.ts # Global guard (skyddar allt utom @Public) │ ├── roles.guard.ts # Guard som kontrollerar @Roles() metadata; kastar 403 │ └── decorators/ │ ├── public.decorator.ts # @Public() – markerar öppen endpoint -│ ├── current-user.decorator.ts # @CurrentUser() – extraherar {userId, username, role} +│ ├── current-user.decorator.ts # @CurrentUser() – extraherar {userId, username, role, isPremium} │ └── roles.decorator.ts # @Roles('admin') – sätter rollkrav via SetMetadata +├── ai/ +│ ├── ai.service.ts # AiService: suggestCategory() — anropar Mistral API med kategorikontext +│ └── ai.module.ts # Exporterar AiService (importeras av ProductsModule, ReceiptImportModule) ├── users/ │ ├── users.controller.ts # GET /api/users/me, PATCH /api/users/me │ │ # GET /api/users (admin), PATCH /api/users/:id/role (admin) │ │ # POST /api/users (admin), DELETE /api/users/:id (admin) │ │ # POST /api/users/:id/reset-password (admin), PATCH /api/users/:id/email (admin) +│ │ # PATCH /api/users/:id/premium (admin) │ ├── users.service.ts # findByUsername, findById, create, updateProfile -│ │ # findAll, setRole, adminCreate, deleteUser, resetPassword, updateEmail +│ │ # findAll, setRole, adminCreate, deleteUser, resetPassword, updateEmail, setPremium │ ├── admin-bootstrap.service.ts # OnApplicationBootstrap: skapar/uppdaterar 4 seed-användare │ └── users.module.ts ├── categories/ │ ├── categories.controller.ts # GET /api/categories, GET /api/categories/tree (@Public) -│ ├── categories.service.ts # findAll (flat), findTree (hierarkisk) +│ ├── categories.service.ts # findAll (flat), findTree (hierarkisk), findFlattened (med full path) │ └── categories.module.ts ├── common/ │ ├── filters/ @@ -210,8 +216,12 @@ backend/src/ │ └── prisma.module.ts ├── products/ │ ├── products.controller.ts # CRUD, merge, duplicates, reset-all -│ ├── products.service.ts # Produktlogik inkl. resetAll() -│ ├── products.module.ts +│ │ # GET /products/pending (admin) +│ │ # GET /products/:id/suggest-category (premium/admin) +│ │ # POST /products/ai-categorize-bulk (admin) +│ │ # PATCH /products/:id/status (admin) +│ ├── products.service.ts # Produktlogik inkl. resetAll(), findPending(), setStatus() +│ ├── products.module.ts # Importerar AiModule + CategoriesModule │ └── dto/ │ ├── create-product.dto.ts │ ├── update-product.dto.ts @@ -232,10 +242,12 @@ backend/src/ │ └── dto/ │ └── create-meal-plan-entry.dto.ts # { date, recipeId, servings? } ├── receipt-import/ -│ ├── receipt-import.controller.ts # POST /api/receipt-import (multipart) -│ ├── receipt-import.service.ts # Mistral AI-anrop, bildtolkning +│ ├── receipt-import.controller.ts # POST /api/receipt-import (multipart, kräver JWT) +│ │ # Skickar isPremium till service (premium/admin → AI-enrichment) +│ ├── receipt-import.service.ts # Mistral AI-anrop, bildtolkning, enrichWithAiCategories() +│ ├── receipt-import.module.ts # Importerar AiModule + CategoriesModule │ └── dto/ -│ └── parsed-receipt-item.dto.ts +│ └── parsed-receipt-item.dto.ts # Inkl. categorySuggestion?: CategorySuggestion ├── receipt-alias/ │ ├── receipt-alias.controller.ts # CRUD /api/receipt-alias │ ├── receipt-alias.service.ts @@ -331,10 +343,18 @@ backend/src/ - **`inventoryCompare(from, to)`** — Kör samma aggregering som `shoppingList` men jämför sedan varje ingrediens mot aktuellt inventarielager. Returnerar status per ingrediens: `räcker | saknas | enhetskonflikt`. **Kvittoimport-API:** -- **`parseReceipt(file)`** — Tar emot en bildel eller PDF (max 15 MB), skickar den till Mistral AI för tolkning och returnerar en lista av kandidatprodukter med namn, kvantitet och enhet. +- **`parseReceipt(file, isPremium)`** — Tar emot en bild eller PDF (max 15 MB), skickar den till Mistral AI för tolkning och returnerar en lista av kandidatprodukter med namn, kvantitet och enhet. - Alias-matchning: före returneringen slås varje rånamn upp mot `ReceiptAlias`-tabellen och mot `Product.normalizedName`. Träffar kopplas automatiskt till rätt produkt-ID. +- **AI-kategorisuggestion (premium):** Om `isPremium = true` (eller admin) och en vara varken alias-matchas eller ordbaserat matchas anropas `AiService.suggestCategory()`. Svaret inkluderas som `categorySuggestion` i retur-DTO:n och visas som ett lila "✨"-chip i frontend. - Stödda MIME-typer: `image/jpeg`, `image/png`, `image/webp`, `image/heic`, `image/heif`, `application/pdf` +**AI-API (`AiService`):** +- **`suggestCategory(productName, categories: FlatCategory[])`** — Skickar produktnamnet och en flat lista av alla kategorier (med full sökväg, t.ex. "Mejeri och ägg > Mjölk och grädde") till Mistral API (`mistral-small-2603`). Returnerar `CategorySuggestion`. +- **Svar-typ `CategorySuggestion`:** `{ categoryId, categoryName, path, confidence: 'high'|'medium'|'low', usedFallback: boolean }` +- **Fallback-strategi:** Om AI returnerar ett ogiltigt kategori-ID eller om anropet misslyckas: försök hitta "Övrigt"-underkategori → fallback till rot-"Övrigt" (id 221) med `confidence: 'low'` och `usedFallback: true` +- **Integration:** `AiModule` exporterar `AiService` och importeras av `ProductsModule` och `ReceiptImportModule` +- **CategoriesService tillägg:** `findFlattened()` bygger en flat lista med `{ id, name, path }` där `path` är den fullständiga kategorivägen (används som kontext till Mistral) + --- ## API-endpoints (fullständig lista) @@ -387,6 +407,13 @@ POST /api/products/backfill-canonical Backfill canonical names (admin) POST /api/products/reset-all Rensa all produktdata (admin) POST /api/products/bulk-update Uppdatera flera produkter (t.ex. sätt kategori) Body: { ids: number[], categoryId?: number | null } + +GET /api/products/pending Lista pending-produkter (admin) +GET /api/products/:id/suggest-category AI-förslag på kategori för produkt (premium/admin) +POST /api/products/ai-categorize-bulk Kör AI-kategorisering på alla okategoriserade (admin) + Returnerar lista av { productId, productName, suggestion: CategorySuggestion } +PATCH /api/products/:id/status Sätt produktstatus active/rejected (admin) + Body: { status: 'active' | 'rejected' } ``` ### Kategori-endpoints @@ -397,11 +424,13 @@ GET /api/categories/tree Hierarkiskt träd (@Public) ### Användar-endpoints ``` -POST /api/auth/login Logga in, returnerar JWT inkl. role (@Public) -GET /api/users/me Hämta inloggad användares profil (inkl. role) +POST /api/auth/login Logga in, returnerar JWT inkl. role + isPremium (@Public) +GET /api/users/me Hämta inloggad användares profil (inkl. role, isPremium) PATCH /api/users/me Uppdatera firstName, lastName, email GET /api/users Lista alla användare (kräver admin-roll) PATCH /api/users/:id/role Ändra roll för användare (kräver admin-roll) +PATCH /api/users/:id/premium Sätt isPremium true/false (kräver admin-roll) + Body: { isPremium: boolean } ``` ### Baslager-endpoints @@ -448,7 +477,8 @@ model User { firstName String? lastName String? passwordHash String - role String @default("user") # "user" eller "admin" + role String @default("user") # "user" eller "admin" + isPremium Boolean @default(false) # Styr tillgång till AI-premium-funktioner createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } @@ -456,12 +486,12 @@ model User { **Bootstrap-användare:** När backend startar kör `AdminBootstrapService.onApplicationBootstrap()` och skapar eller uppdaterar fyra användare baserade på miljövariabler: -| Användarnamn | Roll | E-post | Miljövariabel | -|---|---|---|---| -| Nadmin | admin | nadmin@localhost | `ADMIN_NADMIN_PASSWORD` | -| Padmin | admin | padmin@localhost | `ADMIN_PADMIN_PASSWORD` | -| user1 | user | user1@localhost | `SEED_USER1_PASSWORD` | -| user2 | user | user2@localhost | `SEED_USER2_PASSWORD` | +| Användarnamn | Roll | isPremium | E-post | Miljövariabel | +|---|---|---|---|---| +| Nadmin | admin | false | nadmin@localhost | `ADMIN_NADMIN_PASSWORD` | +| Padmin | admin | false | padmin@localhost | `ADMIN_PADMIN_PASSWORD` | +| user1 | user | false | user1@localhost | `SEED_USER1_PASSWORD` | +| user2 | user | false | user2@localhost | `SEED_USER2_PASSWORD` | Om en användare redan finns men har fel roll rättas rollen automatiskt. Om miljövariabeln saknas hoppas den användaren över med en varning i loggen. @@ -506,6 +536,7 @@ model Product { brand String? # Varumärke categoryId Int? # FK till Category (ny hierarki) categoryRef Category? # Relation till Category + status String @default("active") # "active" | "pending" | "rejected" isActive Boolean @default(true) deletedAt DateTime? createdAt DateTime @default(now()) @@ -518,6 +549,11 @@ model Product { } ``` +**Produktstatus:** +- `active` — normal produkt synlig i alla listor (default) +- `pending` — föreslagen produkt som väntar på admin-godkännande; visas på `/admin/products/pending` +- `rejected` — avvisad produkt; visas inte i vanliga listor + ### InventoryItem ```prisma model InventoryItem { diff --git a/backend/prisma/migrations/20260419000000_add_is_premium_and_product_status/migration.sql b/backend/prisma/migrations/20260419000000_add_is_premium_and_product_status/migration.sql new file mode 100644 index 00000000..902e92ec --- /dev/null +++ b/backend/prisma/migrations/20260419000000_add_is_premium_and_product_status/migration.sql @@ -0,0 +1,8 @@ +-- AlterTable User: add isPremium field +ALTER TABLE `User` ADD COLUMN `isPremium` BOOLEAN NOT NULL DEFAULT false; + +-- AlterTable Product: add status field +ALTER TABLE `Product` ADD COLUMN `status` VARCHAR(191) NOT NULL DEFAULT 'active'; + +-- CreateIndex for status on Product (optional, for filtering pending) +CREATE INDEX `Product_status_idx` ON `Product`(`status`); diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index d7ff9a94..ed5b2ad2 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -15,6 +15,7 @@ model User { lastName String? passwordHash String role String @default("user") + isPremium Boolean @default(false) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -33,6 +34,7 @@ model Product { brand String? canonicalName String? isActive Boolean @default(true) + status String @default("active") deletedAt DateTime? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt diff --git a/backend/src/ai/ai.module.ts b/backend/src/ai/ai.module.ts new file mode 100644 index 00000000..3bed5a9a --- /dev/null +++ b/backend/src/ai/ai.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; +import { AiService } from './ai.service'; + +@Module({ + providers: [AiService], + exports: [AiService], +}) +export class AiModule {} diff --git a/backend/src/ai/ai.service.ts b/backend/src/ai/ai.service.ts new file mode 100644 index 00000000..e19cf9ad --- /dev/null +++ b/backend/src/ai/ai.service.ts @@ -0,0 +1,128 @@ +import { Injectable, Logger, ServiceUnavailableException } from '@nestjs/common'; +import { FlatCategory } from '../categories/categories.service'; + +const MISTRAL_API_URL = 'https://api.mistral.ai/v1/chat/completions'; +const MODEL = 'mistral-small-2603'; + +export type CategorySuggestion = { + categoryId: number; + categoryName: string; + path: string; + confidence: 'high' | 'medium' | 'low'; + usedFallback: boolean; +}; + +@Injectable() +export class AiService { + private readonly logger = new Logger(AiService.name); + + async suggestCategory( + productName: string, + categories: FlatCategory[], + ): Promise { + const apiKey = process.env.MISTRAL_API_KEY; + if (!apiKey) { + throw new ServiceUnavailableException('MISTRAL_API_KEY är inte konfigurerad i miljövariabler'); + } + + const categoryList = categories + .map((c) => `[${c.id}] ${c.path}`) + .join('\n'); + + const systemPrompt = `Du är ett kategoriseringssystem för en livsmedelsapp. Din uppgift är att hitta den mest lämpliga kategorin för en produkt. + +Tillgängliga kategorier (format: [id] Sökväg): +${categoryList} + +Regler: +1. Välj den mest specifika underkategorin som passar produkten. +2. Om ingen specifik kategori passar, välj en underkategori under "Övrigt" om möjligt. +3. Om ingen underkategori under "Övrigt" passar, välj "Övrigt" (den kategori vars sökväg är exakt "Övrigt"). +4. Du MÅSTE alltid returnera ett svar — aldrig null eller tomt. +5. Svara ENDAST med giltig JSON i detta format: { "categoryId": , "confidence": "high" | "medium" | "low" } + - "high": uppenbart matchande kategori + - "medium": trolig matchning + - "low": osäker, används fallback (Övrigt eller underkategori till Övrigt)`; + + const userPrompt = `Produkt: "${productName}"`; + + let raw: string; + try { + const response = await fetch(MISTRAL_API_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${apiKey}`, + }, + body: JSON.stringify({ + model: MODEL, + messages: [ + { role: 'system', content: systemPrompt }, + { role: 'user', content: userPrompt }, + ], + max_tokens: 100, + temperature: 0.1, + response_format: { type: 'json_object' }, + }), + }); + + if (!response.ok) { + const err = await response.text(); + this.logger.error(`Mistral API-fel: ${response.status} ${err}`); + throw new ServiceUnavailableException('AI-tjänsten svarade inte korrekt'); + } + + const data = await response.json() as { choices: { message: { content: string } }[] }; + raw = data.choices?.[0]?.message?.content ?? ''; + } catch (err) { + if (err instanceof ServiceUnavailableException) throw err; + this.logger.error(`Mistral fetch-fel: ${String(err)}`); + throw new ServiceUnavailableException('Kunde inte nå AI-tjänsten'); + } + + // Parsa och validera AI-svaret + let parsed: { categoryId: number; confidence: string }; + try { + parsed = JSON.parse(raw); + } catch { + this.logger.warn(`AI returnerade ogiltig JSON: ${raw}`); + return this.fallbackToOvrigt(categories); + } + + const validId = typeof parsed.categoryId === 'number'; + const matchedCategory = validId ? categories.find((c) => c.id === parsed.categoryId) : null; + + if (!matchedCategory) { + this.logger.warn(`AI returnerade okänt categoryId ${parsed.categoryId}, använder fallback`); + return this.fallbackToOvrigt(categories); + } + + const confidence = ['high', 'medium', 'low'].includes(parsed.confidence) + ? (parsed.confidence as 'high' | 'medium' | 'low') + : 'medium'; + + return { + categoryId: matchedCategory.id, + categoryName: matchedCategory.name, + path: matchedCategory.path, + confidence, + usedFallback: confidence === 'low', + }; + } + + private fallbackToOvrigt(categories: FlatCategory[]): CategorySuggestion { + const ovrigt = categories.find((c) => c.path === 'Övrigt'); + if (!ovrigt) { + // Sista utväg — returnera första kategorin + const first = categories[0]; + return { categoryId: first.id, categoryName: first.name, path: first.path, confidence: 'low', usedFallback: true }; + } + return { + categoryId: ovrigt.id, + categoryName: ovrigt.name, + path: ovrigt.path, + confidence: 'low', + usedFallback: true, + }; + } +} diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 7a4e107b..6ced92a8 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -14,6 +14,7 @@ import { AuthModule } from './auth/auth.module'; import { UsersModule } from './users/users.module'; import { UserProductsModule } from './user-products/user-products.module'; import { CategoriesModule } from './categories/categories.module'; +import { AiModule } from './ai/ai.module'; import { JwtAuthGuard } from './auth/jwt-auth.guard'; import { RolesGuard } from './auth/roles.guard'; @@ -34,6 +35,7 @@ import { RolesGuard } from './auth/roles.guard'; UsersModule, UserProductsModule, CategoriesModule, + AiModule, ], providers: [ { diff --git a/backend/src/auth/auth.service.ts b/backend/src/auth/auth.service.ts index 460857c5..0df6ca36 100644 --- a/backend/src/auth/auth.service.ts +++ b/backend/src/auth/auth.service.ts @@ -27,7 +27,7 @@ export class AuthService { passwordHash, }); - return this.issueToken(user.id, user.username, user.role); + return this.issueToken(user.id, user.username, user.role, user.isPremium); } async login(dto: LoginDto) { @@ -37,16 +37,17 @@ export class AuthService { const valid = await bcrypt.compare(dto.password, user.passwordHash); if (!valid) throw new UnauthorizedException('Felaktigt användarnamn eller lösenord'); - return this.issueToken(user.id, user.username, user.role); + return this.issueToken(user.id, user.username, user.role, user.isPremium); } - private issueToken(userId: number, username: string, role: string) { - const payload = { sub: userId, username, role }; + private issueToken(userId: number, username: string, role: string, isPremium: boolean) { + const payload = { sub: userId, username, role, isPremium }; return { accessToken: this.jwtService.sign(payload), userId, username, role, + isPremium, }; } } diff --git a/backend/src/auth/jwt.strategy.ts b/backend/src/auth/jwt.strategy.ts index 7f09cfd4..ac7aaf18 100644 --- a/backend/src/auth/jwt.strategy.ts +++ b/backend/src/auth/jwt.strategy.ts @@ -12,7 +12,7 @@ export class JwtStrategy extends PassportStrategy(Strategy) { }); } - async validate(payload: { sub: number; username: string; role: string }) { - return { userId: payload.sub, username: payload.username, role: payload.role ?? 'user' }; + async validate(payload: { sub: number; username: string; role: string; isPremium: boolean }) { + return { userId: payload.sub, username: payload.username, role: payload.role ?? 'user', isPremium: payload.isPremium ?? false }; } } diff --git a/backend/src/categories/categories.service.ts b/backend/src/categories/categories.service.ts index 7a84322f..99e6d3ae 100644 --- a/backend/src/categories/categories.service.ts +++ b/backend/src/categories/categories.service.ts @@ -8,6 +8,12 @@ export type CategoryNode = { children: CategoryNode[]; }; +export type FlatCategory = { + id: number; + name: string; + path: string; +}; + @Injectable() export class CategoriesService { constructor(private readonly prisma: PrismaService) {} @@ -30,4 +36,15 @@ export class CategoriesService { }); return roots; } + + async findFlattened(): Promise { + const all = await this.prisma.category.findMany({ orderBy: { name: 'asc' } }); + const nameMap = new Map(); + all.forEach((c) => nameMap.set(c.id, c.name)); + return all.map((c) => ({ + id: c.id, + name: c.name, + path: c.parentId ? `${nameMap.get(c.parentId) ?? ''} > ${c.name}` : c.name, + })); + } } diff --git a/backend/src/products/products.controller.ts b/backend/src/products/products.controller.ts index c94d6657..ecc6583a 100644 --- a/backend/src/products/products.controller.ts +++ b/backend/src/products/products.controller.ts @@ -2,6 +2,7 @@ import { Body, Controller, Delete, + ForbiddenException, Get, HttpCode, Param, @@ -10,6 +11,7 @@ import { Post, Put, Query, + Request, } from '@nestjs/common'; import { CreateProductDto } from './dto/create-product.dto'; import { UpdateProductDto } from './dto/update-product.dto'; @@ -20,10 +22,29 @@ import { SetTagsDto } from './dto/set-tags.dto'; import { UpsertNutritionDto } from './dto/upsert-nutrition.dto'; import { BulkUpdateProductsDto } from './dto/bulk-update-products.dto'; import { Roles } from '../auth/decorators/roles.decorator'; +import { AiService } from '../ai/ai.service'; +import { CategoriesService } from '../categories/categories.service'; +import { IsArray, IsIn, IsInt, IsOptional } from 'class-validator'; + +class AiCategorizeBulkDto { + @IsOptional() + @IsArray() + @IsInt({ each: true }) + productIds?: number[]; +} + +class SetProductStatusDto { + @IsIn(['active', 'rejected']) + status: string; +} @Controller('products') export class ProductsController { - constructor(private readonly productsService: ProductsService) {} + constructor( + private readonly productsService: ProductsService, + private readonly aiService: AiService, + private readonly categoriesService: CategoriesService, + ) {} @Get() findAll( @@ -57,11 +78,50 @@ export class ProductsController { return this.productsService.backfillCanonicalNames(); } + @Roles('admin') + @Get('pending') + findPending() { + return this.productsService.findPending(); + } + + @Roles('admin') + @Post('ai-categorize-bulk') + @HttpCode(200) + async aiCategorizeBulk(@Body() body: AiCategorizeBulkDto) { + const categories = await this.categoriesService.findFlattened(); + let products: { id: number; name: string }[]; + if (body.productIds && body.productIds.length > 0) { + const found = await Promise.all(body.productIds.map((id) => this.productsService.findOne(id))); + products = found.map((p) => ({ id: p.id, name: p.canonicalName ?? p.name })); + } else { + products = await this.productsService.findUncategorized(); + } + const results: { productId: number; productName: string; suggestion: object }[] = []; + for (const product of products) { + const suggestion = await this.aiService.suggestCategory(product.name, categories); + results.push({ productId: product.id, productName: product.name, suggestion }); + } + return results; + } + @Get(':id') findOne(@Param('id', ParseIntPipe) id: number) { return this.productsService.findOne(id); } + @Get(':id/suggest-category') + async suggestCategory( + @Param('id', ParseIntPipe) id: number, + @Request() req: { user: { role: string; isPremium: boolean } }, + ) { + if (req.user.role !== 'admin' && !req.user.isPremium) { + throw new ForbiddenException('Denna funktion kräver premiumkonto'); + } + const product = await this.productsService.findOne(id); + const categories = await this.categoriesService.findFlattened(); + return this.aiService.suggestCategory(product.canonicalName ?? product.name, categories); + } + @Post() create(@Body() body: CreateProductDto) { return this.productsService.create(body); @@ -111,6 +171,15 @@ export class ProductsController { return this.productsService.remove(id); } + @Roles('admin') + @Patch(':id/status') + setStatus( + @Param('id', ParseIntPipe) id: number, + @Body() body: SetProductStatusDto, + ) { + return this.productsService.setStatus(id, body.status); + } + @Roles('admin') @Post(':id/restore') restore(@Param('id', ParseIntPipe) id: number) { diff --git a/backend/src/products/products.module.ts b/backend/src/products/products.module.ts index 9b00f473..4e7436ad 100644 --- a/backend/src/products/products.module.ts +++ b/backend/src/products/products.module.ts @@ -1,8 +1,11 @@ import { Module } from '@nestjs/common'; import { ProductsController } from './products.controller'; import { ProductsService } from './products.service'; +import { AiModule } from '../ai/ai.module'; +import { CategoriesModule } from '../categories/categories.module'; @Module({ + imports: [AiModule, CategoriesModule], controllers: [ProductsController], providers: [ProductsService], }) diff --git a/backend/src/products/products.service.ts b/backend/src/products/products.service.ts index 05aeb841..0aff1740 100644 --- a/backend/src/products/products.service.ts +++ b/backend/src/products/products.service.ts @@ -407,4 +407,27 @@ export class ProductsService { await this.prisma.product.updateMany({ where: { id: { in: ids } }, data: updateData }); return { updated: ids.length }; } + + async findUncategorized(): Promise<{ id: number; name: string; canonicalName: string | null }[]> { + return this.prisma.product.findMany({ + where: { isActive: true, categoryId: null, status: 'active' }, + select: { id: true, name: true, canonicalName: true }, + orderBy: { name: 'asc' }, + }); + } + + async findPending() { + return this.prisma.product.findMany({ + where: { status: 'pending' }, + include: { + categoryRef: { include: { parent: true } }, + owner: { select: { id: true, username: true } }, + }, + orderBy: { createdAt: 'desc' }, + }); + } + + setStatus(id: number, status: string) { + return this.prisma.product.update({ where: { id }, data: { status } }); + } } \ No newline at end of file diff --git a/backend/src/receipt-import/dto/parsed-receipt-item.dto.ts b/backend/src/receipt-import/dto/parsed-receipt-item.dto.ts index 08a6e854..528079f8 100644 --- a/backend/src/receipt-import/dto/parsed-receipt-item.dto.ts +++ b/backend/src/receipt-import/dto/parsed-receipt-item.dto.ts @@ -1,3 +1,5 @@ +import type { CategorySuggestion } from '../../ai/ai.service'; + export interface ParsedReceiptItem { rawName: string; quantity: number; @@ -9,4 +11,6 @@ export interface ParsedReceiptItem { // ordbaserad match: förslag, kräver bekräftelse suggestedProductId?: number; suggestedProductName?: string; + // AI-kategorisuggestion för ej matchade varor (premium) + categorySuggestion?: CategorySuggestion; } diff --git a/backend/src/receipt-import/receipt-import.controller.ts b/backend/src/receipt-import/receipt-import.controller.ts index c06f443e..3a7b1eb0 100644 --- a/backend/src/receipt-import/receipt-import.controller.ts +++ b/backend/src/receipt-import/receipt-import.controller.ts @@ -1,7 +1,9 @@ import { Controller, Post, + Request, UploadedFile, + UseGuards, UseInterceptors, BadRequestException, } from '@nestjs/common'; @@ -9,6 +11,7 @@ import { FileInterceptor } from '@nestjs/platform-express'; import { memoryStorage } from 'multer'; import { ReceiptImportService } from './receipt-import.service'; import { ParsedReceiptItem } from './dto/parsed-receipt-item.dto'; +import { JwtAuthGuard } from '../auth/jwt-auth.guard'; const ALLOWED_MIMES = [ 'image/jpeg', @@ -24,6 +27,7 @@ export class ReceiptImportController { constructor(private readonly receiptImportService: ReceiptImportService) {} @Post() + @UseGuards(JwtAuthGuard) @UseInterceptors( FileInterceptor('file', { storage: memoryStorage(), @@ -32,6 +36,7 @@ export class ReceiptImportController { ) async parseReceipt( @UploadedFile() file?: Express.Multer.File, + @Request() req?: any, ): Promise { if (!file?.buffer) { throw new BadRequestException('Ingen fil skickades med.'); @@ -41,6 +46,7 @@ export class ReceiptImportController { 'Otillåten filtyp. Använd JPEG, PNG, WebP eller PDF.', ); } - return this.receiptImportService.parseReceipt(file); + const isPremium = req?.user?.isPremium === true || req?.user?.role === 'admin'; + return this.receiptImportService.parseReceipt(file, isPremium); } } diff --git a/backend/src/receipt-import/receipt-import.module.ts b/backend/src/receipt-import/receipt-import.module.ts index 2433e510..bf006aaf 100644 --- a/backend/src/receipt-import/receipt-import.module.ts +++ b/backend/src/receipt-import/receipt-import.module.ts @@ -2,9 +2,11 @@ import { Module } from '@nestjs/common'; import { ReceiptImportController } from './receipt-import.controller'; import { ReceiptImportService } from './receipt-import.service'; import { PrismaModule } from '../prisma/prisma.module'; +import { AiModule } from '../ai/ai.module'; +import { CategoriesModule } from '../categories/categories.module'; @Module({ - imports: [PrismaModule], + imports: [PrismaModule, AiModule, CategoriesModule], controllers: [ReceiptImportController], providers: [ReceiptImportService], }) diff --git a/backend/src/receipt-import/receipt-import.service.ts b/backend/src/receipt-import/receipt-import.service.ts index c285aa95..b786ec5e 100644 --- a/backend/src/receipt-import/receipt-import.service.ts +++ b/backend/src/receipt-import/receipt-import.service.ts @@ -7,6 +7,8 @@ import { import * as pdfParse from 'pdf-parse'; import { PrismaService } from '../prisma/prisma.service'; import { ParsedReceiptItem } from './dto/parsed-receipt-item.dto'; +import { AiService } from '../ai/ai.service'; +import { CategoriesService } from '../categories/categories.service'; const MISTRAL_API_URL = 'https://api.mistral.ai/v1/chat/completions'; @@ -36,9 +38,13 @@ ${text}`; export class ReceiptImportService { private readonly logger = new Logger(ReceiptImportService.name); - constructor(private readonly prisma: PrismaService) {} + constructor( + private readonly prisma: PrismaService, + private readonly aiService: AiService, + private readonly categoriesService: CategoriesService, + ) {} - async parseReceipt(file: Express.Multer.File): Promise { + async parseReceipt(file: Express.Multer.File, isPremium = false): Promise { const apiKey = process.env.MISTRAL_API_KEY; if (!apiKey) { throw new ServiceUnavailableException( @@ -51,7 +57,12 @@ export class ReceiptImportService { ? await this.parseReceiptFromPdf(file.buffer, apiKey) : await this.parseReceiptFromImage(file.buffer, file.mimetype, apiKey); - return this.matchProducts(rawItems); + const matched = await this.matchProducts(rawItems); + + if (isPremium) { + return this.enrichWithAiCategories(matched); + } + return matched; } private async parseReceiptFromImage( @@ -221,4 +232,29 @@ export class ReceiptImportService { ); }); } + + private async enrichWithAiCategories(items: ParsedReceiptItem[]): Promise { + const unmatched = items.filter((i) => !i.matchedProductId && !i.suggestedProductId && i.rawName); + if (unmatched.length === 0) return items; + + let categories: Awaited>; + try { + categories = await this.categoriesService.findFlattened(); + } catch { + return items; // Om kategoritjänsten är otillgänglig, returnera utan AI-förslag + } + + const enriched = new Map(); + for (const item of unmatched) { + try { + const suggestion = await this.aiService.suggestCategory(item.rawName, categories); + enriched.set(item.rawName, { ...item, categorySuggestion: suggestion }); + } catch { + // Om AI-anrop misslyckas för enskild vara — hoppa över utan att kasta + enriched.set(item.rawName, item); + } + } + + return items.map((item) => enriched.get(item.rawName) ?? item); + } } diff --git a/backend/src/users/users.controller.ts b/backend/src/users/users.controller.ts index e7d5f786..8b2fcb7a 100644 --- a/backend/src/users/users.controller.ts +++ b/backend/src/users/users.controller.ts @@ -1,5 +1,5 @@ import { Controller, Get, Patch, Post, Delete, Body, Param, ParseIntPipe, BadRequestException } from '@nestjs/common'; -import { IsEmail, IsIn, IsOptional, IsString, MaxLength, MinLength } from 'class-validator'; +import { IsBoolean, IsEmail, IsIn, IsOptional, IsString, MaxLength, MinLength } from 'class-validator'; import { UsersService } from './users.service'; import { CurrentUser } from '../auth/decorators/current-user.decorator'; import { Roles } from '../auth/decorators/roles.decorator'; @@ -9,6 +9,11 @@ class SetRoleDto { role: string; } +class SetPremiumDto { + @IsBoolean() + isPremium: boolean; +} + class AdminCreateUserDto { @IsString() @MinLength(2) @@ -98,6 +103,16 @@ export class UsersController { return { id: updated.id, username: updated.username, role: updated.role }; } + @Roles('admin') + @Patch(':id/premium') + async setPremium( + @Param('id', ParseIntPipe) id: number, + @Body() dto: SetPremiumDto, + ) { + const updated = await this.usersService.setPremium(id, dto.isPremium); + return { id: updated.id, username: updated.username, isPremium: updated.isPremium }; + } + @Roles('admin') @Post() async adminCreateUser( diff --git a/backend/src/users/users.service.ts b/backend/src/users/users.service.ts index 5d855ad6..00404668 100644 --- a/backend/src/users/users.service.ts +++ b/backend/src/users/users.service.ts @@ -25,7 +25,7 @@ export class UsersService { findAll() { return this.prisma.user.findMany({ - select: { id: true, username: true, email: true, firstName: true, lastName: true, role: true, createdAt: true }, + select: { id: true, username: true, email: true, firstName: true, lastName: true, role: true, isPremium: true, createdAt: true }, orderBy: { username: 'asc' }, }); } @@ -34,6 +34,10 @@ export class UsersService { return this.prisma.user.update({ where: { id }, data: { role } }); } + setPremium(id: number, isPremium: boolean) { + return this.prisma.user.update({ where: { id }, data: { isPremium } }); + } + async adminCreate(data: { username: string; email: string; password: string; role?: string }) { const existing = await this.prisma.user.findFirst({ where: { OR: [{ username: data.username }, { email: data.email }] }, diff --git a/frontend/app/Navigation.tsx b/frontend/app/Navigation.tsx index 6e9733ac..371bee58 100644 --- a/frontend/app/Navigation.tsx +++ b/frontend/app/Navigation.tsx @@ -38,6 +38,7 @@ export default async function Navigation() { {(session?.user as any)?.role === 'admin' && ( <> ⚙️ Admin + ⏳ Förslag 👥 Användare )} diff --git a/frontend/app/admin/products/AdminProductList.tsx b/frontend/app/admin/products/AdminProductList.tsx index 6b3bda83..0068cd0b 100644 --- a/frontend/app/admin/products/AdminProductList.tsx +++ b/frontend/app/admin/products/AdminProductList.tsx @@ -3,10 +3,22 @@ import { useState, useMemo, useEffect, useTransition } from 'react'; import type { Product, Category } from '../../../features/inventory/types'; import EditProductForm from './EditProductForm'; -import { bulkSetCategory } from './actions'; +import { bulkSetCategory, suggestBulkCategories } from './actions'; type CategoryNode = Category & { children: CategoryNode[] }; +type AiSuggestion = { + productId: number; + productName: string; + suggestion: { + categoryId: number; + categoryName: string; + path: string; + confidence: 'high' | 'medium' | 'low'; + usedFallback: boolean; + }; +}; + type Props = { products: Product[]; }; @@ -36,6 +48,13 @@ export default function AdminProductList({ products }: Props) { const [isPending, startTransition] = useTransition(); const [bulkError, setBulkError] = useState(null); + // AI-kategorisering state + const [aiLoading, setAiLoading] = useState(false); + const [aiError, setAiError] = useState(null); + const [aiSuggestions, setAiSuggestions] = useState(null); + const [aiApproved, setAiApproved] = useState>(new Set()); + const [aiApplying, setAiApplying] = useState(false); + useEffect(() => { fetch('/api/categories') .then((r) => r.json()) @@ -114,6 +133,44 @@ export default function AdminProductList({ products }: Props) { }); }; + const handleAiCategorize = async () => { + setAiLoading(true); + setAiError(null); + setAiSuggestions(null); + try { + const results = await suggestBulkCategories(); + setAiSuggestions(results); + setAiApproved(new Set(results.map((r) => r.productId))); + } catch (err) { + setAiError(err instanceof Error ? err.message : 'AI-kategorisering misslyckades'); + } finally { + setAiLoading(false); + } + }; + + const handleAiApply = async () => { + if (!aiSuggestions) return; + setAiApplying(true); + try { + const approved = aiSuggestions.filter((s) => aiApproved.has(s.productId)); + const grouped = new Map(); + for (const s of approved) { + const cid = s.suggestion.categoryId; + if (!grouped.has(cid)) grouped.set(cid, []); + grouped.get(cid)!.push(s.productId); + } + for (const [categoryId, ids] of grouped.entries()) { + await bulkSetCategory(ids, categoryId); + } + setAiSuggestions(null); + setAiApproved(new Set()); + } catch (err) { + setAiError(err instanceof Error ? err.message : 'Fel vid tillämpning'); + } finally { + setAiApplying(false); + } + }; + return ( <> {/* Sök + sortering + filter */} @@ -161,6 +218,23 @@ export default function AdminProductList({ products }: Props) { > Okategoriserade + @@ -264,6 +338,68 @@ export default function AdminProductList({ products }: Props) { ))} + + {/* AI-kategorisering modal */} + {(aiError || aiSuggestions) && ( +
+
+
+

✨ AI-kategoriförslag

+ +
+ + {aiError &&
{aiError}
} + + {aiSuggestions && ( + <> +

+ AI har analyserat {aiSuggestions.length} okategoriserade produkter. Avmarkera rader du inte vill godkänna, klicka sedan "Godkänn valda". +

+
+ + + + + {['Produkt', 'AI-förslag', 'Säkerhet'].map((h) => )} + + + + {aiSuggestions.map((s) => { + const approved = aiApproved.has(s.productId); + const isLow = s.suggestion.confidence === 'low' || s.suggestion.usedFallback; + return ( + + + + + + + ); + })} + +
+ setAiApproved(aiApproved.size === aiSuggestions.length ? new Set() : new Set(aiSuggestions.map((s) => s.productId)))} /> + {h}
+ setAiApproved((prev) => { const next = new Set(prev); if (next.has(s.productId)) next.delete(s.productId); else next.add(s.productId); return next; })} /> + {s.productName} + {isLow ? '⚠ ' : '✓ '}{s.suggestion.path} + + + {isLow ? 'Låg' : s.suggestion.confidence === 'high' ? 'Hög' : 'Medium'} + +
+
+
+ + +
+ + )} +
+
+ )} ); } diff --git a/frontend/app/admin/products/EditProductForm.tsx b/frontend/app/admin/products/EditProductForm.tsx index 8bff3467..97de8b53 100644 --- a/frontend/app/admin/products/EditProductForm.tsx +++ b/frontend/app/admin/products/EditProductForm.tsx @@ -2,7 +2,7 @@ import { useState, useTransition, useEffect } from 'react'; import type { Product } from '../../../features/inventory/types'; -import { updateProduct, deleteProduct, setProductTags } from './actions'; +import { updateProduct, deleteProduct, setProductTags, suggestProductCategory } from './actions'; type CategoryNode = { id: number; @@ -11,6 +11,14 @@ type CategoryNode = { children: CategoryNode[]; }; +type AiSuggestion = { + categoryId: number; + categoryName: string; + path: string; + confidence: 'high' | 'medium' | 'low'; + usedFallback: boolean; +}; + type Props = { product: Product; }; @@ -39,6 +47,11 @@ export default function EditProductForm({ product }: Props) { (product as any).categoryId ?? '' ); + // AI-suggestion state + const [aiSuggestion, setAiSuggestion] = useState(null); + const [aiLoading, setAiLoading] = useState(false); + const [aiError, setAiError] = useState(null); + useEffect(() => { if (isOpen && categoryTree.length === 0) { fetch('/api/categories') @@ -64,6 +77,20 @@ export default function EditProductForm({ product }: Props) { const flatCategories = flattenTree(categoryTree); + async function handleAiSuggest() { + setAiLoading(true); + setAiError(null); + setAiSuggestion(null); + try { + const result = await suggestProductCategory(product.id); + setAiSuggestion(result); + } catch (err) { + setAiError(err instanceof Error ? err.message : 'AI-kategorisering misslyckades'); + } finally { + setAiLoading(false); + } + } + function handleSubmit(e: React.FormEvent) { e.preventDefault(); setError(null); @@ -158,16 +185,37 @@ export default function EditProductForm({ product }: Props) {