Add design spec for korting prijstracker

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013gwc3aCiyQxmCWQke8RvEz
This commit is contained in:
Sjoerd de Vries
2026-09-05 14:30:28 +02:00
commit 1bfe4fd794
@@ -0,0 +1,115 @@
# Korting — prijstracker design
**Datum:** 2026-09-05
**Status:** Approved
## Doel
Een webdienst die dagelijks een lijst productpagina's bezoekt, de huidige
prijs extraheert, en een email stuurt wanneer de prijs is veranderd
sinds de vorige check. Gehost op Coolify, bereikbaar op
`korting.sjoerd.app`, repo in Gitea (`git.pietpiraat.online`).
## Architectuur
Eén Node.js/Express monoliet in één Docker-container:
- `server.js` — Express app, basic-auth middleware, web-UI routes
- `db.js` — SQLite setup + queries (`better-sqlite3`)
- `scraper.js` — haalt een pagina op (`fetch`) en extraheert de prijs (`cheerio`)
- `mailer.js` — verstuurt email via `nodemailer` (SMTP)
- `scheduler.js``node-cron` job, dagelijks, doorloopt alle producten
- `views/` — server-rendered HTML (geen frontend-framework)
Geen aparte services: scheduler draait in-process in dezelfde container
als de webserver. SQLite-bestand staat op een persistent Coolify volume
zodat data een redeploy overleeft.
## Data model
Tabel `products`:
| kolom | type | omschrijving |
|---|---|---|
| id | integer PK | |
| name | text | leesbare naam |
| url | text | productpagina-URL |
| last_price | real, nullable | laatst bekende prijs |
| last_checked_at | datetime, nullable | tijdstip laatste (succesvolle) check |
| last_check_status | text, nullable | bijv. `ok` / `not_found` / `fetch_error` |
| created_at | datetime | |
Geen aparte prijshistorie-tabel (YAGNI) — kan later toegevoegd worden als
er behoefte is aan een grafiek.
## Prijs-extractie (`scraper.js`)
Volgorde van pogingen per pagina:
1. JSON-LD (`<script type="application/ld+json">`) met `@type: Product``offers.price`
2. Meta tags: `meta[property="product:price:amount"]` of `og:price:amount`
3. Fallback regex: eerste `€\s?\d+[.,]\d{2}` patroon in de zichtbare pagina-tekst
Als niets oplevert: check telt als mislukt (`last_check_status =
'not_found'`), zichtbaar in de UI naast het product. Geen mail, geen
wijziging aan `last_price`.
## Scheduling & email
- `node-cron`, schema `0 8 * * *`, tijdzone Europe/Amsterdam.
- Extra "nu controleren" knop in de UI om de hele check-run handmatig te
triggeren (zelfde codepad als de cron-job).
- Elk product wordt onafhankelijk verwerkt (try/catch per product) —
één mislukte check blokkeert de andere producten niet.
- Eerste keer dat een product wordt toegevoegd (`last_price IS NULL`):
alleen baseline opslaan, geen mail.
- Bij prijsverschil (nieuwe prijs ≠ `last_price`, en `last_price` niet
null): mail versturen met onderwerp `Prijs gewijzigd: {naam}`, body
met oude prijs, nieuwe prijs, en link naar het product.
- **Belangrijk:** `last_price` (en `last_checked_at`) wordt in de
database alleen bijgewerkt als de mail succesvol is verstuurd (of als
er geen mail nodig was, bijv. baseline of geen wijziging). Zo blijft
het systeem de volgende dag opnieuw proberen te mailen als er een
tijdelijk SMTP-probleem was, in plaats van de wijziging stilzwijgend
te verliezen.
- Mailer config via env vars: `SMTP_HOST` (mail.sjoerd.app),
`SMTP_PORT`, `SMTP_USER`, `SMTP_PASS`, `SMTP_FROM`, `NOTIFY_EMAIL`
(ontvanger).
## Beveiliging
- Basic auth over alle routes (UI + endpoints) via env vars
`BASIC_AUTH_USER` / `BASIC_AUTH_PASS`.
## Deployment
- Dockerfile: `node:22-slim`, production deps only, `CMD ["node",
"server.js"]`.
- Persistent volume gemount op `/data`; SQLite-pad via env var
`DB_PATH` (default `/data/korting.db`).
- Repo aangemaakt in Gitea (`git.pietpiraat.online`).
- Coolify-applicatie gekoppeld aan die repo, domain `korting.sjoerd.app`,
env vars zoals hierboven, auto-deploy on push. Uitgevoerd via de
`deploying-to-coolify` skill.
## Error handling (samengevat)
| situatie | gedrag |
|---|---|
| netwerkfout bij ophalen productpagina | gelogd, `last_check_status = 'fetch_error'`, geen crash van de run |
| geen prijs gevonden op pagina | gelogd, `last_check_status = 'not_found'`, geen mail, `last_price` blijft ongewijzigd |
| SMTP-fout bij versturen mail | gelogd, `last_price` blijft ongewijzigd zodat de volgende run opnieuw probeert te mailen |
## Testing
- Unit tests voor `scraper.js` (Node's ingebouwde test runner) tegen
HTML-fixtures: JSON-LD-pad, meta-tag-pad, regex-fallback-pad, en het
"niets gevonden"-pad.
- Handmatige verificatie van de volledige mail-flow via de "nu
controleren"-knop na deploy.
## Out of scope (mogelijke latere uitbreiding)
- Headless browser (Playwright) als fallback voor pagina's die de
prijs pas via JavaScript renderen.
- Prijshistorie/grafiek per product.