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.
This commit is contained in:
Nils-Johan Gynther
2026-04-19 10:34:21 +02:00
parent 0286ab0991
commit 054a19ed7c
30 changed files with 917 additions and 77 deletions
+18 -16
View File
@@ -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. > 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 ## Översikt över AI-funktioner
Funktion | Beskrivning | Rekommenderad Modell |
|-----------------------------------|-------------------------------------------------------------------------------------------------|----------------------------| | Funktion | Beskrivning | Modell | Status |
| **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` | | **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 |
| **Smart inköpslista** | AI skapar en inköpslista baserat på saknade ingredienser och historisk förbrukning. | `mistral-small-2603` | | **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 |
| **Automatisk kategorisering** | AI kategoriserar nya produkter i inventory baserat på namn och beskrivning. | `mistral-small-2603` | | **Receptförslag utifrån hemmalager** | AI analyserar användarens inventory och föreslår recept baserat på tillgängliga ingredienser. | `mistral-small-2603` | ❌ Planerad |
| **"Vad ska jag laga idag?"** | AI ger snabba receptförslag baserat på vad användaren har hemma. | `mistral-tiny-2603` | | **Veckoplanering med AI** | AI genererar en veckoplan baserat på inventory, recept och användarpreferenser. | `mistral-small-2603` | ❌ Planerad |
| **Enhetskonvertering** | AI hjälper till att konvertera enheter (t.ex. gram till msk) och hanterar osäkerheter. | `labs-leanstral-2603` | | **Smart inköpslista** | AI skapar en inköpslista baserat på saknade ingredienser och historisk förbrukning. | `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` | | **"Vad ska jag laga idag?"** | AI ger snabba receptförslag baserat på vad användaren har hemma. | `mistral-small-2603` | ❌ Planerad |
| **Personliga matlagningsråd** | AI ger personliga tips baserat på användarens matlagningshistorik och inventory. | `mistral-tiny-2603` | | **Enhetskonvertering** | AI hjälper till att konvertera enheter (t.ex. gram till msk) och hanterar osäkerheter. | `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` | | **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 |
| **Kostnadseffektiv inköpslista** | AI skapar en kostnadseffektiv inköpslista baserat på inventory och aktuella butikspriser. | `mistral-small-2603` | | **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 ## 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?"**. 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. **Implementera validering**: Se till att AI-output alltid valideras mot strukturerade scheman. 2. **Veckoplanering med AI** — kräver att receptförslag fungerar; planera mot kampanjpriser kräver extern datakälla.
3. **Testa och iterera**: Börja med enklare modeller och utvärdera resultatet innan du skalar upp. 3. **Validering av AI-output** — säkerställ att AI-svar alltid valideras mot strukturerade scheman (t.ex. Zod) för datakvalitet.
--- ---
+14 -6
View File
@@ -6,7 +6,7 @@
--- ---
## Status — senast genomgånget: 2026-04-18 ## Status — senast genomgånget: 2026-04-19
| Funktion | Status | | Funktion | Status |
|---|---| |---|---|
@@ -37,7 +37,14 @@
| Profilsida med flikar (Min profil / Användare / Databas) | ✅ Klart | | Profilsida med flikar (Min profil / Användare / Databas) | ✅ Klart |
| Teknisk skuld — oanvända InventoryItem-fält | ✅ Klart (migration 20260418) | | Teknisk skuld — oanvända InventoryItem-fält | ✅ Klart (migration 20260418) |
| Teknisk skuld — redirect-routes städade | ✅ Klart | | 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 | | 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 ### 5. Avancerad AI-integration
**Mål:** Smarta receptförslag och veckoplanering baserat på inventarie och kampanjdata. **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: AI-infrastrukturen är nu på plats (`AiService`, `mistral-small-2603`, premium-plan). Kategorisering för produkter och kvittoimport är implementerat. 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) - **"Vad ska jag laga idag?"** — Receptförslag baserat på vad som finns i inventariet; kan byggas direkt ovanpå befintliga inventory- och recipe-endpoints
- Kräver: tydlig API-design, kostnadskontroll och eventuellt modellval per use-case - **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
--- ---
+11 -2
View File
@@ -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 - **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 - **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 - **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 ### Baslager
- **Ständigt lager** — markera produkter du alltid räknar med att ha hemma - **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 ### Admin: Produkter
- **Redigera produkter** — uppdatera visningsnamn, canonical name, kategori (hierarkisk dropdown) och varumärke inline direkt i listan - **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 - **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 - **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 - **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 - **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. > 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) ### Användarprofil och administration (fliksida)
Profilsidan `/profil` är en flikbaserad administrationsyta. Antalet flikar beror på rollen: 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: - **Användare** — fullständig användarhantering:
- Skapa ny användare (användarnamn, e-post, lösenord, roll) - Skapa ny användare (användarnamn, e-post, lösenord, roll)
- Ändra roll via dropdown direkt i tabellen - Ändra roll via dropdown direkt i tabellen
- Ändra **plan** (Free / Paid ✨) via dropdown — styr tillgång till premium-AI-funktioner
- Ändra e-postadress inline - Ändra e-postadress inline
- Återställ lösenord — genererar ett tillfälligt lösenord och visar ett kopierings-redo meddelande - Å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) - 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 ### Autentisering och roller
- **Rollbaserad åtkomstkontroll** — systemet har två roller: `user` (standard) och `admin` - **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: - **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) - `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 - **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
--- ---
+61 -25
View File
@@ -66,13 +66,13 @@ docker exec recipe-db mariadb -uroot -p"LÖSENORD" recipe_app -e "SHOW TABLES;"
| Sida | Fil | Funktionalitet | | Sida | Fil | Funktionalitet |
|------|-----|---| |------|-----|---|
| **Hem** | `app/page.tsx` | Startsida | | **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 | | **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 | | **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/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/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/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) | | | `app/profil/tabs/DatabsTab.tsx` | Server component: produktdatabas (importerar admin/products-komponenter) |
| **Inventorie** | `app/inventory/page.tsx` | Lista, filtrera, sortera varor | | **Inventorie** | `app/inventory/page.tsx` | Lista, filtrera, sortera varor |
| | `InventoryList.tsx` | Ritning av inventarieföremål | | | `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 | | | `app/import/ImportTabsClient.tsx` | Klientkomponent: kvitto/recept-flikar |
| **Admin: Användare** | `app/admin/users/page.tsx` | Redirect till `/profil?tab=anvandare` | | **Admin: Användare** | `app/admin/users/page.tsx` | Redirect till `/profil?tab=anvandare` |
| **Admin: Produkter** | `app/admin/products/page.tsx` | Produktadmin-panel | | **Admin: Produkter** | `app/admin/products/page.tsx` | Produktadmin-panel |
| | `AdminProductList.tsx` | Lista produkter, sök, sortera, filter okategoriserade, bulk-select + bulk-kategorisering | | | `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 | | | `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 | | | `ResetProductsButton.tsx` | Knapp för att rensa all produktdata |
| | `MergePreviewForm.tsx` | Förhandsgranska merge | | | `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) | | **Baslager** | `app/baslager/page.tsx` | Visa och hantera baslager (server component) |
| | `AddToPantryForm.tsx` | Lägg till produkt i baslager (dropdown) | | | `AddToPantryForm.tsx` | Lägg till produkt i baslager (dropdown) |
| | `PantryList.tsx` | Visa baslager grupperat per kategori | | | `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 | | 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` | 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/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/auth/[...nextauth]` | GET, POST | Auth.js handlers (login, logout, session) |
| `/api/products` | GET | Produktlista (auth-wrappat med `auth(req)`) | | `/api/products` | GET | Produktlista (auth-wrappat med `auth(req)`) |
@@ -166,27 +168,31 @@ backend/src/
├── main.ts # Startpunkt (port 8080, global prefix "api") ├── main.ts # Startpunkt (port 8080, global prefix "api")
├── auth/ ├── auth/
│ ├── auth.controller.ts # POST /api/auth/login, POST /api/auth/register │ ├── 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 │ ├── 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) │ ├── jwt-auth.guard.ts # Global guard (skyddar allt utom @Public)
│ ├── roles.guard.ts # Guard som kontrollerar @Roles() metadata; kastar 403 │ ├── roles.guard.ts # Guard som kontrollerar @Roles() metadata; kastar 403
│ └── decorators/ │ └── decorators/
│ ├── public.decorator.ts # @Public() markerar öppen endpoint │ ├── 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 │ └── 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/
│ ├── users.controller.ts # GET /api/users/me, PATCH /api/users/me │ ├── users.controller.ts # GET /api/users/me, PATCH /api/users/me
│ │ # GET /api/users (admin), PATCH /api/users/:id/role (admin) │ │ # GET /api/users (admin), PATCH /api/users/:id/role (admin)
│ │ # POST /api/users (admin), DELETE /api/users/:id (admin) │ │ # POST /api/users (admin), DELETE /api/users/:id (admin)
│ │ # POST /api/users/:id/reset-password (admin), PATCH /api/users/:id/email (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 │ ├── 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 │ ├── admin-bootstrap.service.ts # OnApplicationBootstrap: skapar/uppdaterar 4 seed-användare
│ └── users.module.ts │ └── users.module.ts
├── categories/ ├── categories/
│ ├── categories.controller.ts # GET /api/categories, GET /api/categories/tree (@Public) │ ├── 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 │ └── categories.module.ts
├── common/ ├── common/
│ ├── filters/ │ ├── filters/
@@ -210,8 +216,12 @@ backend/src/
│ └── prisma.module.ts │ └── prisma.module.ts
├── products/ ├── products/
│ ├── products.controller.ts # CRUD, merge, duplicates, reset-all │ ├── products.controller.ts # CRUD, merge, duplicates, reset-all
├── products.service.ts # Produktlogik inkl. resetAll() │ # GET /products/pending (admin)
├── products.module.ts │ # 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/ │ └── dto/
│ ├── create-product.dto.ts │ ├── create-product.dto.ts
│ ├── update-product.dto.ts │ ├── update-product.dto.ts
@@ -232,10 +242,12 @@ backend/src/
│ └── dto/ │ └── dto/
│ └── create-meal-plan-entry.dto.ts # { date, recipeId, servings? } │ └── create-meal-plan-entry.dto.ts # { date, recipeId, servings? }
├── receipt-import/ ├── receipt-import/
│ ├── receipt-import.controller.ts # POST /api/receipt-import (multipart) │ ├── receipt-import.controller.ts # POST /api/receipt-import (multipart, kräver JWT)
├── receipt-import.service.ts # Mistral AI-anrop, bildtolkning │ # 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/ │ └── dto/
│ └── parsed-receipt-item.dto.ts │ └── parsed-receipt-item.dto.ts # Inkl. categorySuggestion?: CategorySuggestion
├── receipt-alias/ ├── receipt-alias/
│ ├── receipt-alias.controller.ts # CRUD /api/receipt-alias │ ├── receipt-alias.controller.ts # CRUD /api/receipt-alias
│ ├── receipt-alias.service.ts │ ├── 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`. - **`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:** **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. - 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` - 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) ## 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/reset-all Rensa all produktdata (admin)
POST /api/products/bulk-update Uppdatera flera produkter (t.ex. sätt kategori) POST /api/products/bulk-update Uppdatera flera produkter (t.ex. sätt kategori)
Body: { ids: number[], categoryId?: number | null } 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 ### Kategori-endpoints
@@ -397,11 +424,13 @@ GET /api/categories/tree Hierarkiskt träd (@Public)
### Användar-endpoints ### Användar-endpoints
``` ```
POST /api/auth/login Logga in, returnerar JWT inkl. role (@Public) POST /api/auth/login Logga in, returnerar JWT inkl. role + isPremium (@Public)
GET /api/users/me Hämta inloggad användares profil (inkl. role) GET /api/users/me Hämta inloggad användares profil (inkl. role, isPremium)
PATCH /api/users/me Uppdatera firstName, lastName, email PATCH /api/users/me Uppdatera firstName, lastName, email
GET /api/users Lista alla användare (kräver admin-roll) 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/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 ### Baslager-endpoints
@@ -449,6 +478,7 @@ model User {
lastName String? lastName String?
passwordHash 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()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt 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: **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 | | Användarnamn | Roll | isPremium | E-post | Miljövariabel |
|---|---|---|---| |---|---|---|---|---|
| Nadmin | admin | nadmin@localhost | `ADMIN_NADMIN_PASSWORD` | | Nadmin | admin | false | nadmin@localhost | `ADMIN_NADMIN_PASSWORD` |
| Padmin | admin | padmin@localhost | `ADMIN_PADMIN_PASSWORD` | | Padmin | admin | false | padmin@localhost | `ADMIN_PADMIN_PASSWORD` |
| user1 | user | user1@localhost | `SEED_USER1_PASSWORD` | | user1 | user | false | user1@localhost | `SEED_USER1_PASSWORD` |
| user2 | user | user2@localhost | `SEED_USER2_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. 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 brand String? # Varumärke
categoryId Int? # FK till Category (ny hierarki) categoryId Int? # FK till Category (ny hierarki)
categoryRef Category? # Relation till Category categoryRef Category? # Relation till Category
status String @default("active") # "active" | "pending" | "rejected"
isActive Boolean @default(true) isActive Boolean @default(true)
deletedAt DateTime? deletedAt DateTime?
createdAt DateTime @default(now()) 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 ### InventoryItem
```prisma ```prisma
model InventoryItem { model InventoryItem {
@@ -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`);
+2
View File
@@ -15,6 +15,7 @@ model User {
lastName String? lastName String?
passwordHash String passwordHash String
role String @default("user") role String @default("user")
isPremium Boolean @default(false)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@ -33,6 +34,7 @@ model Product {
brand String? brand String?
canonicalName String? canonicalName String?
isActive Boolean @default(true) isActive Boolean @default(true)
status String @default("active")
deletedAt DateTime? deletedAt DateTime?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
+8
View File
@@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { AiService } from './ai.service';
@Module({
providers: [AiService],
exports: [AiService],
})
export class AiModule {}
+128
View File
@@ -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<CategorySuggestion> {
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": <nummer>, "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,
};
}
}
+2
View File
@@ -14,6 +14,7 @@ import { AuthModule } from './auth/auth.module';
import { UsersModule } from './users/users.module'; import { UsersModule } from './users/users.module';
import { UserProductsModule } from './user-products/user-products.module'; import { UserProductsModule } from './user-products/user-products.module';
import { CategoriesModule } from './categories/categories.module'; import { CategoriesModule } from './categories/categories.module';
import { AiModule } from './ai/ai.module';
import { JwtAuthGuard } from './auth/jwt-auth.guard'; import { JwtAuthGuard } from './auth/jwt-auth.guard';
import { RolesGuard } from './auth/roles.guard'; import { RolesGuard } from './auth/roles.guard';
@@ -34,6 +35,7 @@ import { RolesGuard } from './auth/roles.guard';
UsersModule, UsersModule,
UserProductsModule, UserProductsModule,
CategoriesModule, CategoriesModule,
AiModule,
], ],
providers: [ providers: [
{ {
+5 -4
View File
@@ -27,7 +27,7 @@ export class AuthService {
passwordHash, 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) { async login(dto: LoginDto) {
@@ -37,16 +37,17 @@ export class AuthService {
const valid = await bcrypt.compare(dto.password, user.passwordHash); const valid = await bcrypt.compare(dto.password, user.passwordHash);
if (!valid) throw new UnauthorizedException('Felaktigt användarnamn eller lösenord'); 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) { private issueToken(userId: number, username: string, role: string, isPremium: boolean) {
const payload = { sub: userId, username, role }; const payload = { sub: userId, username, role, isPremium };
return { return {
accessToken: this.jwtService.sign(payload), accessToken: this.jwtService.sign(payload),
userId, userId,
username, username,
role, role,
isPremium,
}; };
} }
} }
+2 -2
View File
@@ -12,7 +12,7 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
}); });
} }
async validate(payload: { sub: number; username: string; role: string }) { async validate(payload: { sub: number; username: string; role: string; isPremium: boolean }) {
return { userId: payload.sub, username: payload.username, role: payload.role ?? 'user' }; return { userId: payload.sub, username: payload.username, role: payload.role ?? 'user', isPremium: payload.isPremium ?? false };
} }
} }
@@ -8,6 +8,12 @@ export type CategoryNode = {
children: CategoryNode[]; children: CategoryNode[];
}; };
export type FlatCategory = {
id: number;
name: string;
path: string;
};
@Injectable() @Injectable()
export class CategoriesService { export class CategoriesService {
constructor(private readonly prisma: PrismaService) {} constructor(private readonly prisma: PrismaService) {}
@@ -30,4 +36,15 @@ export class CategoriesService {
}); });
return roots; return roots;
} }
async findFlattened(): Promise<FlatCategory[]> {
const all = await this.prisma.category.findMany({ orderBy: { name: 'asc' } });
const nameMap = new Map<number, string>();
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,
}));
}
} }
+70 -1
View File
@@ -2,6 +2,7 @@ import {
Body, Body,
Controller, Controller,
Delete, Delete,
ForbiddenException,
Get, Get,
HttpCode, HttpCode,
Param, Param,
@@ -10,6 +11,7 @@ import {
Post, Post,
Put, Put,
Query, Query,
Request,
} from '@nestjs/common'; } from '@nestjs/common';
import { CreateProductDto } from './dto/create-product.dto'; import { CreateProductDto } from './dto/create-product.dto';
import { UpdateProductDto } from './dto/update-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 { UpsertNutritionDto } from './dto/upsert-nutrition.dto';
import { BulkUpdateProductsDto } from './dto/bulk-update-products.dto'; import { BulkUpdateProductsDto } from './dto/bulk-update-products.dto';
import { Roles } from '../auth/decorators/roles.decorator'; 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') @Controller('products')
export class ProductsController { export class ProductsController {
constructor(private readonly productsService: ProductsService) {} constructor(
private readonly productsService: ProductsService,
private readonly aiService: AiService,
private readonly categoriesService: CategoriesService,
) {}
@Get() @Get()
findAll( findAll(
@@ -57,11 +78,50 @@ export class ProductsController {
return this.productsService.backfillCanonicalNames(); 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') @Get(':id')
findOne(@Param('id', ParseIntPipe) id: number) { findOne(@Param('id', ParseIntPipe) id: number) {
return this.productsService.findOne(id); 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() @Post()
create(@Body() body: CreateProductDto) { create(@Body() body: CreateProductDto) {
return this.productsService.create(body); return this.productsService.create(body);
@@ -111,6 +171,15 @@ export class ProductsController {
return this.productsService.remove(id); 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') @Roles('admin')
@Post(':id/restore') @Post(':id/restore')
restore(@Param('id', ParseIntPipe) id: number) { restore(@Param('id', ParseIntPipe) id: number) {
+3
View File
@@ -1,8 +1,11 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { ProductsController } from './products.controller'; import { ProductsController } from './products.controller';
import { ProductsService } from './products.service'; import { ProductsService } from './products.service';
import { AiModule } from '../ai/ai.module';
import { CategoriesModule } from '../categories/categories.module';
@Module({ @Module({
imports: [AiModule, CategoriesModule],
controllers: [ProductsController], controllers: [ProductsController],
providers: [ProductsService], providers: [ProductsService],
}) })
+23
View File
@@ -407,4 +407,27 @@ export class ProductsService {
await this.prisma.product.updateMany({ where: { id: { in: ids } }, data: updateData }); await this.prisma.product.updateMany({ where: { id: { in: ids } }, data: updateData });
return { updated: ids.length }; 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 } });
}
} }
@@ -1,3 +1,5 @@
import type { CategorySuggestion } from '../../ai/ai.service';
export interface ParsedReceiptItem { export interface ParsedReceiptItem {
rawName: string; rawName: string;
quantity: number; quantity: number;
@@ -9,4 +11,6 @@ export interface ParsedReceiptItem {
// ordbaserad match: förslag, kräver bekräftelse // ordbaserad match: förslag, kräver bekräftelse
suggestedProductId?: number; suggestedProductId?: number;
suggestedProductName?: string; suggestedProductName?: string;
// AI-kategorisuggestion för ej matchade varor (premium)
categorySuggestion?: CategorySuggestion;
} }
@@ -1,7 +1,9 @@
import { import {
Controller, Controller,
Post, Post,
Request,
UploadedFile, UploadedFile,
UseGuards,
UseInterceptors, UseInterceptors,
BadRequestException, BadRequestException,
} from '@nestjs/common'; } from '@nestjs/common';
@@ -9,6 +11,7 @@ import { FileInterceptor } from '@nestjs/platform-express';
import { memoryStorage } from 'multer'; import { memoryStorage } from 'multer';
import { ReceiptImportService } from './receipt-import.service'; import { ReceiptImportService } from './receipt-import.service';
import { ParsedReceiptItem } from './dto/parsed-receipt-item.dto'; import { ParsedReceiptItem } from './dto/parsed-receipt-item.dto';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
const ALLOWED_MIMES = [ const ALLOWED_MIMES = [
'image/jpeg', 'image/jpeg',
@@ -24,6 +27,7 @@ export class ReceiptImportController {
constructor(private readonly receiptImportService: ReceiptImportService) {} constructor(private readonly receiptImportService: ReceiptImportService) {}
@Post() @Post()
@UseGuards(JwtAuthGuard)
@UseInterceptors( @UseInterceptors(
FileInterceptor('file', { FileInterceptor('file', {
storage: memoryStorage(), storage: memoryStorage(),
@@ -32,6 +36,7 @@ export class ReceiptImportController {
) )
async parseReceipt( async parseReceipt(
@UploadedFile() file?: Express.Multer.File, @UploadedFile() file?: Express.Multer.File,
@Request() req?: any,
): Promise<ParsedReceiptItem[]> { ): Promise<ParsedReceiptItem[]> {
if (!file?.buffer) { if (!file?.buffer) {
throw new BadRequestException('Ingen fil skickades med.'); throw new BadRequestException('Ingen fil skickades med.');
@@ -41,6 +46,7 @@ export class ReceiptImportController {
'Otillåten filtyp. Använd JPEG, PNG, WebP eller PDF.', '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);
} }
} }
@@ -2,9 +2,11 @@ import { Module } from '@nestjs/common';
import { ReceiptImportController } from './receipt-import.controller'; import { ReceiptImportController } from './receipt-import.controller';
import { ReceiptImportService } from './receipt-import.service'; import { ReceiptImportService } from './receipt-import.service';
import { PrismaModule } from '../prisma/prisma.module'; import { PrismaModule } from '../prisma/prisma.module';
import { AiModule } from '../ai/ai.module';
import { CategoriesModule } from '../categories/categories.module';
@Module({ @Module({
imports: [PrismaModule], imports: [PrismaModule, AiModule, CategoriesModule],
controllers: [ReceiptImportController], controllers: [ReceiptImportController],
providers: [ReceiptImportService], providers: [ReceiptImportService],
}) })
@@ -7,6 +7,8 @@ import {
import * as pdfParse from 'pdf-parse'; import * as pdfParse from 'pdf-parse';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { ParsedReceiptItem } from './dto/parsed-receipt-item.dto'; 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'; const MISTRAL_API_URL = 'https://api.mistral.ai/v1/chat/completions';
@@ -36,9 +38,13 @@ ${text}`;
export class ReceiptImportService { export class ReceiptImportService {
private readonly logger = new Logger(ReceiptImportService.name); 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<ParsedReceiptItem[]> { async parseReceipt(file: Express.Multer.File, isPremium = false): Promise<ParsedReceiptItem[]> {
const apiKey = process.env.MISTRAL_API_KEY; const apiKey = process.env.MISTRAL_API_KEY;
if (!apiKey) { if (!apiKey) {
throw new ServiceUnavailableException( throw new ServiceUnavailableException(
@@ -51,7 +57,12 @@ export class ReceiptImportService {
? await this.parseReceiptFromPdf(file.buffer, apiKey) ? await this.parseReceiptFromPdf(file.buffer, apiKey)
: await this.parseReceiptFromImage(file.buffer, file.mimetype, 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( private async parseReceiptFromImage(
@@ -221,4 +232,29 @@ export class ReceiptImportService {
); );
}); });
} }
private async enrichWithAiCategories(items: ParsedReceiptItem[]): Promise<ParsedReceiptItem[]> {
const unmatched = items.filter((i) => !i.matchedProductId && !i.suggestedProductId && i.rawName);
if (unmatched.length === 0) return items;
let categories: Awaited<ReturnType<CategoriesService['findFlattened']>>;
try {
categories = await this.categoriesService.findFlattened();
} catch {
return items; // Om kategoritjänsten är otillgänglig, returnera utan AI-förslag
}
const enriched = new Map<string, ParsedReceiptItem>();
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);
}
} }
+16 -1
View File
@@ -1,5 +1,5 @@
import { Controller, Get, Patch, Post, Delete, Body, Param, ParseIntPipe, BadRequestException } from '@nestjs/common'; 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 { UsersService } from './users.service';
import { CurrentUser } from '../auth/decorators/current-user.decorator'; import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { Roles } from '../auth/decorators/roles.decorator'; import { Roles } from '../auth/decorators/roles.decorator';
@@ -9,6 +9,11 @@ class SetRoleDto {
role: string; role: string;
} }
class SetPremiumDto {
@IsBoolean()
isPremium: boolean;
}
class AdminCreateUserDto { class AdminCreateUserDto {
@IsString() @IsString()
@MinLength(2) @MinLength(2)
@@ -98,6 +103,16 @@ export class UsersController {
return { id: updated.id, username: updated.username, role: updated.role }; 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') @Roles('admin')
@Post() @Post()
async adminCreateUser( async adminCreateUser(
+5 -1
View File
@@ -25,7 +25,7 @@ export class UsersService {
findAll() { findAll() {
return this.prisma.user.findMany({ 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' }, orderBy: { username: 'asc' },
}); });
} }
@@ -34,6 +34,10 @@ export class UsersService {
return this.prisma.user.update({ where: { id }, data: { role } }); 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 }) { async adminCreate(data: { username: string; email: string; password: string; role?: string }) {
const existing = await this.prisma.user.findFirst({ const existing = await this.prisma.user.findFirst({
where: { OR: [{ username: data.username }, { email: data.email }] }, where: { OR: [{ username: data.username }, { email: data.email }] },
+1
View File
@@ -38,6 +38,7 @@ export default async function Navigation() {
{(session?.user as any)?.role === 'admin' && ( {(session?.user as any)?.role === 'admin' && (
<> <>
<Link href="/admin/products" style={linkStyle}> Admin</Link> <Link href="/admin/products" style={linkStyle}> Admin</Link>
<Link href="/admin/products/pending" style={linkStyle}> Förslag</Link>
<Link href="/profil?tab=anvandare" style={linkStyle}>👥 Användare</Link> <Link href="/profil?tab=anvandare" style={linkStyle}>👥 Användare</Link>
</> </>
)} )}
@@ -3,10 +3,22 @@
import { useState, useMemo, useEffect, useTransition } from 'react'; import { useState, useMemo, useEffect, useTransition } from 'react';
import type { Product, Category } from '../../../features/inventory/types'; import type { Product, Category } from '../../../features/inventory/types';
import EditProductForm from './EditProductForm'; import EditProductForm from './EditProductForm';
import { bulkSetCategory } from './actions'; import { bulkSetCategory, suggestBulkCategories } from './actions';
type CategoryNode = Category & { children: CategoryNode[] }; 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 = { type Props = {
products: Product[]; products: Product[];
}; };
@@ -36,6 +48,13 @@ export default function AdminProductList({ products }: Props) {
const [isPending, startTransition] = useTransition(); const [isPending, startTransition] = useTransition();
const [bulkError, setBulkError] = useState<string | null>(null); const [bulkError, setBulkError] = useState<string | null>(null);
// AI-kategorisering state
const [aiLoading, setAiLoading] = useState(false);
const [aiError, setAiError] = useState<string | null>(null);
const [aiSuggestions, setAiSuggestions] = useState<AiSuggestion[] | null>(null);
const [aiApproved, setAiApproved] = useState<Set<number>>(new Set());
const [aiApplying, setAiApplying] = useState(false);
useEffect(() => { useEffect(() => {
fetch('/api/categories') fetch('/api/categories')
.then((r) => r.json()) .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<number, number[]>();
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 ( return (
<> <>
{/* Sök + sortering + filter */} {/* Sök + sortering + filter */}
@@ -161,6 +218,23 @@ export default function AdminProductList({ products }: Props) {
> >
Okategoriserade Okategoriserade
</button> </button>
<button
type="button"
onClick={handleAiCategorize}
disabled={aiLoading}
style={{
padding: '0.45rem 0.75rem',
borderRadius: '999px',
border: '1px solid #a78bfa',
background: aiLoading ? '#f5f3ff' : '#ede9fe',
color: '#5b21b6',
fontWeight: 600,
cursor: aiLoading ? 'wait' : 'pointer',
fontSize: '0.9rem',
}}
>
{aiLoading ? '⏳ AI arbetar…' : '✨ AI-kategorisera okategoriserade'}
</button>
</div> </div>
<span style={{ color: '#666', fontSize: '0.9rem', whiteSpace: 'nowrap' }}> <span style={{ color: '#666', fontSize: '0.9rem', whiteSpace: 'nowrap' }}>
@@ -264,6 +338,68 @@ export default function AdminProductList({ products }: Props) {
</article> </article>
))} ))}
</div> </div>
{/* AI-kategorisering modal */}
{(aiError || aiSuggestions) && (
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.45)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000 }}>
<div style={{ background: '#fff', borderRadius: 10, padding: '1.5rem', maxWidth: 700, width: '95%', maxHeight: '85vh', display: 'flex', flexDirection: 'column', gap: '1rem', boxShadow: '0 8px 32px rgba(0,0,0,0.18)' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<h3 style={{ margin: 0 }}> AI-kategoriförslag</h3>
<button onClick={() => { setAiSuggestions(null); setAiError(null); }} style={{ background: 'none', border: 'none', fontSize: 20, cursor: 'pointer', color: '#64748b' }}></button>
</div>
{aiError && <div style={{ color: '#dc2626', background: '#fef2f2', border: '1px solid #fecaca', borderRadius: 6, padding: '0.6rem 1rem', fontSize: 14 }}>{aiError}</div>}
{aiSuggestions && (
<>
<p style={{ margin: 0, fontSize: 13, color: '#475569' }}>
AI har analyserat {aiSuggestions.length} okategoriserade produkter. Avmarkera rader du inte vill godkänna, klicka sedan "Godkänn valda".
</p>
<div style={{ overflowY: 'auto', flex: 1 }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead>
<tr style={{ background: '#f1f5f9', textAlign: 'left' }}>
<th style={{ padding: '0.5rem 0.6rem', borderBottom: '2px solid #e2e8f0' }}>
<input type="checkbox" checked={aiApproved.size === aiSuggestions.length} onChange={() => setAiApproved(aiApproved.size === aiSuggestions.length ? new Set() : new Set(aiSuggestions.map((s) => s.productId)))} />
</th>
{['Produkt', 'AI-förslag', 'Säkerhet'].map((h) => <th key={h} style={{ padding: '0.5rem 0.6rem', borderBottom: '2px solid #e2e8f0' }}>{h}</th>)}
</tr>
</thead>
<tbody>
{aiSuggestions.map((s) => {
const approved = aiApproved.has(s.productId);
const isLow = s.suggestion.confidence === 'low' || s.suggestion.usedFallback;
return (
<tr key={s.productId} style={{ borderBottom: '1px solid #e2e8f0', background: isLow ? '#fffbeb' : approved ? '#f0fdf4' : '#fff', opacity: approved ? 1 : 0.5 }}>
<td style={{ padding: '0.5rem 0.6rem' }}>
<input type="checkbox" checked={approved} onChange={() => setAiApproved((prev) => { const next = new Set(prev); if (next.has(s.productId)) next.delete(s.productId); else next.add(s.productId); return next; })} />
</td>
<td style={{ padding: '0.5rem 0.6rem', fontWeight: 500 }}>{s.productName}</td>
<td style={{ padding: '0.5rem 0.6rem', color: isLow ? '#92400e' : '#15803d' }}>
{isLow ? '⚠ ' : '✓ '}{s.suggestion.path}
</td>
<td style={{ padding: '0.5rem 0.6rem' }}>
<span style={{ display: 'inline-block', padding: '0.15rem 0.5rem', borderRadius: 999, fontSize: 12, fontWeight: 600, background: isLow ? '#fef3c7' : s.suggestion.confidence === 'high' ? '#dcfce7' : '#dbeafe', color: isLow ? '#92400e' : s.suggestion.confidence === 'high' ? '#15803d' : '#1d4ed8' }}>
{isLow ? 'Låg' : s.suggestion.confidence === 'high' ? 'Hög' : 'Medium'}
</span>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
<div style={{ display: 'flex', gap: '0.75rem', justifyContent: 'flex-end' }}>
<button onClick={() => { setAiSuggestions(null); setAiError(null); }} style={{ padding: '0.5rem 1rem', background: '#e2e8f0', border: 'none', borderRadius: 6, cursor: 'pointer', fontWeight: 500 }}>Avbryt</button>
<button onClick={handleAiApply} disabled={aiApplying || aiApproved.size === 0} style={{ padding: '0.5rem 1.2rem', background: '#7c3aed', color: '#fff', border: 'none', borderRadius: 6, cursor: 'pointer', fontWeight: 600 }}>
{aiApplying ? 'Sparar…' : `Godkänn valda (${aiApproved.size})`}
</button>
</div>
</>
)}
</div>
</div>
)}
</> </>
); );
} }
@@ -2,7 +2,7 @@
import { useState, useTransition, useEffect } from 'react'; import { useState, useTransition, useEffect } from 'react';
import type { Product } from '../../../features/inventory/types'; import type { Product } from '../../../features/inventory/types';
import { updateProduct, deleteProduct, setProductTags } from './actions'; import { updateProduct, deleteProduct, setProductTags, suggestProductCategory } from './actions';
type CategoryNode = { type CategoryNode = {
id: number; id: number;
@@ -11,6 +11,14 @@ type CategoryNode = {
children: CategoryNode[]; children: CategoryNode[];
}; };
type AiSuggestion = {
categoryId: number;
categoryName: string;
path: string;
confidence: 'high' | 'medium' | 'low';
usedFallback: boolean;
};
type Props = { type Props = {
product: Product; product: Product;
}; };
@@ -39,6 +47,11 @@ export default function EditProductForm({ product }: Props) {
(product as any).categoryId ?? '' (product as any).categoryId ?? ''
); );
// AI-suggestion state
const [aiSuggestion, setAiSuggestion] = useState<AiSuggestion | null>(null);
const [aiLoading, setAiLoading] = useState(false);
const [aiError, setAiError] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
if (isOpen && categoryTree.length === 0) { if (isOpen && categoryTree.length === 0) {
fetch('/api/categories') fetch('/api/categories')
@@ -64,6 +77,20 @@ export default function EditProductForm({ product }: Props) {
const flatCategories = flattenTree(categoryTree); 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<HTMLFormElement>) { function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault(); e.preventDefault();
setError(null); setError(null);
@@ -158,16 +185,37 @@ export default function EditProductForm({ product }: Props) {
<label style={{ display: 'grid', gap: '0.25rem', fontSize: '0.9rem' }}> <label style={{ display: 'grid', gap: '0.25rem', fontSize: '0.9rem' }}>
<span style={{ fontWeight: 600 }}>Kategori (ny hierarki)</span> <span style={{ fontWeight: 600 }}>Kategori (ny hierarki)</span>
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center', flexWrap: 'wrap' }}>
<select <select
value={selectedCategoryId} value={selectedCategoryId}
onChange={(e) => setSelectedCategoryId(e.target.value === '' ? '' : Number(e.target.value))} onChange={(e) => { setSelectedCategoryId(e.target.value === '' ? '' : Number(e.target.value)); setAiSuggestion(null); }}
style={inputStyle} style={{ ...inputStyle, flex: 1, minWidth: 180 }}
> >
<option value=""> Ingen kategori </option> <option value=""> Ingen kategori </option>
{flatCategories.map((cat) => ( {flatCategories.map((cat) => (
<option key={cat.id} value={cat.id}>{cat.label}</option> <option key={cat.id} value={cat.id}>{cat.label}</option>
))} ))}
</select> </select>
<button
type="button"
onClick={handleAiSuggest}
disabled={aiLoading}
title="Låt AI föreslå kategori"
style={{ padding: '0.45rem 0.75rem', background: '#ede9fe', border: '1px solid #a78bfa', borderRadius: 4, cursor: aiLoading ? 'wait' : 'pointer', fontSize: 13, color: '#5b21b6', fontWeight: 600, whiteSpace: 'nowrap' }}
>
{aiLoading ? '⏳' : '✨ Fråga AI'}
</button>
</div>
{aiError && <span style={{ color: '#dc2626', fontSize: 12 }}>{aiError}</span>}
{aiSuggestion && (
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginTop: 4, flexWrap: 'wrap' }}>
<span style={{ fontSize: 12, padding: '0.2rem 0.6rem', borderRadius: 999, fontWeight: 600, background: aiSuggestion.usedFallback ? '#fef3c7' : aiSuggestion.confidence === 'high' ? '#dcfce7' : '#dbeafe', color: aiSuggestion.usedFallback ? '#92400e' : aiSuggestion.confidence === 'high' ? '#15803d' : '#1d4ed8', border: `1px solid ${aiSuggestion.usedFallback ? '#fcd34d' : aiSuggestion.confidence === 'high' ? '#86efac' : '#93c5fd'}` }}>
{aiSuggestion.usedFallback ? '⚠ AI osäker — ' : 'AI föreslår: '}{aiSuggestion.path}
</span>
<button type="button" onClick={() => { setSelectedCategoryId(aiSuggestion.categoryId); setAiSuggestion(null); }} style={{ padding: '0.2rem 0.5rem', background: '#16a34a', color: '#fff', border: 'none', borderRadius: 4, cursor: 'pointer', fontSize: 12 }}></button>
<button type="button" onClick={() => setAiSuggestion(null)} style={{ padding: '0.2rem 0.5rem', background: '#e2e8f0', border: 'none', borderRadius: 4, cursor: 'pointer', fontSize: 12 }}></button>
</div>
)}
</label> </label>
<label style={{ display: 'grid', gap: '0.25rem', fontSize: '0.9rem' }}> <label style={{ display: 'grid', gap: '0.25rem', fontSize: '0.9rem' }}>
+66
View File
@@ -105,3 +105,69 @@ export async function bulkSetCategory(ids: number[], categoryId: number | null)
revalidatePath('/admin/products'); revalidatePath('/admin/products');
} }
export async function suggestProductCategory(productId: number) {
const res = await fetch(`${API_BASE}/api/products/${productId}/suggest-category`, {
method: 'GET',
headers: { ...(await getAuthHeaders()) },
cache: 'no-store',
});
if (!res.ok) {
const text = await res.text();
throw new Error(`AI-kategorisering misslyckades: ${text}`);
}
return res.json() as Promise<{
categoryId: number;
categoryName: string;
path: string;
confidence: 'high' | 'medium' | 'low';
usedFallback: boolean;
}>;
}
export async function suggestBulkCategories(productIds?: number[]) {
const res = await fetch(`${API_BASE}/api/products/ai-categorize-bulk`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...(await getAuthHeaders()) },
body: JSON.stringify({ productIds }),
cache: 'no-store',
});
if (!res.ok) {
const text = await res.text();
throw new Error(`Bulk-AI-kategorisering misslyckades: ${text}`);
}
return res.json() as Promise<
{
productId: number;
productName: string;
suggestion: {
categoryId: number;
categoryName: string;
path: string;
confidence: 'high' | 'medium' | 'low';
usedFallback: boolean;
};
}[]
>;
}
export async function setProductStatus(id: number, status: 'active' | 'rejected') {
const res = await fetch(`${API_BASE}/api/products/${id}/status`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json', ...(await getAuthHeaders()) },
body: JSON.stringify({ status }),
cache: 'no-store',
});
if (!res.ok) {
const text = await res.text();
throw new Error(`Kunde inte uppdatera status: ${text}`);
}
revalidatePath('/admin/products');
revalidatePath('/admin/products/pending');
}
@@ -0,0 +1,109 @@
'use client';
import { useState, useTransition } from 'react';
import { setProductStatus } from '../actions';
type PendingProduct = {
id: number;
name: string;
canonicalName: string | null;
createdAt: string;
categoryRef?: { name: string; parent?: { name: string } } | null;
owner?: { id: number; username: string } | null;
};
export default function PendingProductsClient({ products: initial }: { products: PendingProduct[] }) {
const [products, setProducts] = useState<PendingProduct[]>(initial);
const [isPending, startTransition] = useTransition();
const [error, setError] = useState<string | null>(null);
const [processing, setProcessing] = useState<number | null>(null);
function handleAction(id: number, status: 'active' | 'rejected') {
setError(null);
setProcessing(id);
startTransition(async () => {
try {
await setProductStatus(id, status);
setProducts((prev) => prev.filter((p) => p.id !== id));
} catch (err) {
setError(err instanceof Error ? err.message : 'Fel vid uppdatering');
} finally {
setProcessing(null);
}
});
}
if (products.length === 0) {
return (
<div style={{ color: '#64748b', background: '#f8fafc', border: '1px solid #e2e8f0', borderRadius: 8, padding: '2rem', textAlign: 'center' }}>
Inga väntande produktförslag 🎉
</div>
);
}
return (
<div>
{error && (
<div style={{ background: '#fef2f2', border: '1px solid #fecaca', borderRadius: 6, padding: '0.6rem 1rem', color: '#dc2626', marginBottom: '1rem', fontSize: 14 }}>
{error}
</div>
)}
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 14 }}>
<thead>
<tr style={{ background: '#f1f5f9', textAlign: 'left' }}>
{['Produkt', 'Kategori (AI)', 'Föreslagen av', 'Datum', 'Åtgärd'].map((h) => (
<th key={h} style={{ padding: '0.6rem 0.8rem', borderBottom: '2px solid #e2e8f0' }}>{h}</th>
))}
</tr>
</thead>
<tbody>
{products.map((p) => {
const isProcessing = processing === p.id && isPending;
const categoryPath = [p.categoryRef?.parent?.name, p.categoryRef?.name].filter(Boolean).join(' ');
return (
<tr key={p.id} style={{ borderBottom: '1px solid #e2e8f0', opacity: isProcessing ? 0.5 : 1 }}>
<td style={{ padding: '0.6rem 0.8rem' }}>
<div style={{ fontWeight: 500 }}>{p.canonicalName ?? p.name}</div>
{p.canonicalName && p.canonicalName !== p.name && (
<div style={{ fontSize: 12, color: '#94a3b8' }}>{p.name}</div>
)}
</td>
<td style={{ padding: '0.6rem 0.8rem' }}>
{categoryPath ? (
<span style={{ fontSize: 12, background: '#e0f2fe', borderRadius: 999, padding: '0.15rem 0.5rem', color: '#0369a1' }}>{categoryPath}</span>
) : (
<span style={{ fontSize: 12, color: '#94a3b8' }}></span>
)}
</td>
<td style={{ padding: '0.6rem 0.8rem', color: '#475569' }}>{p.owner?.username ?? '—'}</td>
<td style={{ padding: '0.6rem 0.8rem', color: '#94a3b8', fontSize: 12 }}>
{new Date(p.createdAt).toLocaleDateString('sv-SE')}
</td>
<td style={{ padding: '0.6rem 0.8rem' }}>
<div style={{ display: 'flex', gap: 6 }}>
<button
onClick={() => handleAction(p.id, 'active')}
disabled={isProcessing}
style={{ padding: '0.3rem 0.7rem', background: '#dcfce7', border: '1px solid #86efac', borderRadius: 4, cursor: 'pointer', fontSize: 12, color: '#15803d', fontWeight: 600 }}
>
Godkänn
</button>
<button
onClick={() => handleAction(p.id, 'rejected')}
disabled={isProcessing}
style={{ padding: '0.3rem 0.7rem', background: '#fef2f2', border: '1px solid #fecaca', borderRadius: 4, cursor: 'pointer', fontSize: 12, color: '#dc2626', fontWeight: 600 }}
>
Avvisa
</button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
);
}
@@ -0,0 +1,27 @@
import { auth } from '../../../../auth';
import { redirect } from 'next/navigation';
import Navigation from '../../../Navigation';
import { getAuthHeaders } from '../../../../lib/auth-headers';
import PendingProductsClient from './PendingProductsClient';
const API_BASE = process.env.NEXT_PUBLIC_API_URL_INTERNAL ?? 'http://recipe-api:8080';
export default async function PendingProductsPage() {
const session = await auth();
if (!session || (session.user as any)?.role !== 'admin') redirect('/');
const headers = await getAuthHeaders();
const res = await fetch(`${API_BASE}/api/products/pending`, { headers, cache: 'no-store' });
const products = res.ok ? await res.json() : [];
return (
<main style={{ padding: '1rem', maxWidth: '1100px', margin: '0 auto' }}>
<Navigation />
<h1 style={{ marginBottom: '0.5rem' }}>Väntande produktförslag</h1>
<p style={{ color: '#64748b', marginBottom: '1.5rem' }}>
Produkter som användare har föreslagit. Godkänn för att göra dem tillgängliga i katalogen, eller avvisa för att ta bort dem.
</p>
<PendingProductsClient products={products} />
</main>
);
}
@@ -19,6 +19,22 @@ export async function PATCH(
if (!session) return NextResponse.json({ message: 'Förbjuden' }, { status: 403 }); if (!session) return NextResponse.json({ message: 'Förbjuden' }, { status: 403 });
const body = await request.json(); const body = await request.json();
// Om body innehåller isPremium → anropa /premium-endpoint
if ('isPremium' in body) {
const res = await fetch(`${API_BASE}/api/users/${id}/premium`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${session.accessToken}`,
},
body: JSON.stringify({ isPremium: body.isPremium }),
});
const data = await res.json();
return NextResponse.json(data, { status: res.status });
}
// Annars → roll-byte
const res = await fetch(`${API_BASE}/api/users/${id}/role`, { const res = await fetch(`${API_BASE}/api/users/${id}/role`, {
method: 'PATCH', method: 'PATCH',
headers: { headers: {
@@ -2,6 +2,14 @@
import { useRef, useState, useEffect } from 'react'; import { useRef, useState, useEffect } from 'react';
type CategorySuggestion = {
categoryId: number;
categoryName: string;
path: string;
confidence: 'high' | 'medium' | 'low';
usedFallback: boolean;
};
type ParsedItem = { type ParsedItem = {
rawName: string; rawName: string;
quantity: number; quantity: number;
@@ -11,6 +19,7 @@ type ParsedItem = {
matchedProductName?: string; matchedProductName?: string;
suggestedProductId?: number; suggestedProductId?: number;
suggestedProductName?: string; suggestedProductName?: string;
categorySuggestion?: CategorySuggestion;
}; };
type Product = { id: number; name: string; canonicalName: string | null }; type Product = { id: number; name: string; canonicalName: string | null };
@@ -27,6 +36,7 @@ type RowState = {
editQty: string; editQty: string;
editUnit: string; editUnit: string;
matchSource: 'alias' | 'suggestion' | 'manual' | 'none'; matchSource: 'alias' | 'suggestion' | 'manual' | 'none';
categorySuggestion?: CategorySuggestion;
}; };
const UNITS = ['st', 'kg', 'g', 'l', 'dl', 'cl', 'ml', 'förp', 'pak', 'burk', 'flaska']; const UNITS = ['st', 'kg', 'g', 'l', 'dl', 'cl', 'ml', 'förp', 'pak', 'burk', 'flaska'];
@@ -116,6 +126,7 @@ export default function ReceiptImportClient() {
editQty: String(item.quantity), editQty: String(item.quantity),
editUnit: item.unit, editUnit: item.unit,
matchSource: 'none', matchSource: 'none',
categorySuggestion: item.categorySuggestion,
}; };
}), }),
); );
@@ -272,6 +283,13 @@ export default function ReceiptImportClient() {
{UNITS.map((u) => <option key={u} value={u}>{u}</option>)} {UNITS.map((u) => <option key={u} value={u}>{u}</option>)}
</select> </select>
</div> </div>
{row.categorySuggestion && row.matchSource === 'none' && (
<div style={{ marginTop: '0.5rem', fontSize: '0.8rem', color: '#7c3aed', background: '#f5f3ff', border: '1px solid #ddd6fe', borderRadius: '5px', padding: '4px 8px', display: 'inline-flex', alignItems: 'center', gap: '0.4rem' }}>
<span></span>
<span>AI-förslag: <strong>{row.categorySuggestion.path}</strong></span>
{row.categorySuggestion.usedFallback && <span style={{ color: '#b45309' }}>(osäker)</span>}
</div>
)}
{row.selectedProductId !== '' && row.matchSource !== 'alias' && ( {row.selectedProductId !== '' && row.matchSource !== 'alias' && (
<label style={{ display: 'flex', alignItems: 'center', gap: '0.4rem', marginTop: '0.5rem', fontSize: '0.82rem', color: '#555', cursor: 'pointer' }}> <label style={{ display: 'flex', alignItems: 'center', gap: '0.4rem', marginTop: '0.5rem', fontSize: '0.82rem', color: '#555', cursor: 'pointer' }}>
<input type="checkbox" checked={row.saveAlias} onChange={(e) => updateRow(i, { saveAlias: e.target.checked })} /> <input type="checkbox" checked={row.saveAlias} onChange={(e) => updateRow(i, { saveAlias: e.target.checked })} />
+37 -1
View File
@@ -7,6 +7,7 @@ interface User {
username: string; username: string;
email: string; email: string;
role: string; role: string;
isPremium: boolean;
firstName?: string; firstName?: string;
lastName?: string; lastName?: string;
createdAt: string; createdAt: string;
@@ -109,6 +110,21 @@ export default function AnvandareClient({ users: initial, currentUserId }: Props
setCopiedPw(false); setCopiedPw(false);
} }
async function handlePremiumChange(id: number, isPremium: boolean) {
setError('');
const res = await fetch(`/api/admin-users/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ isPremium }),
});
if (res.ok) {
setUsers((prev) => prev.map((u) => (u.id === id ? { ...u, isPremium } : u)));
} else {
const data = await res.json().catch(() => ({}));
setError(data.message ?? 'Kunde inte ändra plan');
}
}
async function handleEmailSave(id: number) { async function handleEmailSave(id: number) {
setError(''); setError('');
const res = await fetch(`/api/admin-users/${id}`, { const res = await fetch(`/api/admin-users/${id}`, {
@@ -241,7 +257,7 @@ export default function AnvandareClient({ users: initial, currentUserId }: Props
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 14 }}> <table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 14 }}>
<thead> <thead>
<tr style={{ background: '#f1f5f9', textAlign: 'left' }}> <tr style={{ background: '#f1f5f9', textAlign: 'left' }}>
{['Användare', 'E-post', 'Roll', 'Åtgärder'].map((h) => ( {['Användare', 'E-post', 'Roll', 'Plan', 'Åtgärder'].map((h) => (
<th <th
key={h} key={h}
style={{ padding: '0.6rem 0.8rem', borderBottom: '2px solid #e2e8f0' }} style={{ padding: '0.6rem 0.8rem', borderBottom: '2px solid #e2e8f0' }}
@@ -367,6 +383,26 @@ export default function AnvandareClient({ users: initial, currentUserId }: Props
)} )}
</td> </td>
{/* Plan */}
<td style={{ padding: '0.6rem 0.8rem' }}>
<select
value={user.isPremium ? 'paid' : 'free'}
onChange={(e) => handlePremiumChange(user.id, e.target.value === 'paid')}
style={{
padding: '0.25rem 0.4rem',
border: '1px solid #cbd5e1',
borderRadius: 4,
fontSize: 13,
background: user.isPremium ? '#fef9c3' : '#f8fafc',
color: user.isPremium ? '#854d0e' : '#334155',
fontWeight: user.isPremium ? 600 : 400,
}}
>
<option value="free">Free</option>
<option value="paid">Paid </option>
</select>
</td>
{/* Åtgärder */} {/* Åtgärder */}
<td style={{ padding: '0.6rem 0.8rem' }}> <td style={{ padding: '0.6rem 0.8rem' }}>
{isSelf ? ( {isSelf ? (