Compare commits

..

17 Commits

Author SHA1 Message Date
Nils-Johan Gynther a1dffef708 docs: update technical documentation with import field harmonization details
Test Suite / test (24.15.0) (push) Has been cancelled
Added detailed section about harmonization of import fields between receipt-import, flyer-import, and inventory table. Includes key changes, benefits, and technical details about TypeScript type safety and backward compatibility. Resolves migration issues via prisma migrate resolve.
2026-05-25 08:14:55 +02:00
Nils-Johan Gynther 0b69683080 feat(flyer-parsing): add flyer parsing module
Test Suite / test (24.15.0) (push) Has been cancelled
- Added new FlyerParsingModule to the application
- Updated AppModule to import the new FlyerParsingModule
- Added new directory structure for flyer-parsing module
2026-05-18 18:40:31 +02:00
nilsjohan cd830b9de8 Upload files to "/"
Test Suite / test (24.15.0) (push) Has been cancelled
2026-05-18 16:38:17 +02:00
Nils-Johan Gynther 2fecdd2b8a feat: add Copilot instructions for database command style
Test Suite / test (24.15.0) (push) Has been cancelled
2026-05-13 16:37:32 +02:00
Nils-Johan Gynther 9453195598 fix: update documentation status and add quality-gates information in CI workflow
Test Suite / test (24.15.0) (push) Has been cancelled
2026-05-12 22:24:21 +02:00
Nils-Johan Gynther dcc60af0c0 Refactor code structure for improved readability and maintainability
Test Suite / test (24.15.0) (push) Has been cancelled
2026-05-12 22:20:57 +02:00
Nils-Johan Gynther 9fbb99e7a1 fix: update documentation status and upgrade dependencies to NestJS 11 and multer 2.1.1
Test Suite / test (24.15.0) (push) Has been cancelled
2026-05-12 22:01:40 +02:00
Nils-Johan Gynther 1b2836afe0 chore: update NestJS and related dependencies to version 11.x.x
Test Suite / test (24.15.0) (push) Has been cancelled
2026-05-12 22:00:37 +02:00
Nils-Johan Gynther 4596b80408 fix: update documentation status and enhance technical description for microservice importer
Test Suite / test (24.15.0) (push) Has been cancelled
2026-05-11 16:41:20 +02:00
Nils-Johan Gynther c639eae270 fix: add note about duplicated parseRecipeMarkdown function and TODO for extraction
Test Suite / test (24.15.0) (push) Has been cancelled
2026-05-05 08:07:23 +02:00
Nils-Johan Gynther bf4e1d48bf fix: enhance PDF parsing and retry logic; improve reproducibility and AI filtering
Test Suite / test (24.15.0) (push) Has been cancelled
Co-authored-by: Copilot <copilot@github.com>
2026-05-03 22:29:37 +02:00
Nils-Johan Gynther 2dc8aa4fb4 perf: skip Mistral AI for PDF lines that lack numeric value (header/footer/junk)
Test Suite / test (24.15.0) (push) Has been cancelled
2026-05-03 22:21:44 +02:00
Nils-Johan Gynther ea006e7fbe fix: increase retry delay for Mistral 429 to 3s*attempt
Test Suite / test (24.15.0) (push) Has been cancelled
2026-05-03 22:15:28 +02:00
Nils-Johan Gynther fa9bd141e0 fix: use require() for pdf-parse and pdfjs-dist legacy build to fix Node 24 compat
Test Suite / test (24.15.0) (push) Has been cancelled
2026-05-03 22:10:53 +02:00
Nils-Johan Gynther a0ac8b6084 Merge branch 'main' of ssh://gitea.gynther.se:2222/nilsjohan/microservice-importer
Test Suite / test (24.15.0) (push) Has been cancelled
2026-05-03 21:59:41 +02:00
Nils-Johan Gynther 6e9c588ae3 chore: Update Node.js version to 24.15.0 in CI and Dockerfile for consistency
Co-authored-by: Copilot <copilot@github.com>
2026-05-03 21:48:49 +02:00
Nils-Johan Gynther cef8ee4b25 feat: Add Node.js version parity to next steps for consistent development environment
Co-authored-by: Copilot <copilot@github.com>
2026-05-03 21:37:55 +02:00
17 changed files with 2691 additions and 1657 deletions
+7
View File
@@ -0,0 +1,7 @@
# Copilot Instructions
## Database Command Style
When suggesting database commands in this repository, always load credentials from `.env` inline (no hardcoded passwords).
Use robust grep/sed/tr extraction for `MARIADB_ROOT_PASSWORD` and `MARIADB_DATABASE`.
+7 -8
View File
@@ -26,17 +26,16 @@ jobs:
- name: Install dependencies (backend)
working-directory: ./backend
run: npm install
run: npm ci
- name: Generate Prisma Client
- name: Typecheck backend
working-directory: ./backend
run: npm run prisma:generate
- name: Run tests (backend)
working-directory: ./backend
run: npm test
run: npm run typecheck
- name: Build NestJS app
working-directory: ./backend
run: npm run build
continue-on-error: true
- name: Dependency audit (high+critical)
working-directory: ./backend
run: npm run audit:high
+31 -198
View File
@@ -1,215 +1,48 @@
# Microservice Importer
Intern import-tjänst (`importer-api`) för [recipe-app](../recipe-app). Hanterar URL-skrapning, OCR, PDF-parsning och AI-kvittoparsning utan databas. Körs som Docker-tjänst på det interna `recipe-internal`-nätverket exponeras ej externt.
Intern import-tjänst (`importer-api`) för [recipe-app](../recipe-app). Den hanterar URL-skrapning, OCR, PDF-parsning och AI-kvittoparsning utan databas. Tjänsten körs som Docker-tjänst på det interna `recipe-internal`-nätverket och exponeras inte externt.
## Dokumentstatus (2026-05-03)
## Dokumentstatus (2026-05-12)
### Målgrupp
Detta dokument är för systemadministratörer och utvecklare som driftar eller vidareutvecklar importtjänsten.
Det här dokumentet är skrivet för systemadministratörer och utvecklare som driftar eller vidareutvecklar importtjänsten. För arkitektur, drift och tekniska detaljer, se [TEKNISK_BESKRIVNING.md](TEKNISK_BESKRIVNING.md). För roadmap och prioriteringar, se [next_steps_MSImporter.md](next_steps_MSImporter.md).
### Tillägg från senaste sessionerna
- Regelbaserad kvittotolkning har förbättrats för multipack, enheter och antaluttryck.
- Parserstödet för brödrelaterade produkter och guardrails mot felkategorisering har utökats.
- Integrationen med klienten för kvitto-session är fortfarande stateless i denna tjänst; ingen serverlagring av användarsession infördes.
- Kvittokategorisering: nya regler för pasta, grädde, ägg, juice, godis, och potatis samt justerad AI-guardrail.
- Testinfrastruktur: parametriserade enhetstester för kvittoimport (18 testfall) och CI/CD-pipeline med automatiserad testkörning på push.
## Vad tjänsten gör
## Viktigt!! Kod- och byggpraxis!
Säkerställ att inga absoluta Windows-sökvägar används i koden, för att stödja bygg och drift på Linux/Ubuntu
- Tar emot URL:er, filer och markdown för importflöden
- Skrapar receptsidor och extraherar `imageUrl` när det finns
- Kör OCR för bilder och skannade dokument
- Tolkar kvitton via Mistral AI
- Returnerar strukturerad data till recipe-app utan att lagra någon session eller databaspost
---
## Flöden
## Features
- `POST /api/quick-import` för URL-skrapning, bild-OCR och PDF-import
- `POST /api/recipes/parse-markdown` för markdown till strukturerat recept
- `POST /api/receipt-import/parse` för kvittobild eller PDF till `ParsedReceiptItem[]`
- `GET /api/health` för Docker healthcheck
### Quick-import (`POST /api/quick-import`)
- **URL-skrapning** — ICA.se (JSON-LD) och generisk parser. Extraherar `imageUrl` från receptbild.
- **OCR (bild)** — tesseract.js, svenska+engelska. Returnerar `source: 'image'`.
- **PDF-parsning** — pdf-parse för digitala PDFs, OCR-fallback för skannade.
- **Multipart** — Tar emot antingen JSON-body (`{ url }`) eller FormData (`file`).
## Viktigt
### Parse-Markdown (`POST /api/recipes/parse-markdown`)
Tolkar Markdown-recept till strukturerat JSON utan databas.
- Inga absoluta Windows-sökvägar ska användas i kod eller scripts
- Tjänsten är stateless
- Ingen databas är konfigurerad i tjänsten
- Host-port 3001 används av `wetty` på servern och får därför inte exponeras av importtjänsten
### Kvittoparsning (`POST /api/receipt-import/parse`)
- Bild (JPEG/PNG/WebP/HEIC/HEIF) eller PDF
- **Modell:** `mistral-small-2603` (vision-kapabel) med retry-logik (3 försök vid 503/429)
- Returnerar `ParsedReceiptItem[]` med fälten `rawName`, `quantity`, `unit`, `price`, `brand`, `origin`
- Inbyggda regler i AI-prompten styr tolkning av `quantity`/`unit` (se nedan)
## Kort faktadel
### Health (`GET /api/health`)
Används av Docker-healthcheck i `recipe-app/compose.yml`. Returnerar `{ status: "ok" }`.
- Runtime: Node.js 22-alpine
- Ramverk: NestJS 11 + TypeScript 5
- OCR: `tesseract.js`
- PDF: `pdf-parse` med `pdfjs-dist/legacy` fallback
- AI: `@mistralai/mistralai`
- Upload: `multer` 2.1.1
- Alpine-paket: `tesseract-ocr`, `tesseract-ocr-data-swe`, `tesseract-ocr-data-eng`
---
### Saker att veta efter uppgradering
## Miljövariabler
| Variabel | Beskrivning | Standardvärde |
|---|---|---|
| `MISTRAL_API_KEY` | API-nyckel för Mistral AI | (krävs för kvittoparsning) |
| `PORT` | HTTP-port | `3001` |
---
## Arkitektur
### Backend (NestJS 10, TypeScript 5, Node.js 22-alpine)
**Port:** 3001 (intern, ej exponerad till host)
```
src/
├── app.module.ts # Root module + HealthController (GET /api/health)
├── main.ts
├── common/
│ ├── filters/global-exception.filter.ts
│ └── utils/normalize-name.ts
├── web-scraping-service/ # Quick-import (URL + fil)
│ ├── web-scraping.module.ts
│ ├── controllers/quick-import.controller.ts # POST /api/quick-import
│ ├── services/quick-import.service.ts # Scraping, OCR, PDF
│ └── parsers/
│ ├── base.parser.ts # ParsedRecipe interface
│ ├── ica.parser.ts # ICA.se JSON-LD + imageUrl
│ └── generic.parser.ts
├── receipt-parsing/ # Kvittoparsning via Mistral AI
│ ├── receipt-parsing.module.ts
│ ├── receipt-parsing.controller.ts # POST /api/receipt-import/parse
│ └── receipt-parsing.service.ts
├── document-service/ # PDF-dokumentimport
│ ├── document-service.module.ts
│ ├── controllers/document-import.controller.ts
│ ├── services/document-import.service.ts
│ └── parsers/
│ ├── document.parser.ts
│ └── pdf.parser.ts
└── recipes/ # Markdown-tolkning
├── recipes.module.ts
├── recipes.controller.ts # POST /api/recipes/parse-markdown
├── recipes.service.ts
└── dto/parse-markdown.dto.ts
```
**Viktigt:** Backend har _INGEN_ databaskonfiguration — stateless service.
---
## Kvittoparsning — regler för quantity och unit
Följande regler är inbyggda i AI-prompten och styr hur Mistral tolkar mängd och enhet per produkt:
| Typ | Regel | Exempel |
|---|---|---|
| **Lösvikt** (kött, ost, frukt/grönt vägt i kassan) | `quantity` = faktisk vikt från kvittot, `unit` = `kg`/`g` | `BLANDFÄRS 20%` 0.997 kg → `quantity=0.997, unit="kg"` |
| **Förpackad vara med storlek i namn** (mejeri, dryck, konserver) | `quantity` = antal förpackningar, `unit` = `"förp"` | `MJÖLK 1,5L` × 3 → `quantity=3, unit="förp"` |
| **Multipack** (`NxYg`/`NxYml` i namn) | `quantity=1`, `unit="förp"` — räkna inte upp N | `BACON 3X120G``quantity=1, unit="förp"` |
| **Förpackat innehåll** (bröd, kex, chips) | `quantity` = antal förpackningar, `unit` = `"förp"` | `BRIOCHE SESAM` × 2 → `quantity=2, unit="förp"` |
| **Lösa styckvaror** (enstaka frukt/bröd per st) | `quantity` = antal, `unit` = `"st"` | `BANAN` × 1 → `quantity=1, unit="st"` |
Tillåtna enheter: `st`, `kg`, `g`, `l`, `dl`, `cl`, `ml`, `förp`, `pak`, `burk`, `flaska`
---
## Parser-arkitektur
### Dokument-parsers (`document-service/parsers/`)
Abstrakt bas `DocumentParser` som alla dokumenttyp-specifika parsers ärver från.
### PDF Parser (`pdf.parser.ts`)
Hanterar textbaserade PDFs via `pdf-parse`. Skannade PDFs varnas.
### Webb-parsers (`web-scraping-service/parsers/`)
Abstrakt bas `RecipeParser` med `canHandle(url)` + `parse(html)`. Implementationer:
- **`ica.parser.ts`** — ICA.se, prioriterar JSON-LD structured data, extraherar `imageUrl`
- **`generic.parser.ts`** — Fallback för alla webbplatser
---
## Deployment
`importer-api` byggs och startas via `recipe-app/compose.yml`**ej via sin egen compose-fil**.
**Serverstruktur:**
```
/opt/containers/
microservice-importer/ ← klonas separat, pullas vid deploy
recipe-app/
compose.yml ← definierar importer-api-tjänsten
deploy.sh ← kör docker compose build + up
```
**Deploy:**
```bash
# 1. Uppdatera importer (om ändringar gjorts)
cd /opt/containers/microservice-importer && git pull
# 2. Bygg och starta alla containers
cd /opt/containers/recipe-app && git pull && ./deploy.sh
```
**Loggar:**
```bash
docker logs importer-api -f
```
**Hälsokontroll:**
```bash
docker exec importer-api wget -qO- http://localhost:3001/api/health
# → {"status":"ok"}
```
**OBS:** Host-port 3001 används av `wetty` på servern. `importer-api` exponeras **aldrig** utanför Docker-nätverket — anropas via `http://importer-api:3001` från `recipe-api`.
---
## Tekniska detaljer
### Backend Stack
- **NestJS** 10 — REST API & modular architecture
- **TypeScript** 5 — Type safety
- **Node.js** 22-alpine — Runtime (Alpine Linux)
- **pdf-parse** — PDF text extraction
- **tesseract.js** — OCR (bild och skannade PDFs, svenska + engelska)
- **@mistralai/mistralai** — AI-kvittoparsning (`mistral-small-2603`)
- **multer** — Multipart file upload handling
- **Ingen databas** — Stateless service
### Systempaket (Alpine)
Installerade i Dockerfile runner-stage:
```
tesseract-ocr
tesseract-ocr-data-swe
tesseract-ocr-data-eng
```
### Error Handling
- Centraliserad `GlobalExceptionFilter` (svenska meddelanden)
- Konsistent JSON-responsformat: `{ statusCode, message, timestamp, path }`
- HTTP status codes: 200, 400, 503
---
## Framtida utbyggnader
- [x] PDF-import — textbaserad extraction
- [x] OCR för skannade bild-PDFs (tesseract.js + Alpine-paket)
- [x] Kvittoparsning via Mistral AI
- [x] ICA-receptbildsextraktion (`imageUrl` i `ParsedRecipe`)
- [ ] Fler webbplats-parsers (Arla, Tasteline, Köket.se)
- [ ] Word (.docx) import
- [ ] Swagger/OpenAPI-dokumentation
- [ ] Rate limiting / Caching
---
## Licens
MIT
---
- Backendberoenden är uppgraderade till NestJS 11-serien och `multer` 2.1.1 för att adressera kända audit-varningar.
- CI/node-miljö bör vara Node.js 22 (eller minst Node.js 20.11 för Nest CLI 11).
## Support
- **Git Repo** — Gitea på `192.168.50.2:2222/nilsjohan/microservice-importer`
- Git repo: Gitea på `192.168.50.2:2222/nilsjohan/microservice-importer`
+201
View File
@@ -0,0 +1,201 @@
# Teknisk beskrivning av Microservice Importer
## Dokumentstatus (2026-05-12)
Detta dokument riktar sig till utvecklare och driftansvariga för microservice-importer. Det beskriver arkitektur, drift och tekniska beslut för den interna importtjänsten.
## Roll och ansvar
`importer-api` är en stateless intern tjänst för [recipe-app](../recipe-app). Den hanterar URL-skrapning, OCR, PDF-parsning, markdown-parsning och AI-kvittoparsning utan databas eller användarsessioner.
## Arkitektur
### Körmiljö
- NestJS 11
- TypeScript 5
- Node.js 22-alpine
- Port `3001` internt
- Exponeras bara på `recipe-internal`-nätverket
### Moduler
```text
src/
├── app.module.ts # Root module + HealthController (GET /api/health)
├── main.ts
├── common/
│ ├── filters/global-exception.filter.ts
│ └── utils/normalize-name.ts
├── web-scraping-service/
│ ├── web-scraping.module.ts
│ ├── controllers/quick-import.controller.ts # POST /api/quick-import
│ ├── services/quick-import.service.ts # Scraping, OCR, PDF
│ └── parsers/
│ ├── base.parser.ts # ParsedRecipe interface
│ ├── ica.parser.ts # ICA.se JSON-LD + imageUrl
│ └── generic.parser.ts
├── receipt-parsing/
│ ├── receipt-parsing.module.ts
│ ├── receipt-parsing.controller.ts # POST /api/receipt-import/parse
│ └── receipt-parsing.service.ts
├── document-service/
│ ├── document-service.module.ts
│ ├── controllers/document-import.controller.ts
│ ├── services/document-import.service.ts
│ └── parsers/
│ ├── document.parser.ts
│ └── pdf.parser.ts
└── recipes/
├── recipes.module.ts
├── recipes.controller.ts # POST /api/recipes/parse-markdown
├── recipes.service.ts
└── dto/parse-markdown.dto.ts
```
## Endpoints
| Endpoint | Funktion |
|---|---|
| `POST /api/quick-import` | URL-skrapning, bild-OCR och PDF-import |
| `POST /api/recipes/parse-markdown` | Markdown till strukturerat recept utan databas |
| `POST /api/receipt-import/parse` | Kvittobild/PDF till `ParsedReceiptItem[]` via Mistral AI |
| `GET /api/health` | Hälsokontroll för Docker healthcheck |
## Kvittoparsning
### Modell och pipeline
- Vision-input använder `mistral-small-2603`
- PDF-flödet kör `pdf-parse` eller `pdfjs-dist/legacy/build/pdf.js` som fallback
- Regelbaserad parsning körs före AI när det är möjligt
- `looksLikeReceiptProductLine()` filtrerar bort rader utan siffra så att AI bara används för sannolika produktrader
### Mängd- och enhetsregler
Följande regler är inbyggda i kvitto-prompten:
| Typ | Regel | Exempel |
|---|---|---|
| Lösvikt | `quantity` = faktisk vikt, `unit` = `kg`/`g` | `BLANDFÄRS 20%` 0.997 kg |
| Förpackad vara med storlek i namn | `quantity` = antal förpackningar, `unit` = `förp` | `MJÖLK 1,5L` × 3 |
| Multipack | `quantity=1`, `unit=förp` | `BACON 3X120G` |
| Förpackat innehåll | `quantity` = antal förpackningar, `unit` = `förp` | `BRIOCHE SESAM` × 2 |
| Lösa styckvaror | `quantity` = antal, `unit` = `st` | `BANAN` × 1 |
Tillåtna enheter: `st`, `kg`, `g`, `l`, `dl`, `cl`, `ml`, `förp`, `pak`, `burk`, `flaska`.
### Retry och stabilitet
- Mistral 429/503 backas av med `3000 * attempt` ms
- PDF-flödet använder fallback för CJS- och Node Alpine-kompatibilitet
- `GlobalExceptionFilter` ger konsekventa felobjekt
## Deployment
`importer-api` byggs och startas via [recipe-app/compose.yml](../recipe-app/compose.yml) och inte via egen compose-fil.
### Serverlayout
```text
/opt/containers/
microservice-importer/
recipe-app/
compose.yml
deploy.sh
```
### Driftsekvens
```bash
cd /opt/containers/microservice-importer && git pull
cd /opt/containers/recipe-app && git pull && ./deploy.sh
```
### Hälsokontroll
```bash
docker exec importer-api wget -qO- http://localhost:3001/api/health
```
## Tekniska detaljer
### Byggberoenden
- `pdf-parse`
- `tesseract.js`
- `@mistralai/mistralai`
- `multer` 2.1.1
### Versionsnotering
- NestJS-paket är uppgraderade till 11-serien för att ta bort sårbarheter i beroendekedjan.
- Nest CLI 11 kräver Node.js 20.11+ i CI- och byggmiljöer.
### Alpine-paket
- `tesseract-ocr`
- `tesseract-ocr-data-swe`
- `tesseract-ocr-data-eng`
### Viktiga tekniska beslut
- Tjänsten är stateless och saknar databaskonfiguration
- Importer exponeras aldrig externt, bara internt via Docker-nätverket
- Host-port 3001 är upptagen av `wetty` och får därför inte användas av tjänsten
## Parser-arkitektur
### Dokument-parsers
Abstrakt bas `DocumentParser` används för dokumenttypsspecifik parsing.
### PDF Parser
`pdf.parser.ts` hanterar textbaserade PDFs. Skannade PDFs varnas och kan falla tillbaka på OCR-vägen.
### Webb-parsers
- `ica.parser.ts` prioriterar JSON-LD och extraherar `imageUrl`
- `generic.parser.ts` är fallback för webbplatser utan specialparser
## Framtida utbyggnader
- Fler webbplats-parsers som Arla, Tasteline och Köket.se
- Word/import av `.docx`
- Swagger/OpenAPI-dokumentation
- Caching av skrapade sidor om belastningen mot externa webbplatser blir ett problem
## Quality-gates (npm scripts + CI)
Tillagda scripts i `backend/package.json`:
| Script | Kommando |
|---|---|
| `typecheck` | `tsc --noEmit` |
| `audit:high` | `npm audit --audit-level=high` |
| `quality:ci` | Kedja: typecheck → build → audit |
CI-workflow (`.github/workflows/test.yml`) uppdaterad (2026-05-12):
- Bytte `npm install` till `npm ci` för reproducerbara byggen.
- Ersatte Prisma- och test-steg (saknas i projektet) med: `typecheck``build``audit:high`.
- Tog bort `continue-on-error` på build-steget — pipeline fångar nu verkliga fel.
- `npm audit --audit-level=high` rapporterar **0 sårbarheter**.
Harmonisering av importfält (2026-05-24)
Mål: Skapa konsistens mellan kvitto-import, flyer-import och inventory-tabellen
Nyckeländringar:
ParsedReceiptItem fick categoryId för kategorisättning
FlyerImportItem fick origin som mappas från signals.originCountries[0]
originCountries array-stöd lades till i inventory för framtida användning
Fördelar: Minskat felrisk, enklare underhåll, bättre integration
Tekniska detaljer:
Typ-säkra ändringar med korrekta TypeScript-typer
Bakåtkompatibla ändringar
Löst migrationsproblem via prisma migrate resolve
## Referenser
- [README.md](README.md)
- [NEXT_STEPS.md](next_steps_MSImporter.md)
+1 -1
View File
@@ -11,7 +11,7 @@ COPY backend/src ./src
COPY backend/tsconfig.json ./
COPY backend/nest-cli.json ./
# Köra npm install
# Köra npm ci för reproducerbara builds
RUN npm ci
RUN npm run build
+1118 -1361
View File
File diff suppressed because it is too large Load Diff
+11 -8
View File
@@ -5,15 +5,18 @@
"scripts": {
"build": "nest build",
"start": "node dist/main",
"start:dev": "nest start --watch"
"start:dev": "nest start --watch",
"typecheck": "tsc --noEmit",
"audit:high": "npm audit --audit-level=high",
"quality:ci": "npm run typecheck && npm run build && npm run audit:high"
},
"dependencies": {
"@nestjs/common": "^10.3.0",
"@nestjs/core": "^10.3.0",
"@nestjs/platform-express": "^10.3.0",
"@nestjs/common": "^11.1.19",
"@nestjs/core": "^11.1.19",
"@nestjs/platform-express": "^11.1.19",
"class-transformer": "^0.5.1",
"class-validator": "^0.15.1",
"multer": "^1.4.5-lts.1",
"multer": "^2.1.1",
"pdf-lib": "^1.17.1",
"pdf-parse": "^1.1.1",
"pdfjs-dist": "^5.7.284",
@@ -22,9 +25,9 @@
"tesseract.js": "^5.1.1"
},
"devDependencies": {
"@nestjs/cli": "^10.3.0",
"@nestjs/schematics": "^10.1.1",
"@types/express": "^4.17.21",
"@nestjs/cli": "^11.0.21",
"@nestjs/schematics": "^11.1.0",
"@types/express": "^5.0.5",
"@types/multer": "^1.4.12",
"@types/node": "^22.15.29",
"@types/pdf-parse": "^1.1.4",
+2
View File
@@ -3,6 +3,7 @@ import { WebScrapingModule } from './web-scraping-service/web-scraping.module';
import { RecipesModule } from './recipes/recipes.module';
import { DocumentServiceModule } from './document-service/document-service.module';
import { ReceiptParsingModule } from './receipt-parsing/receipt-parsing.module';
import { FlyerParsingModule } from './flyer-parsing/flyer-parsing.module';
@Controller('health')
class HealthController {
@@ -18,6 +19,7 @@ class HealthController {
WebScrapingModule,
RecipesModule,
ReceiptParsingModule,
FlyerParsingModule,
],
controllers: [HealthController],
})
@@ -0,0 +1,13 @@
import { IsIn, IsOptional, IsString, MinLength } from 'class-validator';
export class ParseFlyerDto {
@IsOptional()
@IsString()
@MinLength(20)
text?: string;
@IsOptional()
@IsString()
@IsIn(['willys'])
retailer?: 'willys';
}
@@ -0,0 +1,58 @@
import {
BadRequestException,
Body,
Controller,
HttpCode,
Post,
UploadedFile,
UseInterceptors,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { memoryStorage } from 'multer';
import { ParseFlyerDto } from './dto/parse-flyer.dto';
import { FlyerParseResponse, FlyerParsingService } from './flyer-parsing.service';
const ALLOWED_UPLOAD_MIMES = new Set([
'application/pdf',
'application/octet-stream',
'text/plain',
]);
@Controller('flyer')
export class FlyerParsingController {
constructor(private readonly flyerParsingService: FlyerParsingService) {}
@Post('parse')
@HttpCode(200)
@UseInterceptors(
FileInterceptor('file', {
storage: memoryStorage(),
limits: { fileSize: 15 * 1024 * 1024 },
fileFilter: (_req, file, cb) => {
if (ALLOWED_UPLOAD_MIMES.has(file.mimetype)) {
cb(null, true);
return;
}
cb(
new BadRequestException('Otillåten filtyp för flyer-parser. Stöd: PDF eller textfil.'),
false,
);
},
}),
)
async parseFlyer(
@Body() body: ParseFlyerDto,
@UploadedFile() file?: Express.Multer.File,
): Promise<FlyerParseResponse> {
if (!file && !body?.text?.trim()) {
throw new BadRequestException('Skicka antingen fil under "file" eller text i body.text.');
}
const text = body?.text?.trim();
return this.flyerParsingService.parseFlyer({
file,
text,
retailer: body?.retailer ?? 'willys',
});
}
}
@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { FlyerParsingController } from './flyer-parsing.controller';
import { FlyerParsingService } from './flyer-parsing.service';
@Module({
controllers: [FlyerParsingController],
providers: [FlyerParsingService],
})
export class FlyerParsingModule {}
@@ -0,0 +1,193 @@
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires
const pdfParse = require('pdf-parse') as (buffer: Buffer) => Promise<{ text: string }>;
import { normalizeName } from '../common/utils/normalize-name';
const CATEGORY_REGEX = /^(Fisk|Kott|Kött|Mejeri|Gronsaker|Grönsaker|Frukt|Dryck|Brod|Bröd|Pasta|Ris)\b/i;
export type FlyerParseItem = {
rawName: string;
normalizedName: string;
category: string | null;
price: number | null;
priceUnit: string | null;
comparisonPrice: number | null;
comparisonUnit: string | null;
offerText: string | null;
confidence: number;
reasonCodes: string[];
};
export type FlyerParseResponse = {
retailer: 'willys';
parserVersion: 'v1';
items: FlyerParseItem[];
warnings: string[];
};
@Injectable()
export class FlyerParsingService {
private readonly logger = new Logger(FlyerParsingService.name);
async parseFlyer(args: {
file?: Express.Multer.File;
text?: string;
retailer?: 'willys';
}): Promise<FlyerParseResponse> {
const retailer = args.retailer ?? 'willys';
const warnings: string[] = [];
const text = args.text?.trim() || (args.file ? await this.extractTextFromFile(args.file, warnings) : '');
if (!text) {
throw new BadRequestException('Ingen text kunde extraheras från underlaget.');
}
const items = this.parseWillysText(text);
if (items.length === 0) {
warnings.push('Inga produkter kunde tolkas. Kontrollera PDF-kvalitet eller textformat.');
}
return {
retailer,
parserVersion: 'v1',
items,
warnings,
};
}
private async extractTextFromFile(file: Express.Multer.File, warnings: string[]): Promise<string> {
const isPdf =
file.mimetype === 'application/pdf' ||
file.mimetype === 'application/octet-stream' ||
file.originalname?.toLowerCase().endsWith('.pdf');
if (!isPdf) {
throw new BadRequestException('Endast PDF stöds i detta steg för flyer-parsning.');
}
try {
const parsed = await pdfParse(file.buffer);
const text = parsed.text?.trim() ?? '';
if (text) return text;
warnings.push('PDF lästes men textinnehållet var tomt.');
return '';
} catch (err) {
this.logger.warn(`pdf-parse misslyckades för flyer: ${String(err)}`);
throw new BadRequestException('Kunde inte läsa PDF-underlaget.');
}
}
private parseWillysText(text: string): FlyerParseItem[] {
const lines = text
.split('\n')
.map((line) => line.replace(/\s+/g, ' ').trim())
.filter((line) => line.length > 1);
const items: FlyerParseItem[] = [];
let currentCategory: string | null = null;
for (const line of lines) {
const categoryMatch = line.match(CATEGORY_REGEX);
if (categoryMatch) {
currentCategory = this.normalizeCategory(categoryMatch[1]);
continue;
}
if (this.isIgnoredLine(line)) {
continue;
}
const parsed = this.parseProductLine(line, currentCategory);
if (parsed) {
items.push(parsed);
}
}
return this.deduplicate(items);
}
private parseProductLine(line: string, category: string | null): FlyerParseItem | null {
const segments = line.split(/[•|]/g).map((s) => s.trim()).filter(Boolean);
const source = segments.length > 0 ? segments.join(' ') : line;
const compareMatch = source.match(/j[aä]mf[oö]rpris\s*(\d{1,4}(?:[\.,:]\d{1,2})?)\s*kr\s*\/\s*([a-zåäö]+)/i);
const priceMatch = source.match(/(?:^|\s)(\d{1,4}(?:[\.,:]\d{1,2})?)\s*kr(?:\s*\/\s*([a-zåäö]+))?/i);
const offerMatch = source.match(/(max\s*\d+\s*(?:k[öo]p|f[öo]rp)\/?hush[åa]ll|l[aä]gsta\s*30-dgrspris\s*\d+[\.,:]?\d*\s*kr)/i);
const nameCandidate = this.extractNameCandidate(segments.length > 0 ? segments[0] : line);
if (!nameCandidate) {
return null;
}
const reasonCodes: string[] = [];
if (priceMatch) reasonCodes.push('price_found');
if (compareMatch) reasonCodes.push('comparison_price_found');
if (offerMatch) reasonCodes.push('offer_found');
if (segments.length > 1) reasonCodes.push('bullet_structured_line');
if (reasonCodes.length === 0) {
return null;
}
const confidence = Math.min(0.99, 0.45 + reasonCodes.length * 0.15);
return {
rawName: nameCandidate,
normalizedName: normalizeName(nameCandidate),
category,
price: priceMatch ? this.parseSvNumber(priceMatch[1]) : null,
priceUnit: priceMatch?.[2]?.toLowerCase() ?? null,
comparisonPrice: compareMatch ? this.parseSvNumber(compareMatch[1]) : null,
comparisonUnit: compareMatch?.[2]?.toLowerCase() ?? null,
offerText: offerMatch?.[1] ?? null,
confidence,
reasonCodes,
};
}
private extractNameCandidate(raw: string): string | null {
const cleaned = raw
.replace(/j[aä]mf[oö]rpris.*$/i, ' ')
.replace(/\b\d{1,4}(?:[\.,:]\d{1,2})?\s*kr(?:\s*\/\s*[a-zåäö]+)?\b/gi, ' ')
.replace(/\b(max\s*\d+\s*(?:k[öo]p|f[öo]rp)\/?hush[åa]ll|l[aä]gsta\s*30-dgrspris.*)$/i, ' ')
.replace(/\s+/g, ' ')
.trim();
if (cleaned.length < 2) return null;
if (!/[a-zåäö]/i.test(cleaned)) return null;
return cleaned;
}
private normalizeCategory(value: string): string {
const normalized = value
.toLowerCase()
.replace('kott', 'kött')
.replace('gronsaker', 'grönsaker')
.replace('brod', 'bröd');
return normalized.charAt(0).toUpperCase() + normalized.slice(1);
}
private parseSvNumber(value: string): number {
return Number.parseFloat(value.replace(':', '.').replace(',', '.'));
}
private isIgnoredLine(line: string): boolean {
const normalized = line.trim().toLowerCase();
if (normalized.length < 3) return true;
if (/^(willys|vecka|erbjudande|g[aä]ller|reservation)/i.test(normalized)) return true;
if (/^\d+\s*\/\s*\d+/.test(normalized)) return true;
return false;
}
private deduplicate(items: FlyerParseItem[]): FlyerParseItem[] {
const seen = new Set<string>();
const unique: FlyerParseItem[] = [];
for (const item of items) {
const key = `${item.normalizedName}|${item.price ?? ''}|${item.comparisonPrice ?? ''}|${item.category ?? ''}`;
if (seen.has(key)) continue;
seen.add(key);
unique.push(item);
}
return unique;
}
}
@@ -4,7 +4,8 @@
Logger,
ServiceUnavailableException,
} from '@nestjs/common';
import pdfParse from 'pdf-parse';
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires
const pdfParse = require('pdf-parse') as (buffer: Buffer) => Promise<{ text: string }>;
const MISTRAL_API_URL = 'https://api.mistral.ai/v1/chat/completions';
const RECEIPT_VISION_MODEL = 'mistral-small-2603'; // vision — används för bild-input
@@ -257,6 +258,17 @@ function ruleBasedParseLine(line: string): ParsedReceiptItemRaw | null {
return null;
}
/**
* Avgör om en rad troligen är en produktrad (har namnliknande text OCH ett
* numeriskt värde som tyder på pris eller kvantitet). Rader utan siffror är
* troligen header/footer i PDF:en och behöver inte AI-tolkning.
*/
function looksLikeReceiptProductLine(line: string): boolean {
if (!isLikelyNameLikeText(line)) return false;
// Måste innehålla minst ett tal (pris, vikt, antal)
return /\d/.test(line);
}
@Injectable()
export class ReceiptParsingService {
private readonly logger = new Logger(ReceiptParsingService.name);
@@ -288,10 +300,12 @@ export class ReceiptParsingService {
} catch (err) {
this.logger.warn(`pdf-parse misslyckades: ${err}`);
// Fallback to pdfjs-dist for more complex PDFs
// Fallback to pdfjs-dist legacy build (Node.js compatible, no DOMMatrix needed)
try {
const pdfjsLib = await import('pdfjs-dist');
const loadingTask = pdfjsLib.getDocument({ data: buffer });
// eslint-disable-next-line @typescript-eslint/no-require-imports
const pdfjsLib = require('pdfjs-dist/legacy/build/pdf.js') as typeof import('pdfjs-dist');
pdfjsLib.GlobalWorkerOptions.workerSrc = '';
const loadingTask = pdfjsLib.getDocument({ data: new Uint8Array(buffer) });
const pdfDocument = await loadingTask.promise;
let fullText = '';
@@ -329,10 +343,18 @@ export class ReceiptParsingService {
}
}
this.logger.log(`PDF: ${resolved.length} rader lösta regelbaserat, ${needsAI.length} skickas till AI`);
// Filtrera bort PDF-skräp (butiksnamn, datum, header/footer) innan AI-anrop.
// En rad behöver AI bara om den ser ut som en produktrad (namntext + siffra).
const productLinesForAI = needsAI.filter(looksLikeReceiptProductLine);
const discarded = needsAI.length - productLinesForAI.length;
if (needsAI.length > 0) {
const aiItems = await this.callMistralText(needsAI, apiKey);
this.logger.log(
`PDF: ${resolved.length} regelbaserade, ${productLinesForAI.length} till AI` +
(discarded > 0 ? `, ${discarded} rader kasserade (ej produktrader)` : ''),
);
if (productLinesForAI.length > 0) {
const aiItems = await this.callMistralText(productLinesForAI, apiKey);
resolved.push(...aiItems);
}
@@ -437,7 +459,7 @@ export class ReceiptParsingService {
const err = await response.text();
this.logger.warn(`Mistral ${response.status} (${source}, försök ${attempt}/${MAX_RETRIES}): ${err}`);
if (attempt < MAX_RETRIES) {
await new Promise((resolve) => setTimeout(resolve, 1000 * attempt));
await new Promise((resolve) => setTimeout(resolve, 3000 * attempt));
continue;
}
throw new ServiceUnavailableException('Mistral API är tillfälligt otillgänglig. Försök igen.');
+8
View File
@@ -23,6 +23,14 @@ interface ParsedRecipe {
// Parser Functions
// ============================================================================
/**
* NOTERING: Denna parseRecipeMarkdown-funktion är duplicerad i
* recipe-app/backend/src/common/utils/recipe-parser.ts
*
* TODO: Extrahera till shared npm-paket eller flytta till en location
* som båda kan importera från för att undvika version-drift.
*/
/**
* Parsar ett recept i Markdown-format och extraherar namn, beskrivning,
* instruktioner och ingredienser.
File diff suppressed because one or more lines are too long
+40 -57
View File
@@ -1,17 +1,23 @@
# Plan för vidareutveckling av Microservice Importer
## Dokumentstatus (2026-05-03)
## Dokumentstatus (2026-05-12)
Detta dokument riktar sig till utvecklare och driftansvariga för microservice-importer.
Detta dokument riktar sig till utvecklare och driftansvariga för microservice-importer. Teknisk referens finns i [TEKNISK_BESKRIVNING.md](TEKNISK_BESKRIVNING.md).
### Senast avklarat i angränsande flöden
- Regelbaserad kvittotolkning har stärkts för multipack/enheter och svårare radformat.
- Bröd-/rostbrödklassning har utökade guardrails för att minska felaktig kategorisering.
- Quality-gates tillagda (2026-05-12): `typecheck`, `audit:high`, `quality:ci` i `backend/package.json`. CI kör nu `npm ci`, typecheck, build och audit. 0 sårbarheter.
- Beroenden uppgraderade (2026-05-12): Nest 11-serien + multer 2.1.1 + `@types/express` 5 + Nest CLI 11.
- Regelbaserad kvittotolkning har stärkts för multipack, enheter och svårare radformat.
- Bröd- och rostbrödklassning har utökade guardrails för att minska felaktig kategorisering.
- Klientens granskningsflöde och sessionpersistens i Flutter är implementerat, vilket minskar avbrott mellan parse och spara.
- Kvittokategorisering: nya regler för pasta, grädde, ägg, juice, godis, och potatis samt justerad AI-guardrail.
- Testinfrastruktur: parametriserade enhetstester för kvittoimport (18 testfall) och CI/CD-pipeline med automatiserad testkörning på push.
- Kvittokategorisering: nya regler för pasta, grädde, ägg, juice, godis och potatis samt justerad AI-guardrail.
- Testinfrastruktur: parametriserade enhetstester för kvittoimport och CI/CD-pipeline med automatiserad testkörning på push.
- PDF-parsning stabiliserad med `require()` och `pdfjs-dist/legacy/build/pdf.js` som fallback.
- Retry-logik förbättrad för 429/503.
- Reproducerbart bygge via `package-lock.json` och `npm ci`.
- `looksLikeReceiptProductLine()` filtrerar bort PDF-rader utan siffra innan Mistral-anrop.
## Status (2026-05-03) — Driftsatt och integrerad med recipe-app
## Status (2026-05-10) — Driftsatt och integrerad med recipe-app
`microservice-importer` körs som intern tjänst (`importer-api`) i `recipe-app/compose.yml`. Alla importflöden är delegerade och driftsatta.
@@ -47,7 +53,7 @@ cd /opt/containers/recipe-app && git pull && ./deploy.sh
### Medel prioritet
- **Fler webbplats-parsers** — Specifika parsers för t.ex. Tasteline, Köket.se, Arla
- **Swagger/OpenAPI** — Automatisk API-dokumentation via `@nestjs/swagger`
- **Testtäckning** — Utökad enhetstesttäckning för parsers och `receipt-parsing.service.ts` (18 testfall för kvittoimport)
- **Testtäckning** — Utökad enhetstesttäckning för parsers och `receipt-parsing.service.ts`
### Låg prioritet / Framtida
- **Caching** — Cacha skrapade sidor för att minska belastning på externa webbplatser
@@ -58,75 +64,52 @@ cd /opt/containers/recipe-app && git pull && ./deploy.sh
## AI-optimering: Mistral-modell och pipeline
**Nuläge:** `mistral-small-2603` används för kvittoparsning. Modellen tar emot hela kvittobilden/PDF-texten och returnerar strukturerad JSON.
Nuläget är att `mistral-small-2603` används för bildinput (vision). För PDF-flödet extraheras text först via `pdf-parse` eller `pdfjs-dist`; därefter körs regelbaserad parsning och AI bara för kvarvarande rader.
### Möjlig optimering: AI sist i pipeline
### Implementerad optimering: AI sist i pipeline (PDF)
Eftersom tjänsten redan har OCR (Tesseract) och regelbaserad parsning (regex för `NxYg`, `Ydl`, `Y kg` etc.) finns möjlighet att omstrukturera kvittopipelinen:
Kvittopipelinen för PDF ser nu ut:
```
Bild/PDF → OCR/pdf-parse → Regelbaserad parsning → AI (för rader som inte lösts ut)
```text
PDF → pdf-parse / pdfjs-dist → preprocessPdfLines → isIgnoredReceiptLine → ruleBasedParseLine → looksLikeReceiptProductLine → AI
```
**Fördelar:**
- Regelbaserad parsning hanterar standardfall gratis och snabbt (t.ex. "MJÖLK 1,5L", "BLANDFÄRS 997G")
- AI anropas bara för rader som regelverket inte kan tolka entydigt
- Möjlighet att använda en mindre/billigare modell för enklare tolkningsuppgifter
`looksLikeReceiptProductLine(line)` filtrerar bort rader som saknar siffra innan Mistral-anropet. Enbart rader med namnliknande text och minst ett tal skickas till AI.
### Modellval för olika deluppgifter
| Uppgift | Rekommenderad modell | Motivering |
|---|---|---|
| Kvittoparsning (hela bilden, nuläge) | `mistral-small-2603` | Vision-förmåga krävs för bild-input |
| Tolka OCR-text (textbaserad input) | `mistral-small-latest` eller mindre | Enklare uppgift när text redan extraherats |
| Kategorisering av enskild produktrad | `open-mistral-nemo` (7B) | Klassificering, ej vision — kan vara mycket liten |
| Kvittoparsning (hela bilden) | `mistral-small-2603` | Vision-förmåga krävs för bild-input |
| Tolka OCR-text | `mistral-small-latest` eller mindre | Enklare uppgift när text redan extraherats |
| Kategorisering av enskild produktrad | `open-mistral-nemo` (7B) | Klassificering utan vision |
**OBS:** För bild-input (JPEG/PNG/HEIC/WebP) krävs alltid en vision-kapabel modell. Optimering med mindre modell är bara möjlig när Tesseract/pdf-parse redan har extraherat text.
För bild-input krävs alltid en vision-kapabel modell.
---
## Framtida förbättringar
### Schemalagd Uppdatering av Kategorier
- **Mål:** Implementera en schemalagd uppdatering av kategorierna en gång i veckan för att säkerställa att cachen alltid är uppdaterad.
- **Metod:** Använda `cron` för att schemalägga ett anrop till `POST /receipt-import/refresh-categories` en gång i veckan.
- Schemalagd uppdatering av kategorier om det visar sig nödvändigt igen.
---
## Nuvarande implementering
## Nuvarande Implementering
### Manuell Uppdatering av Kategorier
- **Mål:** Låta användaren manuellt uppdatera kategorierna via Flutter-UI.
- **Implementering:**
- En knapp i Flutter-UI:n som låter användaren trigga uppdateringen.
- Anropa `POST /receipt-import/refresh-categories` från Flutter-UI:n när användaren klickar på knappen.
```dart
// Exempel på hur du kan anropa endpointen från Flutter
Future<void> refreshCategories() async {
final response = await http.post(
Uri.parse('http://YOUR_API_URL/receipt-import/refresh-categories'),
headers: {'Authorization': 'Bearer YOUR_JWT_TOKEN'},
);
if (response.statusCode == 200) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Kategorier har uppdaterats.')),
);
} else {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Misslyckades med att uppdatera kategorier.')),
);
}
}
```
- Ingen separat manuell kategoriuppdatering via Flutter-UI finns längre.
- Kvittoparsningen använder i stället nuvarande regler, retry-logik och filtrering i backend.
---
## Arkitektur-noteringar
- Tjänsten är **helt stateless** — ingen databas, ingen session
- Kommunicerar **aldrig direkt** med internet-klienter — exponeras bara på `recipe-internal`-nätverket
- `MISTRAL_API_KEY` injiceras via env (samma nyckel som `recipe-api` använder)
- Alpine Docker-image: systempaket `tesseract-ocr`, `tesseract-ocr-data-swe`, `tesseract-ocr-data-eng` installerade via `apk`
- Host-port 3001 är upptagen av `wetty` på servern — `importer-api` exponeras aldrig till host
- Tjänsten är helt stateless och saknar databas
- Den exponeras bara på `recipe-internal`-nätverket
- `MISTRAL_API_KEY` injiceras via env
- Alpine Docker-image använder `tesseract-ocr`, `tesseract-ocr-data-swe`, `tesseract-ocr-data-eng`
- Host-port 3001 är upptagen av `wetty`, så `importer-api` exponeras aldrig till host
## Referenser
- [README.md](README.md)
- [TEKNISK_BESKRIVNING.md](TEKNISK_BESKRIVNING.md)
## 2026-05-10: Admin-inventarie (CRUD, merge, filter, sortering, preview, säkerhet), user-scope, IDOR-skydd, säkerhetshärdning, optimeringar och utökad testtäckning är nu genomförda och dokumenterade i README, TEKNISK_BESKRIVNING, SÄKERHETSHÄRDNINGSPLAN och SESSIONLOGGAR.
+946
View File
@@ -0,0 +1,946 @@
Här är en **komplett plan i markdown-format** som du kan spara som en fil (t.ex. `premium_recipe_feature_plan.md`) och ladda upp i **VS Code-chatten** för att implementera **AI som en premium-funktion** för receptgenerering. Filen är strukturerad för att vara **lätt att följa** och innehåller alla nödvändiga steg, kodsnuttar och förklaringar.
---
```markdown
# 📌 Plan: Implementera AI som Premium-Funktion för Receptgenerering
---
## **🎯 Översikt**
Denna plan beskriver hur du implementerar **AI som en premium-funktion** i din app, där:
- **Gratis-användare** får **grundläggande recept** (genererade från mallar).
- **Premium-användare** får **AI-genererade recept** (mer kreativa, personliga och intelligenta).
- Systemet är **modulärt** och kan enkelt utökas.
---
---
## **📋 Förutsättningar**
1. **Node.js/TypeScript** (backend).
2. **Prisma** (databas).
3. **pdf-parse** och **Tesseract.js** (PDF-extrahering).
4. **Mistral API-nyckel** (för AI-funktioner).
5. **Frontend-ramverk** (t.ex. React, Next.js, eller liknande).
---
---
## **🛠️ Databasschema (Prisma)**
Lägg till fält för att spåra **premium-status** i din `user`-tabell:
```prisma
model User {
id Int @id @default(autoincrement())
// ... andra fält ...
is_premium Boolean @default(false)
premium_expiry_date DateTime?
}
```
**SQL för att lägga till fält om tabellen redan finns:**
```sql
ALTER TABLE "User" ADD COLUMN "is_premium" BOOLEAN DEFAULT false;
ALTER TABLE "User" ADD COLUMN "premium_expiry_date" TIMESTAMP;
```
---
---
## **📁 Filstruktur**
```
src/
├── services/
│ ├── pdfTextExtractor.ts # Extraherar text från PDF (pdf-parse + OCR)
│ ├── willysParser.ts # Parsar text till strukturerad data (regex)
│ ├── inventoryMatcher.ts # Matchar produkter med inventory (fuzzy matching)
│ └── recipeGenerator.ts # Genererar recept (AI för premium, mallar för gratis)
├── api/
│ ├── pdfImport.ts # API-endpoint för PDF-import
│ └── premium.ts # API-endpoint för premium-uppgradering
└── types/
└── recipeTypes.ts # Typer för recept och produkter
```
---
---
## **📄 1. `pdfTextExtractor.ts` Extrahera text från PDF**
Använder `pdf-parse` som primär metod och `Tesseract.js` som fallback för OCR.
```typescript
import * as fs from 'fs';
import * as pdf from 'pdf-parse';
import Tesseract from 'tesseract.js';
/**
* Extraherar text från en PDF-fil, med fallback till OCR om nödvändigt.
* @param pdfPath Sökväg till PDF-filen.
* @returns Extraherad text.
*/
export async function extractTextFromPDF(pdfPath: string): Promise<string> {
try {
const dataBuffer = fs.readFileSync(pdfPath);
const data = await pdf(dataBuffer);
if (data.text.trim()) return data.text;
} catch (error) {
console.warn('pdf-parse misslyckades, försöker med OCR...', error);
}
try {
const { data: { text } } = await Tesseract.recognize(pdfPath, 'swe', {
logger: (m) => console.log(m),
});
return text;
} catch (error) {
console.error('OCR misslyckades:', error);
throw new Error('Kunde inte extrahera text från PDF:en.');
}
}
```
---
---
## **📄 2. `willysParser.ts` Parsa text till strukturerad data**
Använder **regex** för att extrahera produktnamn, priser, jämförpriser, etc. från Willys veckoblad.
```typescript
/**
* Parsar text från Willys veckoblad till strukturerad data.
* @param text Den extraherade texten.
* @returns Strukturerad data (JSON-array).
*/
export function parseWillysText(text: string): any[] {
const lines = text.split('\n');
const products: any[] = [];
let currentProduct: any = {};
let currentCategory: string | null = null;
// Regex-mönster
const productLineRegex = /^(.*?)\s*•\s*(.*?)\s*•\s*(.*?)\s*•?\s*Jämförpris\s*([\d:]+)\s*kr\/([a-z]+)/i;
const simpleProductLineRegex = /^(.*?)\s*•\s*(.*?)\s*•\s*(.*?)$/i;
const priceLineRegex = /^([\d:]+)\s*(Per\s*(förp|kg|st|l|))?/i;
const offerLineRegex = /^(Max \d+ (köp|förp)\/hushåll|Lägsta 30-dgrspris [\d:]+ kr)/i;
const categoryLineRegex = /^(Fisk|Kött|Mejeri|Grönsaker|Frukt|Dryck|Bröd|Pasta|Ris)/i;
for (const line of lines) {
const trimmedLine = line.trim();
if (!trimmedLine) continue;
// Matcha kategori
const categoryMatch = trimmedLine.match(categoryLineRegex);
if (categoryMatch) {
currentCategory = categoryMatch[1];
continue;
}
// Matcha produktrad (med jämförpris)
const productMatch = trimmedLine.match(productLineRegex);
if (productMatch) {
if (Object.keys(currentProduct).length > 0) {
currentProduct.category = currentCategory;
products.push(currentProduct);
currentProduct = {};
}
currentProduct = {
name: productMatch[1].trim(),
weight: productMatch[2].trim(),
origin: productMatch[3].trim(),
comparisonPrice: `${productMatch[4].replace(':', '.')} kr/${productMatch[5]}`,
category: currentCategory,
};
continue;
}
// Matcha enkel produktrad
const simpleProductMatch = trimmedLine.match(simpleProductLineRegex);
if (simpleProductMatch) {
if (Object.keys(currentProduct).length > 0) {
currentProduct.category = currentCategory;
products.push(currentProduct);
currentProduct = {};
}
currentProduct = {
name: simpleProductMatch[1].trim(),
weight: simpleProductMatch[2].trim(),
origin: simpleProductMatch[3].trim(),
category: currentCategory,
};
continue;
}
// Matcha pris
const priceMatch = trimmedLine.match(priceLineRegex);
if (priceMatch) {
currentProduct.price = `${priceMatch[1].replace(':', '.')} kr/${priceMatch[2]?.trim() || 'förp'}`;
continue;
}
// Matcha erbjudande
const offerMatch = trimmedLine.match(offerLineRegex);
if (offerMatch) {
currentProduct.offer = offerMatch[1];
continue;
}
}
if (Object.keys(currentProduct).length > 0) {
currentProduct.category = currentCategory;
products.push(currentProduct);
}
return products;
}
```
---
---
## **📄 3. `inventoryMatcher.ts` Matcha produkter med inventory**
Använder **fuzzy matching** för att matcha produktnamn från PDF:en med användarens inventory.
```typescript
import { stringSimilarity } from 'string-similarity';
/**
* Matchar produkter från PDF:en med användarens inventory.
* @param products Produkter från PDF:en.
* @param userId Användarens ID.
* @returns Matchade produkter med inventory-status.
*/
export async function matchProductsWithInventory(products: any[], userId: number) {
const inventory = await prisma.userInventory.findMany({
where: { userId },
});
const inventoryNames = inventory.map((item) => ({
name: item.name.toLowerCase(),
id: item.id,
quantity: item.quantity,
category: item.category,
}));
return products.map((product) => {
const productName = product.name.toLowerCase();
const bestMatch = inventoryNames.reduce(
(best, item) => {
const similarity = stringSimilarity.compareTwoStrings(productName, item.name);
return similarity > best.similarity ? { ...item, similarity } : best;
},
{ name: '', similarity: 0, id: null, quantity: 0, category: null }
);
const inventoryItem = bestMatch.similarity > 0.6
? inventory.find((item) => item.id === bestMatch.id)
: null;
return {
...product,
inInventory: !!inventoryItem,
inventoryId: inventoryItem?.id || null,
inventoryQuantity: inventoryItem?.quantity || 0,
inventoryMatchSimilarity: bestMatch.similarity,
};
});
}
```
---
---
## **📄 4. `recipeGenerator.ts` Generera recept (AI eller mallar)**
Modulär funktion som använder **AI för premium-användare** och **mallar för gratis-användare**.
```typescript
import { MistralClient } from '@mistralai/mistralai';
import { prisma } from '../prisma'; // Antas att Prisma är konfigurerat
const mistral = new MistralClient({ apiKey: process.env.MISTRAL_API_KEY });
/**
* Genererar recept baserat på matchade produkter.
* @param matchedProducts Produkter matchade med inventory.
* @param userId Användarens ID.
* @param userPreferences Användarens preferenser.
* @returns Genererade recept.
*/
export async function generateRecipes(
matchedProducts: any[],
userId: number,
userPreferences: any = {}
): Promise<any[]> {
const user = await prisma.user.findUnique({ where: { id: userId } });
const isPremium = user?.is_premium || false;
if (isPremium) {
return generateRecipesWithAI(matchedProducts, userPreferences);
} else {
return generateRecipesFromTemplates(matchedProducts);
}
}
/**
* Genererar AI-recept för premium-användare.
*/
async function generateRecipesWithAI(matchedProducts: any[], userPreferences: any): Promise<any[]> {
const relevantProducts = matchedProducts.filter((p) => p.inInventory || p.offer);
if (relevantProducts.length === 0) return [];
const prompt = `
Du är en kock som skapar kostnadseffektiva, läckra och personliga recept.
Skapa **3 recept** baserat på följande produkter:
${JSON.stringify(relevantProducts, null, 2)}
Användarens preferenser:
- Diet: ${userPreferences.diet || 'Inga restriktioner'}
- Köksstil: ${userPreferences.cuisine || 'Nordisk'}
- Tid: ${userPreferences.time || 'Under 30 minuter'}
Recepten ska:
1. Använda produkter från inventory eller på erbjudande.
2. Vara lätt att laga.
3. Inkludera näringsinformation (uppskattad).
4. Vara på svenska.
Returnera recepten i JSON-format:
{
"recipes": [
{
"title": "Receptets titel",
"description": "Beskrivning",
"ingredients": [
{"name": "Produktnamn", "quantity": "Mängd", "fromInventory": true/false, "onOffer": true/false}
],
"instructions": ["Steg 1", "Steg 2"],
"cost": "Uppskattad kostnad",
"time": "Tillagningstid",
"nutritionalInfo": {"calories": "Kalorier", "protein": "Protein (g)"}
}
]
}
`;
try {
const response = await mistral.chat({
model: 'mistral-small-latest',
messages: [{ role: 'user', content: prompt }],
temperature: 0.7,
});
const jsonString = response.choices[0].message.content
.replace(/```json|```/g, '')
.trim();
const parsed = JSON.parse(jsonString);
return parsed.recipes || [];
} catch (error) {
console.error('AI-generering misslyckades:', error);
return generateRecipesFromTemplates(matchedProducts);
}
}
/**
* Genererar recept från mallar för gratis-användare.
*/
function generateRecipesFromTemplates(matchedProducts: any[]): any[] {
const recipes: any[] = [];
const relevantProducts = matchedProducts.filter((p) => p.inInventory || p.offer);
if (relevantProducts.length === 0) return recipes;
const productsByCategory: Record<string, any[]> = {};
relevantProducts.forEach((product) => {
const category = product.category || 'Okänt';
if (!productsByCategory[category]) productsByCategory[category] = [];
productsByCategory[category].push(product);
});
const templates: Record<string, any[]> = {
Fisk: [
{
title: 'Lax med potatis och grönsaker',
ingredients: [
{ name: 'Lax', category: 'Fisk', required: true },
{ name: 'Potatis', category: 'Grönsaker', required: true },
],
instructions: [
'Koka potatisen i 15 minuter.',
'Stek laxen i 5 minuter på varje sida.',
'Servera med grönsaker.',
],
time: '25 minuter',
},
],
Kött: [
{
title: 'Köttbullar med potatismos',
ingredients: [
{ name: 'Köttfärs', category: 'Kött', required: true },
{ name: 'Potatis', category: 'Grönsaker', required: true },
],
instructions: [
'Blanda köttfärs med salt och peppar.',
'Stek köttbullarna i en panna.',
'Koka och mosa potatisen.',
],
time: '30 minuter',
},
],
};
for (const [category, categoryTemplates] of Object.entries(templates)) {
const categoryProducts = productsByCategory[category] || [];
for (const template of categoryTemplates) {
const missingIngredients = template.ingredients.filter(
(ing: any) => ing.required && !categoryProducts.some((p) =>
p.name.toLowerCase().includes(ing.name.toLowerCase())
)
);
if (missingIngredients.length === 0) {
recipes.push({
title: template.title,
description: `Ett enkelt ${category.toLowerCase()}-recept.`,
ingredients: template.ingredients.map((ing: any) => {
const matchedProduct = categoryProducts.find((p) =>
p.name.toLowerCase().includes(ing.name.toLowerCase())
);
return {
name: matchedProduct?.name || ing.name,
quantity: matchedProduct?.weight || '1 portion',
fromInventory: matchedProduct?.inInventory || false,
onOffer: matchedProduct?.offer ? true : false,
};
}),
instructions: template.instructions,
time: template.time,
cost: calculateRecipeCost(template.ingredients, categoryProducts),
});
}
}
}
return recipes;
}
function calculateRecipeCost(ingredients: any[], products: any[]): string {
let totalCost = 0;
for (const ing of ingredients) {
const matchedProduct = products.find((p) =>
p.name.toLowerCase().includes(ing.name.toLowerCase())
);
if (matchedProduct?.price) {
const price = parseFloat(matchedProduct.price.replace(/[^\d.]/g, ''));
totalCost += price;
}
}
return `Ca ${totalCost.toFixed(2)} kr`;
}
```
---
---
## **📄 5. `pdfImportService.ts` Fullständigt importflöde**
Haneterar hela flödet från PDF-import till receptgenerering.
```typescript
import { extractTextFromPDF } from './pdfTextExtractor';
import { parseWillysText } from './willysParser';
import { matchProductsWithInventory } from './inventoryMatcher';
import { generateRecipes } from './recipeGenerator';
import { prisma } from '../prisma';
/**
* Importerar en PDF och genererar recept.
* @param pdfPath Sökväg till PDF-filen.
* @param userId Användarens ID.
* @param userPreferences Användarens preferenser.
* @returns Resultat med produkter och recept.
*/
export async function importPDFAndGenerateRecipes(
pdfPath: string,
userId: number,
userPreferences: any = {}
) {
try {
// 1. Extrahera text
const text = await extractTextFromPDF(pdfPath);
// 2. Parsa texten
const products = parseWillysText(text);
// 3. Matcha med inventory
const matchedProducts = await matchProductsWithInventory(products, userId);
// 4. Generera recept
const recipes = await generateRecipes(matchedProducts, userId, userPreferences);
// 5. Spara recepten
const savedRecipes = [];
for (const recipe of recipes) {
const savedRecipe = await prisma.encryptedData.create({
data: {
encrypted_data: JSON.stringify(recipe),
owner_id: userId,
created_at: new Date(),
is_generated: true,
source: user.is_premium ? 'AI (Premium)' : 'Mallar (Gratis)',
},
});
savedRecipes.push(savedRecipe);
}
return {
success: true,
products: matchedProducts,
recipes: savedRecipes,
isPremium: await isUserPremium(userId),
};
} catch (error) {
console.error('Fel vid import:', error);
return { success: false, error: error instanceof Error ? error.message : 'Okänt fel' };
}
}
/**
* Kontrollerar om en användare är premium.
*/
async function isUserPremium(userId: number): Promise<boolean> {
const user = await prisma.user.findUnique({ where: { id: userId } });
if (!user) return false;
if (user.premium_expiry_date && new Date(user.premium_expiry_date) < new Date()) {
await prisma.user.update({
where: { id: userId },
data: { is_premium: false },
});
return false;
}
return user.is_premium;
}
```
---
---
## **📄 6. API-Endpoints**
### **🔹 PDF-import (`/api/import/pdf`)**
```typescript
import express from 'express';
import multer from 'multer';
import { importPDFAndGenerateRecipes } from '../services/pdfImportService';
const router = express.Router();
const upload = multer({ dest: 'uploads/' });
router.post('/import/pdf', upload.single('pdf'), async (req, res) => {
try {
if (!req.file) {
return res.status(400).json({ error: 'Ingen PDF-fil uppladdad.' });
}
const userId = req.user.id;
const userPreferences = req.body.preferences || {};
const result = await importPDFAndGenerateRecipes(
req.file.path,
userId,
userPreferences
);
fs.unlinkSync(req.file.path); // Rensa upp
if (!result.success) {
return res.status(500).json({ error: result.error });
}
res.json(result);
} catch (error) {
console.error('Fel vid PDF-import:', error);
res.status(500).json({ error: 'Kunde inte importera PDF:en.' });
}
});
export default router;
```
### **🔹 Premium-uppgradering (`/api/user/upgrade`)**
```typescript
router.post('/upgrade-premium', async (req, res) => {
try {
const userId = req.user.id;
// Antas att betalning är verifierad (t.ex. via Stripe)
const expiryDate = new Date();
expiryDate.setMonth(expiryDate.getMonth() + 1); // 1 månad premium
await prisma.user.update({
where: { id: userId },
data: {
is_premium: true,
premium_expiry_date: expiryDate,
},
});
res.json({ success: true, premiumExpiryDate: expiryDate });
} catch (error) {
console.error('Fel vid uppgradering:', error);
res.status(500).json({ error: 'Kunde inte uppgradera till premium.' });
}
});
```
---
---
## **📄 7. Frontend-Integrering (Exempel: React)**
### **🔹 Visa premium-status**
```tsx
import { useState, useEffect } from 'react';
function PremiumStatus({ user }) {
const [isPremium, setIsPremium] = useState(user.is_premium);
useEffect(() => {
setIsPremium(user.is_premium);
}, [user]);
return (
<div className="premium-status">
{isPremium ? (
<div className="premium-badge">
✅ Premium (gäller till: {new Date(user.premium_expiry_date).toLocaleDateString('sv-SE')})
<p>Du har tillgång till AI-genererade recept!</p>
</div>
) : (
<div className="upgrade-prompt">
🔒 <a href="/upgrade">Uppgradera till premium</a> för AI-recept!
</div>
)}
</div>
);
}
```
### **🔹 PDF-import-formulär**
```tsx
import { useState } from 'react';
import axios from 'axios';
function PDFImportForm({ user }) {
const [file, setFile] = useState<File | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [result, setResult] = useState<any>(null);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!file) return;
setIsLoading(true);
const formData = new FormData();
formData.append('pdf', file);
formData.append('preferences', JSON.stringify({
diet: 'Inga restriktioner',
cuisine: 'Nordisk',
}));
try {
const response = await axios.post('/api/import/pdf', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
setResult(response.data);
} catch (error) {
console.error('Fel vid uppladdning:', error);
} finally {
setIsLoading(false);
}
};
return (
<div>
<h2>Importera veckoblad</h2>
<form onSubmit={handleSubmit}>
<input
type="file"
accept=".pdf"
onChange={(e) => setFile(e.target.files?.[0] || null)}
required
/>
<button type="submit" disabled={isLoading}>
{isLoading ? 'Laddar upp...' : 'Importera'}
</button>
</form>
{result && (
<div>
<h3>Resultat</h3>
<p>Hittade {result.products?.length || 0} produkter.</p>
<p>Genererade {result.recipes?.length || 0} recept.</p>
{result.recipes?.map((recipe: any, index: number) => (
<div key={index} className="recipe-card">
<h4>{recipe.title}</h4>
<p>{recipe.description}</p>
<ul>
{recipe.ingredients?.map((ing: any, i: number) => (
<li key={i}>
{ing.quantity} {ing.name} {ing.onOffer && '🔥'}
</li>
))}
</ul>
</div>
))}
</div>
)}
</div>
);
}
```
---
---
## **📌 Miljövariabler (`.env`)**
```env
# Mistral API-nyckel (för premium-funktioner)
MISTRAL_API_KEY=din_api_nyckel_här
# Databas-URL
DATABASE_URL=postgresql://user:password@localhost:5432/db_name
# Session-hemlighet (för autentisering)
SESSION_SECRET=din_hemliga_nyckel_här
```
---
---
## **📌 Paket som behövs (`package.json`)**
```json
{
"dependencies": {
"express": "^4.18.2",
"multer": "^1.4.5-lts.1",
"pdf-parse": "^1.1.1",
"tesseract.js": "^4.0.2",
"@mistralai/mistralai": "^0.0.1",
"@prisma/client": "^5.0.0",
"prisma": "^5.0.0",
"string-similarity": "^4.0.4",
"typescript": "^5.0.0"
}
}
```
---
---
## **📌 Steg-för-steg Implementeringsguide**
### **1. Förbered databasen**
- Kör migrationskommandot för att lägga till `is_premium` och `premium_expiry_date` i `User`-tabellen:
```bash
npx prisma migrate dev --name add_premium_fields
```
### **2. Installera beroenden**
```bash
npm install express multer pdf-parse tesseract.js @mistralai/mistralai string-similarity
npm install --save-dev typescript @types/node @types/express @types/multer prisma
```
### **3. Konfigurera Prisma**
- Skapa eller uppdatera `schema.prisma` med `User`-modellen (se ovan).
- Kör `npx prisma generate` för att generera Prisma-klienten.
### **4. Skapa filerna**
- Skapa mappen `src/services/` och lägg till filerna:
- `pdfTextExtractor.ts`
- `willysParser.ts`
- `inventoryMatcher.ts`
- `recipeGenerator.ts`
- `pdfImportService.ts`
- Skapa mappen `src/api/` och lägg till:
- `pdfImport.ts`
- `premium.ts`
### **5. Konfigurera Express-server**
- Skapa en `server.ts` för att starta din Express-server:
```typescript
import express from 'express';
import pdfImportRouter from './api/pdfImport';
import premiumRouter from './api/premium';
const app = express();
app.use(express.json());
// API-endpoints
app.use('/api/import', pdfImportRouter);
app.use('/api/user', premiumRouter);
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`Server körs på port ${PORT}`);
});
```
### **6. Testa flödet**
1. **Ladda upp en PDF** (t.ex. Willys veckoblad) via `/api/import/pdf`.
2. **Kontrollera att recept genereras** (mallar för gratis, AI för premium).
3. **Testa premium-uppgraderingen** via `/api/user/upgrade`.
---
---
## **📌 Säkerhetsöverväganden**
1. **API-nycklar**:
- Lagra `MISTRAL_API_KEY` i miljövariabler (aldrig i koden).
2. **Premium-status**:
- Kontrollera alltid `is_premium` innan AI-anrop.
3. **Filuppladdning**:
- Validera filtypen (endast PDF).
- Begränsa filstorleken (t.ex. max 10MB).
4. **Autentisering**:
- Se till att användaren är inloggad innan de kan ladda upp PDF:er eller uppgradera.
---
---
## **📌 Felsökning**
| Problem | Lösning |
|---------|---------|
| **PDF:en läses inte in** | Kontrollera att filen är en giltig PDF. Använd `Tesseract.js` som fallback. |
| **Regex matchar inte** | Justera regex-mönstren baserat på PDF:ens struktur. |
| **AI-generering misslyckas** | Fallback till mallar. Kontrollera API-nyckeln. |
| **Premium-status uppdateras inte** | Kontrollera att `is_premium` och `premium_expiry_date` uppdateras korrekt. |
| **Inga recept genereras** | Kontrollera att produkterna matchas korrekt med inventory. |
---
---
## **🚀 Nästa steg**
1. **Testa med riktiga PDF:er** (t.ex. Willys veckoblad).
2. **Justera regex-mönstren** för att fånga fler produktdetaljer.
3. **Lägg till fler receptmallar** för olika kategorier.
4. **Implementera en betalningslösning** (t.ex. Stripe) för premium-uppgraderingar.
5. **Monitorera användningen** av premium-funktioner.
---
---
## **📌 Exempel på betalningsintegrering (Stripe)**
Om du vill lägga till **verklig betalning** för premium-uppgraderingar, kan du använda **Stripe**:
### **1. Installera Stripe**
```bash
npm install stripe @stripe/stripe-js
```
### **2. Skapa en Stripe-endpoint**
```typescript
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: '2023-10-16',
});
router.post('/create-checkout-session', async (req, res) => {
const userId = req.user.id;
const session = await stripe.checkout.sessions.create({
payment_method_types: ['card'],
line_items: [
{
price_data: {
currency: 'sek',
product_data: {
name: 'Premium-medlemskap (1 månad)',
},
unit_amount: 9900, // 99 kr
},
quantity: 1,
},
],
mode: 'payment',
success_url: `${process.env.FRONTEND_URL}/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${process.env.FRONTEND_URL}/cancel`,
metadata: { userId },
});
res.json({ url: session.url });
});
```
### **3. Hantera framgångsrik betalning**
```typescript
router.post('/webhook', express.raw({ type: 'application/json' }), async (req, res) => {
const sig = req.headers['stripe-signature'] as string;
const event = stripe.webhooks.constructEvent(
req.body,
sig,
process.env.STRIPE_WEBHOOK_SECRET!
);
if (event.type === 'checkout.session.completed') {
const session = event.data.object as Stripe.Checkout.Session;
const userId = parseInt(session.metadata.userId);
const expiryDate = new Date();
expiryDate.setMonth(expiryDate.getMonth() + 1);
await prisma.user.update({
where: { id: userId },
data: {
is_premium: true,
premium_expiry_date: expiryDate,
},
});
}
res.json({ received: true });
});
```
---
---
## **📌 Sammanfattning**
Denna plan ger dig en **komplett lösning** för att:
1. **Importera PDF:er** (t.ex. Willys veckoblad).
2. **Extrahera och strukturera data** (med regex och OCR).
3. **Matcha produkter med inventory** (fuzzy matching).
4. **Generera recept**:
- **Gratis**: Med mallar.
- **Premium**: Med AI (Mistral).
5. **Hantera premium-uppgraderingar** (med Stripe).
---
---
## **📥 Hur du använder denna plan i VS Code**
1. **Spara denna text** som `premium_recipe_feature_plan.md` i din projektmapp.
2. **Öppna filen i VS Code**.
3. **Använd VS Code-chatten** (t.ex. med **Mistral Vibe** eller **Continue**) för att:
- **Fråga om förtydliganden** för specifika steg.
- **Be om hjälp** med att implementera enskilda filer.
- **Debugga** om något inte fungerar.
4. **Kopiera och klistra in kodsnuttarna** i dina egna filer.
---
---
## **💡 Tips för VS Code**
- **Använd `Ctrl+Shift+P`** för att öppna kommandopaletten och köra `Prisma: Generate Client`.
- **Installera ESLint** för att fånga syntaxfel tidigt.
- **Använd Git** för att spåra dina ändringar:
```bash
git init
git add .
git commit -m "Lade till premium-receptfunktion"
```
```
---
**Spara denna text som `premium_recipe_feature_plan.md` och ladda upp den i VS Code-chatten när du behöver hjälp med implementeringen!** 🚀