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:
+18
-16
@@ -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
@@ -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
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+62
-26
@@ -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
|
||||||
@@ -448,7 +477,8 @@ model User {
|
|||||||
firstName String?
|
firstName String?
|
||||||
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 {
|
||||||
|
|||||||
+8
@@ -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`);
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { AiService } from './ai.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
providers: [AiService],
|
||||||
|
exports: [AiService],
|
||||||
|
})
|
||||||
|
export class AiModule {}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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: [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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 }] },
|
||||||
|
|||||||
@@ -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>
|
||||||
<select
|
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center', flexWrap: 'wrap' }}>
|
||||||
value={selectedCategoryId}
|
<select
|
||||||
onChange={(e) => setSelectedCategoryId(e.target.value === '' ? '' : Number(e.target.value))}
|
value={selectedCategoryId}
|
||||||
style={inputStyle}
|
onChange={(e) => { setSelectedCategoryId(e.target.value === '' ? '' : Number(e.target.value)); setAiSuggestion(null); }}
|
||||||
>
|
style={{ ...inputStyle, flex: 1, minWidth: 180 }}
|
||||||
<option value="">— Ingen kategori —</option>
|
>
|
||||||
{flatCategories.map((cat) => (
|
<option value="">— Ingen kategori —</option>
|
||||||
<option key={cat.id} value={cat.id}>{cat.label}</option>
|
{flatCategories.map((cat) => (
|
||||||
))}
|
<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' }}>
|
||||||
|
|||||||
@@ -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 })} />
|
||||||
|
|||||||
@@ -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 ? (
|
||||||
|
|||||||
Reference in New Issue
Block a user