Compare commits
20 Commits
99343f74af
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| a1dffef708 | |||
| 0b69683080 | |||
| cd830b9de8 | |||
| 2fecdd2b8a | |||
| 9453195598 | |||
| dcc60af0c0 | |||
| 9fbb99e7a1 | |||
| 1b2836afe0 | |||
| 4596b80408 | |||
| c639eae270 | |||
| bf4e1d48bf | |||
| 2dc8aa4fb4 | |||
| ea006e7fbe | |||
| fa9bd141e0 | |||
| a0ac8b6084 | |||
| 6e9c588ae3 | |||
| bc6702b3e9 | |||
| cef8ee4b25 | |||
| 96833f0eea | |||
| 19ef7a4ea5 |
@@ -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`.
|
||||||
@@ -12,7 +12,7 @@ jobs:
|
|||||||
|
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
node-version: [24.x]
|
node-version: [24.15.0]
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
@@ -26,17 +26,16 @@ jobs:
|
|||||||
|
|
||||||
- name: Install dependencies (backend)
|
- name: Install dependencies (backend)
|
||||||
working-directory: ./backend
|
working-directory: ./backend
|
||||||
run: npm install
|
run: npm ci
|
||||||
|
|
||||||
- name: Generate Prisma Client
|
- name: Typecheck backend
|
||||||
working-directory: ./backend
|
working-directory: ./backend
|
||||||
run: npm run prisma:generate
|
run: npm run typecheck
|
||||||
|
|
||||||
- name: Run tests (backend)
|
|
||||||
working-directory: ./backend
|
|
||||||
run: npm test
|
|
||||||
|
|
||||||
- name: Build NestJS app
|
- name: Build NestJS app
|
||||||
working-directory: ./backend
|
working-directory: ./backend
|
||||||
run: npm run build
|
run: npm run build
|
||||||
continue-on-error: true
|
|
||||||
|
- name: Dependency audit (high+critical)
|
||||||
|
working-directory: ./backend
|
||||||
|
run: npm run audit:high
|
||||||
|
|||||||
@@ -1,215 +1,48 @@
|
|||||||
# Microservice Importer
|
# 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
|
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).
|
||||||
Detta dokument är för systemadministratörer och utvecklare som driftar eller vidareutvecklar importtjänsten.
|
|
||||||
|
|
||||||
### Tillägg från senaste sessionerna
|
## Vad tjänsten gör
|
||||||
- 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.
|
|
||||||
|
|
||||||
## Viktigt!! Kod- och byggpraxis!
|
- Tar emot URL:er, filer och markdown för importflöden
|
||||||
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
|
- 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`)
|
## Viktigt
|
||||||
- **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`).
|
|
||||||
|
|
||||||
### Parse-Markdown (`POST /api/recipes/parse-markdown`)
|
- Inga absoluta Windows-sökvägar ska användas i kod eller scripts
|
||||||
Tolkar Markdown-recept till strukturerat JSON utan databas.
|
- 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`)
|
## Kort faktadel
|
||||||
- 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)
|
|
||||||
|
|
||||||
### Health (`GET /api/health`)
|
- Runtime: Node.js 22-alpine
|
||||||
Används av Docker-healthcheck i `recipe-app/compose.yml`. Returnerar `{ status: "ok" }`.
|
- 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
|
- 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).
|
||||||
| 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
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Support
|
## 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`
|
||||||
|
|||||||
@@ -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)
|
||||||
+5
-4
@@ -1,21 +1,22 @@
|
|||||||
# Byggas från projektets rot: docker build -f backend/Dockerfile -t recipe-importer-api:local .
|
# Byggas från projektets rot: docker build -f backend/Dockerfile -t recipe-importer-api:local .
|
||||||
|
|
||||||
# Stage 1: Bygg applikationen
|
# Stage 1: Bygg applikationen
|
||||||
FROM node:22-alpine AS builder
|
FROM node:24.15.0-alpine AS builder
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Kopiera backend-filer
|
# Kopiera backend-filer
|
||||||
COPY backend/package.json ./
|
COPY backend/package.json ./
|
||||||
|
COPY backend/package-lock.json ./
|
||||||
COPY backend/src ./src
|
COPY backend/src ./src
|
||||||
COPY backend/tsconfig.json ./
|
COPY backend/tsconfig.json ./
|
||||||
COPY backend/nest-cli.json ./
|
COPY backend/nest-cli.json ./
|
||||||
|
|
||||||
# Köra npm install
|
# Köra npm ci för reproducerbara builds
|
||||||
RUN npm install
|
RUN npm ci
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
# Stage 2: Kör applikationen
|
# Stage 2: Kör applikationen
|
||||||
FROM node:22-alpine AS runner
|
FROM node:24.15.0-alpine AS runner
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
|
|
||||||
|
|||||||
Generated
+1401
-1366
File diff suppressed because it is too large
Load Diff
+12
-8
@@ -5,25 +5,29 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "nest build",
|
"build": "nest build",
|
||||||
"start": "node dist/main",
|
"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": {
|
"dependencies": {
|
||||||
"@nestjs/common": "^10.3.0",
|
"@nestjs/common": "^11.1.19",
|
||||||
"@nestjs/core": "^10.3.0",
|
"@nestjs/core": "^11.1.19",
|
||||||
"@nestjs/platform-express": "^10.3.0",
|
"@nestjs/platform-express": "^11.1.19",
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.15.1",
|
"class-validator": "^0.15.1",
|
||||||
"multer": "^1.4.5-lts.1",
|
"multer": "^2.1.1",
|
||||||
"pdf-lib": "^1.17.1",
|
"pdf-lib": "^1.17.1",
|
||||||
"pdf-parse": "^1.1.1",
|
"pdf-parse": "^1.1.1",
|
||||||
|
"pdfjs-dist": "^5.7.284",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
"rxjs": "^7.8.1",
|
"rxjs": "^7.8.1",
|
||||||
"tesseract.js": "^5.1.1"
|
"tesseract.js": "^5.1.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@nestjs/cli": "^10.3.0",
|
"@nestjs/cli": "^11.0.21",
|
||||||
"@nestjs/schematics": "^10.1.1",
|
"@nestjs/schematics": "^11.1.0",
|
||||||
"@types/express": "^4.17.21",
|
"@types/express": "^5.0.5",
|
||||||
"@types/multer": "^1.4.12",
|
"@types/multer": "^1.4.12",
|
||||||
"@types/node": "^22.15.29",
|
"@types/node": "^22.15.29",
|
||||||
"@types/pdf-parse": "^1.1.4",
|
"@types/pdf-parse": "^1.1.4",
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { WebScrapingModule } from './web-scraping-service/web-scraping.module';
|
|||||||
import { RecipesModule } from './recipes/recipes.module';
|
import { RecipesModule } from './recipes/recipes.module';
|
||||||
import { DocumentServiceModule } from './document-service/document-service.module';
|
import { DocumentServiceModule } from './document-service/document-service.module';
|
||||||
import { ReceiptParsingModule } from './receipt-parsing/receipt-parsing.module';
|
import { ReceiptParsingModule } from './receipt-parsing/receipt-parsing.module';
|
||||||
|
import { FlyerParsingModule } from './flyer-parsing/flyer-parsing.module';
|
||||||
|
|
||||||
@Controller('health')
|
@Controller('health')
|
||||||
class HealthController {
|
class HealthController {
|
||||||
@@ -18,6 +19,7 @@ class HealthController {
|
|||||||
WebScrapingModule,
|
WebScrapingModule,
|
||||||
RecipesModule,
|
RecipesModule,
|
||||||
ReceiptParsingModule,
|
ReceiptParsingModule,
|
||||||
|
FlyerParsingModule,
|
||||||
],
|
],
|
||||||
controllers: [HealthController],
|
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,
|
Logger,
|
||||||
ServiceUnavailableException,
|
ServiceUnavailableException,
|
||||||
} from '@nestjs/common';
|
} 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 MISTRAL_API_URL = 'https://api.mistral.ai/v1/chat/completions';
|
||||||
const RECEIPT_VISION_MODEL = 'mistral-small-2603'; // vision — används för bild-input
|
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;
|
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()
|
@Injectable()
|
||||||
export class ReceiptParsingService {
|
export class ReceiptParsingService {
|
||||||
private readonly logger = new Logger(ReceiptParsingService.name);
|
private readonly logger = new Logger(ReceiptParsingService.name);
|
||||||
@@ -288,13 +300,27 @@ export class ReceiptParsingService {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.warn(`pdf-parse misslyckades: ${err}`);
|
this.logger.warn(`pdf-parse misslyckades: ${err}`);
|
||||||
|
|
||||||
// Fallback to pdf-lib for more complex PDFs
|
// Fallback to pdfjs-dist legacy build (Node.js compatible, no DOMMatrix needed)
|
||||||
try {
|
try {
|
||||||
const { PDFDocument } = await import('pdf-lib');
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||||
const pdfDoc = await PDFDocument.load(buffer);
|
const pdfjsLib = require('pdfjs-dist/legacy/build/pdf.js') as typeof import('pdfjs-dist');
|
||||||
text = pdfDoc.getPages().map(page => page.getText()).join(' ');
|
pdfjsLib.GlobalWorkerOptions.workerSrc = '';
|
||||||
|
const loadingTask = pdfjsLib.getDocument({ data: new Uint8Array(buffer) });
|
||||||
|
const pdfDocument = await loadingTask.promise;
|
||||||
|
|
||||||
|
let fullText = '';
|
||||||
|
for (let i = 1; i <= pdfDocument.numPages; i++) {
|
||||||
|
const page = await pdfDocument.getPage(i);
|
||||||
|
const textContent = await page.getTextContent();
|
||||||
|
const pageText = textContent.items
|
||||||
|
.map((item: any) => (item.str ? item.str : ''))
|
||||||
|
.join(' ');
|
||||||
|
fullText += pageText + ' ';
|
||||||
|
}
|
||||||
|
|
||||||
|
text = fullText;
|
||||||
} catch (fallbackErr) {
|
} catch (fallbackErr) {
|
||||||
this.logger.error(`Både pdf-parse och pdf-lib misslyckades: ${fallbackErr}`);
|
this.logger.error(`Både pdf-parse och pdfjs-dist misslyckades: ${fallbackErr}`);
|
||||||
throw new BadRequestException('PDF-filen kunde inte läsas. Kontrollera att filen inte är skadad eller krypterad.');
|
throw new BadRequestException('PDF-filen kunde inte läsas. Kontrollera att filen inte är skadad eller krypterad.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -317,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) {
|
this.logger.log(
|
||||||
const aiItems = await this.callMistralText(needsAI, apiKey);
|
`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);
|
resolved.push(...aiItems);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -425,7 +459,7 @@ export class ReceiptParsingService {
|
|||||||
const err = await response.text();
|
const err = await response.text();
|
||||||
this.logger.warn(`Mistral ${response.status} (${source}, försök ${attempt}/${MAX_RETRIES}): ${err}`);
|
this.logger.warn(`Mistral ${response.status} (${source}, försök ${attempt}/${MAX_RETRIES}): ${err}`);
|
||||||
if (attempt < MAX_RETRIES) {
|
if (attempt < MAX_RETRIES) {
|
||||||
await new Promise((resolve) => setTimeout(resolve, 1000 * attempt));
|
await new Promise((resolve) => setTimeout(resolve, 3000 * attempt));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
throw new ServiceUnavailableException('Mistral API är tillfälligt otillgänglig. Försök igen.');
|
throw new ServiceUnavailableException('Mistral API är tillfälligt otillgänglig. Försök igen.');
|
||||||
|
|||||||
@@ -23,6 +23,14 @@ interface ParsedRecipe {
|
|||||||
// Parser Functions
|
// 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,
|
* Parsar ett recept i Markdown-format och extraherar namn, beskrivning,
|
||||||
* instruktioner och ingredienser.
|
* instruktioner och ingredienser.
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
+40
-57
@@ -1,17 +1,23 @@
|
|||||||
# Plan för vidareutveckling av Microservice Importer
|
# 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
|
### Senast avklarat i angränsande flöden
|
||||||
- Regelbaserad kvittotolkning har stärkts för multipack/enheter och svårare radformat.
|
- 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.
|
||||||
- Bröd-/rostbrödklassning har utökade guardrails för att minska felaktig kategorisering.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
`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
|
### Medel prioritet
|
||||||
- **Fler webbplats-parsers** — Specifika parsers för t.ex. Tasteline, Köket.se, Arla
|
- **Fler webbplats-parsers** — Specifika parsers för t.ex. Tasteline, Köket.se, Arla
|
||||||
- **Swagger/OpenAPI** — Automatisk API-dokumentation via `@nestjs/swagger`
|
- **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
|
### Låg prioritet / Framtida
|
||||||
- **Caching** — Cacha skrapade sidor för att minska belastning på externa webbplatser
|
- **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
|
## 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:
|
||||||
|
|
||||||
```
|
```text
|
||||||
Bild/PDF → OCR/pdf-parse → Regelbaserad parsning → AI (för rader som inte lösts ut)
|
PDF → pdf-parse / pdfjs-dist → preprocessPdfLines → isIgnoredReceiptLine → ruleBasedParseLine → looksLikeReceiptProductLine → AI
|
||||||
```
|
```
|
||||||
|
|
||||||
**Fördelar:**
|
`looksLikeReceiptProductLine(line)` filtrerar bort rader som saknar siffra innan Mistral-anropet. Enbart rader med namnliknande text och minst ett tal skickas till AI.
|
||||||
- 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
|
|
||||||
|
|
||||||
### Modellval för olika deluppgifter
|
### Modellval för olika deluppgifter
|
||||||
|
|
||||||
| Uppgift | Rekommenderad modell | Motivering |
|
| Uppgift | Rekommenderad modell | Motivering |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Kvittoparsning (hela bilden, nuläge) | `mistral-small-2603` | Vision-förmåga krävs för bild-input |
|
| Kvittoparsning (hela bilden) | `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 |
|
| Tolka OCR-text | `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 |
|
| 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
|
## Framtida förbättringar
|
||||||
|
|
||||||
### Schemalagd Uppdatering av Kategorier
|
- Schemalagd uppdatering av kategorier om det visar sig nödvändigt igen.
|
||||||
- **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.
|
|
||||||
|
|
||||||
---
|
## Nuvarande implementering
|
||||||
|
|
||||||
## Nuvarande Implementering
|
- Ingen separat manuell kategoriuppdatering via Flutter-UI finns längre.
|
||||||
|
- Kvittoparsningen använder i stället nuvarande regler, retry-logik och filtrering i backend.
|
||||||
### 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.')),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Arkitektur-noteringar
|
## Arkitektur-noteringar
|
||||||
|
|
||||||
- Tjänsten är **helt stateless** — ingen databas, ingen session
|
- Tjänsten är helt stateless och saknar databas
|
||||||
- Kommunicerar **aldrig direkt** med internet-klienter — exponeras bara på `recipe-internal`-nätverket
|
- Den exponeras bara på `recipe-internal`-nätverket
|
||||||
- `MISTRAL_API_KEY` injiceras via env (samma nyckel som `recipe-api` använder)
|
- `MISTRAL_API_KEY` injiceras via env
|
||||||
- Alpine Docker-image: systempaket `tesseract-ocr`, `tesseract-ocr-data-swe`, `tesseract-ocr-data-eng` installerade via `apk`
|
- Alpine Docker-image använder `tesseract-ocr`, `tesseract-ocr-data-swe`, `tesseract-ocr-data-eng`
|
||||||
- Host-port 3001 är upptagen av `wetty` på servern — `importer-api` exponeras aldrig till host
|
- 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.
|
||||||
|
|||||||
@@ -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!** 🚀
|
||||||
Reference in New Issue
Block a user