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:
@@ -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.
|
||||
Reference in New Issue
Block a user