Files
korting/docs/superpowers/plans/2026-09-05-korting-prijstracker.md
2026-09-05 14:35:57 +02:00

1333 lines
41 KiB
Markdown

# Korting prijstracker Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Build a Node.js/Express web app that lets you manage a list of product URLs, checks their price daily, and emails when a price changes.
**Architecture:** One Express app in one process. `node-cron` runs a daily job in-process; the same check logic is reachable via a "check now" button in the UI. SQLite (`better-sqlite3`) stores products and their last known price. Price extraction tries JSON-LD, then meta tags, then a regex fallback. Email goes out via `nodemailer` over SMTP to the existing Postal server at `mail.sjoerd.app`.
**Tech Stack:** Node.js 22, Express, better-sqlite3, cheerio, nodemailer, node-cron, express-basic-auth. Tests via Node's built-in `node:test` + `supertest` (devDependency only).
**Spec:** `docs/superpowers/specs/2026-09-05-korting-prijstracker-design.md`
## Global Constraints
- Node.js 22, CommonJS modules (`require`/`module.exports`), no TypeScript, no bundler.
- One SQLite file at `DB_PATH` (default `./data/korting.db` locally; `/data/korting.db` on the Coolify volume in production).
- Daily cron schedule `0 8 * * *`, timezone `Europe/Amsterdam`.
- `last_price` and `last_checked_at` are updated **only** on a fully successful check (price found, and email either not needed or sent successfully). They are left untouched on `fetch_error`, `not_found`, or `mail_error`, so a failure is retried automatically the next day.
- Every route is protected by basic auth (`BASIC_AUTH_USER` / `BASIC_AUTH_PASS`).
- SMTP env vars: `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASS`, `SMTP_FROM`, `NOTIFY_EMAIL` (recipient).
- Test runner: Node's built-in `node:test` (`node --test test/`). The only devDependency is `supertest`, used solely for the Express app tests.
---
### Task 1: Project scaffolding
**Files:**
- Create: `package.json`
- Create: `.gitignore`
- Create: `.env.example`
- Create: `test/smoke.test.js`
**Interfaces:**
- Produces: `npm test` runs `node --test test/`; `npm start` runs `node server.js` (server.js created in Task 8).
- [ ] **Step 1: Create `package.json`**
```json
{
"name": "korting",
"version": "1.0.0",
"private": true,
"description": "Dagelijkse prijstracker die mailt bij prijswijzigingen",
"main": "server.js",
"scripts": {
"start": "node server.js",
"test": "node --test test/"
},
"dependencies": {
"better-sqlite3": "^11.3.0",
"cheerio": "^1.0.0",
"express": "^4.19.2",
"express-basic-auth": "^1.2.1",
"node-cron": "^3.0.3",
"nodemailer": "^6.9.14"
},
"devDependencies": {
"supertest": "^7.0.0"
}
}
```
- [ ] **Step 2: Install dependencies**
Run: `npm install`
Expected: completes with no errors, creates `node_modules/` and `package-lock.json`.
- [ ] **Step 3: Create `.gitignore`**
```
node_modules/
data/
.env
```
- [ ] **Step 4: Create `.env.example`**
```
PORT=3000
DB_PATH=./data/korting.db
BASIC_AUTH_USER=admin
BASIC_AUTH_PASS=change-me
SMTP_HOST=mail.sjoerd.app
SMTP_PORT=587
SMTP_USER=
SMTP_PASS=
SMTP_FROM=korting@sjoerd.app
NOTIFY_EMAIL=openai@sjoerddev.com
```
- [ ] **Step 5: Create `test/smoke.test.js`**
```js
const test = require('node:test');
const assert = require('node:assert/strict');
test('smoke test runs', () => {
assert.ok(true);
});
```
- [ ] **Step 6: Run the test suite**
Run: `npm test`
Expected: PASS (1 test)
- [ ] **Step 7: Commit**
```bash
git add package.json package-lock.json .gitignore .env.example test/smoke.test.js
git commit -m "chore: project scaffolding"
```
---
### Task 2: Database module
**Files:**
- Create: `src/db.js`
- Test: `test/db.test.js`
**Interfaces:**
- Produces:
- `openDb(dbPath: string) -> Database` — creates parent dir (unless `:memory:`), ensures schema, returns a `better-sqlite3` instance.
- `listProducts(db) -> Array<Product>` ordered by `created_at` ascending.
- `getProduct(db, id) -> Product | undefined`
- `addProduct(db, { name, url }) -> Product` (sets `created_at`, `last_price: null`, `last_checked_at: null`)
- `deleteProduct(db, id) -> void`
- `recordCheckSuccess(db, id, { price, checkedAt }) -> void` — sets `last_price`, `last_checked_at`, `last_check_status = 'ok'`
- `recordCheckFailure(db, id, status: string) -> void` — sets only `last_check_status`
- `Product` shape: `{ id, name, url, last_price, last_checked_at, last_check_status, created_at }`
- [ ] **Step 1: Write the failing tests**
Create `test/db.test.js`:
```js
const test = require('node:test');
const assert = require('node:assert/strict');
const {
openDb, listProducts, getProduct, addProduct, deleteProduct,
recordCheckSuccess, recordCheckFailure,
} = require('../src/db');
function freshDb() {
return openDb(':memory:');
}
test('addProduct inserts a row with defaults and returns it', () => {
const db = freshDb();
const product = addProduct(db, { name: 'Voorbeeld', url: 'https://example.com' });
assert.equal(product.name, 'Voorbeeld');
assert.equal(product.url, 'https://example.com');
assert.equal(product.last_price, null);
assert.equal(product.last_checked_at, null);
assert.ok(product.created_at);
assert.ok(product.id);
});
test('listProducts returns all inserted products in insertion order', () => {
const db = freshDb();
addProduct(db, { name: 'A', url: 'https://a' });
addProduct(db, { name: 'B', url: 'https://b' });
const products = listProducts(db);
assert.equal(products.length, 2);
assert.equal(products[0].name, 'A');
assert.equal(products[1].name, 'B');
});
test('getProduct returns undefined for an unknown id', () => {
const db = freshDb();
assert.equal(getProduct(db, 999), undefined);
});
test('deleteProduct removes the row', () => {
const db = freshDb();
const product = addProduct(db, { name: 'A', url: 'https://a' });
deleteProduct(db, product.id);
assert.equal(getProduct(db, product.id), undefined);
});
test('recordCheckSuccess updates price, timestamp and status', () => {
const db = freshDb();
const product = addProduct(db, { name: 'A', url: 'https://a' });
recordCheckSuccess(db, product.id, { price: 12.5, checkedAt: '2026-09-05T08:00:00.000Z' });
const updated = getProduct(db, product.id);
assert.equal(updated.last_price, 12.5);
assert.equal(updated.last_checked_at, '2026-09-05T08:00:00.000Z');
assert.equal(updated.last_check_status, 'ok');
});
test('recordCheckFailure only updates the status, leaving price and timestamp untouched', () => {
const db = freshDb();
const product = addProduct(db, { name: 'A', url: 'https://a' });
recordCheckSuccess(db, product.id, { price: 12.5, checkedAt: '2026-09-05T08:00:00.000Z' });
recordCheckFailure(db, product.id, 'fetch_error');
const updated = getProduct(db, product.id);
assert.equal(updated.last_price, 12.5);
assert.equal(updated.last_checked_at, '2026-09-05T08:00:00.000Z');
assert.equal(updated.last_check_status, 'fetch_error');
});
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `npm test`
Expected: FAIL with "Cannot find module '../src/db'"
- [ ] **Step 3: Write `src/db.js`**
```js
const Database = require('better-sqlite3');
const fs = require('node:fs');
const path = require('node:path');
const SCHEMA = `
CREATE TABLE IF NOT EXISTS products (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
url TEXT NOT NULL,
last_price REAL,
last_checked_at TEXT,
last_check_status TEXT,
created_at TEXT NOT NULL
)`;
function openDb(dbPath) {
if (dbPath !== ':memory:') {
fs.mkdirSync(path.dirname(dbPath), { recursive: true });
}
const db = new Database(dbPath);
db.exec(SCHEMA);
return db;
}
function listProducts(db) {
return db.prepare('SELECT * FROM products ORDER BY created_at ASC').all();
}
function getProduct(db, id) {
return db.prepare('SELECT * FROM products WHERE id = ?').get(id);
}
function addProduct(db, { name, url }) {
const createdAt = new Date().toISOString();
const info = db.prepare(
'INSERT INTO products (name, url, created_at) VALUES (?, ?, ?)'
).run(name, url, createdAt);
return getProduct(db, info.lastInsertRowid);
}
function deleteProduct(db, id) {
db.prepare('DELETE FROM products WHERE id = ?').run(id);
}
function recordCheckSuccess(db, id, { price, checkedAt }) {
db.prepare(
'UPDATE products SET last_price = ?, last_checked_at = ?, last_check_status = ? WHERE id = ?'
).run(price, checkedAt, 'ok', id);
}
function recordCheckFailure(db, id, status) {
db.prepare('UPDATE products SET last_check_status = ? WHERE id = ?').run(status, id);
}
module.exports = {
openDb, listProducts, getProduct, addProduct, deleteProduct,
recordCheckSuccess, recordCheckFailure,
};
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `npm test`
Expected: PASS (all `db.test.js` tests + smoke test)
- [ ] **Step 5: Commit**
```bash
git add src/db.js test/db.test.js
git commit -m "feat: add SQLite-backed products store"
```
---
### Task 3: Scraper module
**Files:**
- Create: `src/scraper.js`
- Create: `test/fixtures/json-ld.html`
- Create: `test/fixtures/meta-tag.html`
- Create: `test/fixtures/regex-fallback.html`
- Create: `test/fixtures/no-price.html`
- Test: `test/scraper.test.js`
**Interfaces:**
- Produces:
- `extractPrice(html: string) -> { price: number | null, method: 'json-ld' | 'meta' | 'regex' | null }`
- `fetchAndExtractPrice(url: string, { fetchImpl? }) -> Promise<{ price: number | null, method: string | null, error: Error | null }>``fetchImpl` defaults to the global `fetch`; never throws, errors are returned on the `error` field.
- [ ] **Step 1: Create fixture files**
`test/fixtures/json-ld.html`:
```html
<html><head>
<script type="application/ld+json">
{"@context":"https://schema.org","@type":"Product","name":"Voorbeeld product","offers":{"@type":"Offer","price":"19.99","priceCurrency":"EUR"}}
</script>
</head><body><p>Voorbeeld product</p></body></html>
```
`test/fixtures/meta-tag.html`:
```html
<html><head>
<meta property="og:price:amount" content="24.50" />
</head><body><p>Voorbeeld product</p></body></html>
```
`test/fixtures/regex-fallback.html`:
```html
<html><body><p>Onze prijs: &euro;14,95 vandaag</p></body></html>
```
`test/fixtures/no-price.html`:
```html
<html><body><p>Geen prijs op deze pagina.</p></body></html>
```
- [ ] **Step 2: Write the failing tests**
Create `test/scraper.test.js`:
```js
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { extractPrice, fetchAndExtractPrice } = require('../src/scraper');
function loadFixture(name) {
return fs.readFileSync(path.join(__dirname, 'fixtures', name), 'utf8');
}
test('extractPrice reads price from JSON-LD Product offers', () => {
const result = extractPrice(loadFixture('json-ld.html'));
assert.deepEqual(result, { price: 19.99, method: 'json-ld' });
});
test('extractPrice reads price from og:price:amount meta tag', () => {
const result = extractPrice(loadFixture('meta-tag.html'));
assert.deepEqual(result, { price: 24.50, method: 'meta' });
});
test('extractPrice falls back to euro-amount regex in page text', () => {
const result = extractPrice(loadFixture('regex-fallback.html'));
assert.deepEqual(result, { price: 14.95, method: 'regex' });
});
test('extractPrice returns null price when nothing matches', () => {
const result = extractPrice(loadFixture('no-price.html'));
assert.deepEqual(result, { price: null, method: null });
});
test('fetchAndExtractPrice returns error when fetch rejects', async () => {
const fetchImpl = async () => { throw new Error('network down'); };
const result = await fetchAndExtractPrice('https://example.invalid', { fetchImpl });
assert.equal(result.price, null);
assert.ok(result.error instanceof Error);
});
test('fetchAndExtractPrice returns error on non-OK response', async () => {
const fetchImpl = async () => ({ ok: false, status: 404, text: async () => '' });
const result = await fetchAndExtractPrice('https://example.invalid', { fetchImpl });
assert.equal(result.price, null);
assert.ok(result.error instanceof Error);
});
test('fetchAndExtractPrice extracts price from fetched HTML', async () => {
const html = loadFixture('meta-tag.html');
const fetchImpl = async () => ({ ok: true, status: 200, text: async () => html });
const result = await fetchAndExtractPrice('https://example.invalid', { fetchImpl });
assert.deepEqual(result, { price: 24.50, method: 'meta', error: null });
});
```
- [ ] **Step 3: Run tests to verify they fail**
Run: `npm test`
Expected: FAIL with "Cannot find module '../src/scraper'"
- [ ] **Step 4: Write `src/scraper.js`**
```js
const cheerio = require('cheerio');
function roundMoney(n) {
return Math.round(n * 100) / 100;
}
function parsePriceValue(raw) {
if (raw == null) return null;
const cleaned = String(raw).trim().replace(',', '.');
const value = parseFloat(cleaned);
return Number.isFinite(value) ? roundMoney(value) : null;
}
function searchJsonLdNode(node) {
if (Array.isArray(node)) {
for (const item of node) {
const price = searchJsonLdNode(item);
if (price != null) return price;
}
return null;
}
if (node && typeof node === 'object') {
const type = node['@type'];
const isProduct = type === 'Product' || (Array.isArray(type) && type.includes('Product'));
if (isProduct && node.offers) {
const offers = Array.isArray(node.offers) ? node.offers[0] : node.offers;
const price = parsePriceValue(offers && offers.price);
if (price != null) return price;
}
if (Array.isArray(node['@graph'])) {
const price = searchJsonLdNode(node['@graph']);
if (price != null) return price;
}
}
return null;
}
function findPriceInJsonLd($) {
const scripts = $('script[type="application/ld+json"]').toArray();
for (const el of scripts) {
let data;
try {
data = JSON.parse($(el).contents().text());
} catch {
continue;
}
const price = searchJsonLdNode(data);
if (price != null) return price;
}
return null;
}
function findPriceInMeta($) {
const selectors = ['meta[property="product:price:amount"]', 'meta[property="og:price:amount"]'];
for (const selector of selectors) {
const price = parsePriceValue($(selector).attr('content'));
if (price != null) return price;
}
return null;
}
function findPriceInText($) {
const text = $('body').text();
const match = text.match(/€\s?(\d+[.,]\d{2})/);
return match ? parsePriceValue(match[1]) : null;
}
function extractPrice(html) {
const $ = cheerio.load(html);
let price = findPriceInJsonLd($);
if (price != null) return { price, method: 'json-ld' };
price = findPriceInMeta($);
if (price != null) return { price, method: 'meta' };
price = findPriceInText($);
if (price != null) return { price, method: 'regex' };
return { price: null, method: null };
}
async function fetchAndExtractPrice(url, { fetchImpl = fetch } = {}) {
let response;
try {
response = await fetchImpl(url);
} catch (err) {
return { price: null, method: null, error: err };
}
if (!response.ok) {
return { price: null, method: null, error: new Error(`HTTP ${response.status}`) };
}
const html = await response.text();
const { price, method } = extractPrice(html);
return { price, method, error: null };
}
module.exports = { extractPrice, fetchAndExtractPrice, parsePriceValue };
```
- [ ] **Step 5: Run tests to verify they pass**
Run: `npm test`
Expected: PASS (all `scraper.test.js` tests + previous tests)
- [ ] **Step 6: Commit**
```bash
git add src/scraper.js test/scraper.test.js test/fixtures
git commit -m "feat: add price scraper with JSON-LD/meta/regex fallback"
```
---
### Task 4: Mailer module
**Files:**
- Create: `src/mailer.js`
- Test: `test/mailer.test.js`
**Interfaces:**
- Produces:
- `createTransport(env = process.env) -> Transport` — builds a `nodemailer` transport from `SMTP_HOST`/`SMTP_PORT`/`SMTP_USER`/`SMTP_PASS`.
- `sendPriceChangeEmail(transport, { from, to, name, url, oldPrice, newPrice }) -> Promise<void>` — throws/rejects if the transport's `sendMail` rejects.
- `formatPrice(value: number) -> string` — e.g. `19.9 -> "19,90"`.
- [ ] **Step 1: Write the failing tests**
Create `test/mailer.test.js`:
```js
const test = require('node:test');
const assert = require('node:assert/strict');
const { sendPriceChangeEmail, formatPrice, createTransport } = require('../src/mailer');
test('formatPrice formats with comma decimal separator', () => {
assert.equal(formatPrice(19.9), '19,90');
assert.equal(formatPrice(24.5), '24,50');
});
test('sendPriceChangeEmail sends mail with expected fields', async () => {
const calls = [];
const fakeTransport = { sendMail: async (opts) => { calls.push(opts); } };
await sendPriceChangeEmail(fakeTransport, {
from: 'korting@sjoerd.app',
to: 'me@example.com',
name: 'Voorbeeld product',
url: 'https://shop.example/product',
oldPrice: 19.99,
newPrice: 17.5,
});
assert.equal(calls.length, 1);
assert.equal(calls[0].from, 'korting@sjoerd.app');
assert.equal(calls[0].to, 'me@example.com');
assert.equal(calls[0].subject, 'Prijs gewijzigd: Voorbeeld product');
assert.match(calls[0].text, /19,99/);
assert.match(calls[0].text, /17,50/);
assert.match(calls[0].text, /https:\/\/shop\.example\/product/);
});
test('sendPriceChangeEmail propagates transport errors', async () => {
const fakeTransport = { sendMail: async () => { throw new Error('smtp down'); } };
await assert.rejects(
() => sendPriceChangeEmail(fakeTransport, {
from: 'a@b.com', to: 'c@d.com', name: 'X', url: 'https://x', oldPrice: 1, newPrice: 2,
}),
/smtp down/
);
});
test('createTransport builds a transport from env vars', () => {
const transport = createTransport({ SMTP_HOST: 'mail.sjoerd.app', SMTP_PORT: '587', SMTP_USER: 'u', SMTP_PASS: 'p' });
assert.equal(typeof transport.sendMail, 'function');
});
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `npm test`
Expected: FAIL with "Cannot find module '../src/mailer'"
- [ ] **Step 3: Write `src/mailer.js`**
```js
const nodemailer = require('nodemailer');
function createTransport(env = process.env) {
return nodemailer.createTransport({
host: env.SMTP_HOST,
port: Number(env.SMTP_PORT || 587),
secure: Number(env.SMTP_PORT) === 465,
auth: env.SMTP_USER ? { user: env.SMTP_USER, pass: env.SMTP_PASS } : undefined,
});
}
function formatPrice(value) {
return value.toFixed(2).replace('.', ',');
}
async function sendPriceChangeEmail(transport, { from, to, name, url, oldPrice, newPrice }) {
await transport.sendMail({
from,
to,
subject: `Prijs gewijzigd: ${name}`,
text: [
`De prijs van "${name}" is gewijzigd.`,
`Oude prijs: €${formatPrice(oldPrice)}`,
`Nieuwe prijs: €${formatPrice(newPrice)}`,
`Link: ${url}`,
].join('\n'),
});
}
module.exports = { createTransport, sendPriceChangeEmail, formatPrice };
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `npm test`
Expected: PASS (all `mailer.test.js` tests + previous tests)
- [ ] **Step 5: Commit**
```bash
git add src/mailer.js test/mailer.test.js
git commit -m "feat: add SMTP mailer for price-change notifications"
```
---
### Task 5: Price-check core logic
**Files:**
- Create: `src/checkProducts.js`
- Test: `test/checkProducts.test.js`
**Interfaces:**
- Consumes (via dependency injection, no direct import): a `fetchAndExtractPrice`-shaped function (Task 3), a `sendMail(payload) -> Promise<void>`-shaped function (built from Task 4's `sendPriceChangeEmail`), `recordCheckSuccess`/`recordCheckFailure`-shaped functions (Task 2), and a `now() -> string` clock function.
- Produces:
- `checkProduct(product: Product, deps) -> Promise<{ status: 'ok'|'fetch_error'|'not_found'|'mail_error', changed?: boolean }>`
- `checkAllProducts(products: Product[], deps) -> Promise<Array<{ productId, status, changed?, error? }>>` — never throws; a single product's failure is caught and reported as `{ status: 'error', error }`.
- [ ] **Step 1: Write the failing tests**
Create `test/checkProducts.test.js`:
```js
const test = require('node:test');
const assert = require('node:assert/strict');
const { checkProduct, checkAllProducts } = require('../src/checkProducts');
function makeDeps(overrides = {}) {
const failures = [];
const successes = [];
const mailCalls = [];
const deps = {
fetchAndExtractPrice: async () => ({ price: 10, method: 'meta', error: null }),
sendMail: async (payload) => { mailCalls.push(payload); },
recordCheckFailure: (id, status) => failures.push({ id, status }),
recordCheckSuccess: (id, data) => successes.push({ id, ...data }),
now: () => '2026-09-05T08:00:00.000Z',
...overrides,
};
return { deps, failures, successes, mailCalls };
}
test('checkProduct stores baseline price without sending mail', async () => {
const { deps, successes, mailCalls } = makeDeps();
const product = { id: 1, name: 'X', url: 'https://x', last_price: null };
const result = await checkProduct(product, deps);
assert.equal(result.status, 'ok');
assert.equal(result.changed, false);
assert.equal(mailCalls.length, 0);
assert.deepEqual(successes, [{ id: 1, price: 10, checkedAt: '2026-09-05T08:00:00.000Z' }]);
});
test('checkProduct sends mail and updates price when price changed', async () => {
const { deps, successes, mailCalls } = makeDeps();
const product = { id: 2, name: 'X', url: 'https://x', last_price: 15 };
const result = await checkProduct(product, deps);
assert.equal(result.status, 'ok');
assert.equal(result.changed, true);
assert.equal(mailCalls.length, 1);
assert.deepEqual(mailCalls[0], { name: 'X', url: 'https://x', oldPrice: 15, newPrice: 10 });
assert.equal(successes.length, 1);
});
test('checkProduct does not send mail or update when price unchanged', async () => {
const { deps, successes, mailCalls } = makeDeps();
const product = { id: 3, name: 'X', url: 'https://x', last_price: 10 };
const result = await checkProduct(product, deps);
assert.equal(result.status, 'ok');
assert.equal(result.changed, false);
assert.equal(mailCalls.length, 0);
assert.equal(successes.length, 1);
});
test('checkProduct records fetch_error and does not touch price on network failure', async () => {
const { deps, failures, successes } = makeDeps({
fetchAndExtractPrice: async () => ({ price: null, method: null, error: new Error('down') }),
});
const product = { id: 4, name: 'X', url: 'https://x', last_price: 10 };
const result = await checkProduct(product, deps);
assert.equal(result.status, 'fetch_error');
assert.deepEqual(failures, [{ id: 4, status: 'fetch_error' }]);
assert.equal(successes.length, 0);
});
test('checkProduct records not_found when no price could be extracted', async () => {
const { deps, failures, successes } = makeDeps({
fetchAndExtractPrice: async () => ({ price: null, method: null, error: null }),
});
const product = { id: 5, name: 'X', url: 'https://x', last_price: 10 };
const result = await checkProduct(product, deps);
assert.equal(result.status, 'not_found');
assert.deepEqual(failures, [{ id: 5, status: 'not_found' }]);
assert.equal(successes.length, 0);
});
test('checkProduct records mail_error and keeps old price when sending mail fails', async () => {
const { deps, failures, successes } = makeDeps({
sendMail: async () => { throw new Error('smtp down'); },
});
const product = { id: 6, name: 'X', url: 'https://x', last_price: 15 };
const result = await checkProduct(product, deps);
assert.equal(result.status, 'mail_error');
assert.deepEqual(failures, [{ id: 6, status: 'mail_error' }]);
assert.equal(successes.length, 0);
});
test('checkAllProducts processes every product independently, isolating failures', async () => {
const { deps } = makeDeps();
let calls = 0;
const throwingDeps = {
...deps,
fetchAndExtractPrice: async () => {
calls += 1;
if (calls === 1) throw new Error('boom');
return { price: 10, method: 'meta', error: null };
},
};
const products = [
{ id: 1, name: 'A', url: 'https://a', last_price: null },
{ id: 2, name: 'B', url: 'https://b', last_price: null },
];
const results = await checkAllProducts(products, throwingDeps);
assert.equal(results.length, 2);
assert.equal(results[0].status, 'error');
assert.equal(results[1].status, 'ok');
});
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `npm test`
Expected: FAIL with "Cannot find module '../src/checkProducts'"
- [ ] **Step 3: Write `src/checkProducts.js`**
```js
async function checkProduct(product, deps) {
const { fetchAndExtractPrice, sendMail, recordCheckSuccess, recordCheckFailure, now } = deps;
const result = await fetchAndExtractPrice(product.url);
if (result.error) {
recordCheckFailure(product.id, 'fetch_error');
return { status: 'fetch_error' };
}
if (result.price == null) {
recordCheckFailure(product.id, 'not_found');
return { status: 'not_found' };
}
const isBaseline = product.last_price == null;
const changed = !isBaseline && result.price !== product.last_price;
if (changed) {
try {
await sendMail({
name: product.name,
url: product.url,
oldPrice: product.last_price,
newPrice: result.price,
});
} catch {
recordCheckFailure(product.id, 'mail_error');
return { status: 'mail_error' };
}
}
recordCheckSuccess(product.id, { price: result.price, checkedAt: now() });
return { status: 'ok', changed };
}
async function checkAllProducts(products, deps) {
const results = [];
for (const product of products) {
try {
const result = await checkProduct(product, deps);
results.push({ productId: product.id, ...result });
} catch (err) {
results.push({ productId: product.id, status: 'error', error: err });
}
}
return results;
}
module.exports = { checkProduct, checkAllProducts };
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `npm test`
Expected: PASS (all `checkProducts.test.js` tests + previous tests)
- [ ] **Step 5: Commit**
```bash
git add src/checkProducts.js test/checkProducts.test.js
git commit -m "feat: add price-check orchestration with retry-safe error handling"
```
---
### Task 6: Scheduler
**Files:**
- Create: `src/scheduler.js`
- Test: `test/scheduler.test.js`
**Interfaces:**
- Produces:
- `start(runCheck: () => Promise<any>, { cron? }) -> ScheduledTask` — schedules `runCheck` with cron expression `'0 8 * * *'` and `{ timezone: 'Europe/Amsterdam' }`; `cron` defaults to the real `node-cron` module and is injectable for tests.
- `SCHEDULE` (string constant `'0 8 * * *'`), `TIMEZONE` (string constant `'Europe/Amsterdam'`).
- [ ] **Step 1: Write the failing test**
Create `test/scheduler.test.js`:
```js
const test = require('node:test');
const assert = require('node:assert/strict');
const { start, SCHEDULE, TIMEZONE } = require('../src/scheduler');
test('start schedules the daily check with the expected cron expression and timezone', () => {
const calls = [];
const fakeCron = {
schedule: (expression, fn, options) => {
calls.push({ expression, fn, options });
return { stop: () => {} };
},
};
const runCheck = async () => {};
start(runCheck, { cron: fakeCron });
assert.equal(calls.length, 1);
assert.equal(calls[0].expression, SCHEDULE);
assert.equal(SCHEDULE, '0 8 * * *');
assert.equal(calls[0].options.timezone, TIMEZONE);
assert.equal(TIMEZONE, 'Europe/Amsterdam');
assert.equal(calls[0].fn, runCheck);
});
```
- [ ] **Step 2: Run test to verify it fails**
Run: `npm test`
Expected: FAIL with "Cannot find module '../src/scheduler'"
- [ ] **Step 3: Write `src/scheduler.js`**
```js
const cronDefault = require('node-cron');
const SCHEDULE = '0 8 * * *';
const TIMEZONE = 'Europe/Amsterdam';
function start(runCheck, { cron = cronDefault } = {}) {
return cron.schedule(SCHEDULE, runCheck, { timezone: TIMEZONE });
}
module.exports = { start, SCHEDULE, TIMEZONE };
```
- [ ] **Step 4: Run test to verify it passes**
Run: `npm test`
Expected: PASS (all `scheduler.test.js` tests + previous tests)
- [ ] **Step 5: Commit**
```bash
git add src/scheduler.js test/scheduler.test.js
git commit -m "feat: add daily cron scheduler for price checks"
```
---
### Task 7: Views + Express app
**Files:**
- Create: `src/views.js`
- Create: `src/app.js`
- Test: `test/app.test.js`
**Interfaces:**
- Consumes: nothing directly imported from earlier tasks — `createApp(deps)` takes injected `listProducts`, `addProduct`, `deleteProduct`, `checkAllProducts`, `authUser`, `authPass` functions/values, matching the shapes produced by Tasks 2 and 5 (wired together in Task 8).
- Produces:
- `renderIndex({ products: Product[], message?: string }) -> string` (full HTML document)
- `createApp(deps) -> express.Application` with routes: `GET /`, `POST /products`, `POST /products/:id/delete`, `POST /check-now` — all behind basic auth.
- [ ] **Step 1: Write the failing tests**
Create `test/app.test.js`:
```js
const test = require('node:test');
const assert = require('node:assert/strict');
const request = require('supertest');
const { createApp } = require('../src/app');
function makeDeps(overrides = {}) {
let products = [];
let nextId = 1;
return {
listProducts: () => products,
addProduct: ({ name, url }) => {
const product = { id: nextId++, name, url, last_price: null, last_checked_at: null, last_check_status: null };
products.push(product);
return product;
},
deleteProduct: (id) => { products = products.filter((p) => p.id !== id); },
checkAllProducts: async () => [],
authUser: 'admin',
authPass: 'secret',
...overrides,
};
}
test('rejects requests without basic auth credentials', async () => {
const app = createApp(makeDeps());
const res = await request(app).get('/');
assert.equal(res.status, 401);
});
test('lists products for authenticated requests', async () => {
const deps = makeDeps();
deps.addProduct({ name: 'Voorbeeld', url: 'https://example.com' });
const app = createApp(deps);
const res = await request(app).get('/').auth('admin', 'secret');
assert.equal(res.status, 200);
assert.match(res.text, /Voorbeeld/);
});
test('adding a product redirects to the list', async () => {
const deps = makeDeps();
const app = createApp(deps);
const res = await request(app)
.post('/products')
.auth('admin', 'secret')
.type('form')
.send({ name: 'Nieuw product', url: 'https://shop.example/p' });
assert.equal(res.status, 302);
assert.equal(res.headers.location, '/');
const list = await request(app).get('/').auth('admin', 'secret');
assert.match(list.text, /Nieuw product/);
});
test('deleting a product removes it from the list', async () => {
const deps = makeDeps();
const product = deps.addProduct({ name: 'Weg ermee', url: 'https://shop.example/p2' });
const app = createApp(deps);
const res = await request(app).post(`/products/${product.id}/delete`).auth('admin', 'secret');
assert.equal(res.status, 302);
const list = await request(app).get('/').auth('admin', 'secret');
assert.doesNotMatch(list.text, /Weg ermee/);
});
test('check-now triggers checkAllProducts and redirects with confirmation', async () => {
let called = false;
const deps = makeDeps({ checkAllProducts: async () => { called = true; return []; } });
const app = createApp(deps);
const res = await request(app).post('/check-now').auth('admin', 'secret');
assert.equal(res.status, 302);
assert.equal(res.headers.location, '/?checked=1');
assert.equal(called, true);
});
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `npm test`
Expected: FAIL with "Cannot find module '../src/app'"
- [ ] **Step 3: Write `src/views.js`**
```js
function escapeHtml(str) {
return String(str).replace(/[&<>"']/g, (c) => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
}[c]));
}
function formatPrice(price) {
return price == null ? '—' : `${Number(price).toFixed(2).replace('.', ',')}`;
}
function renderProductRow(product) {
return `
<tr>
<td>${escapeHtml(product.name)}</td>
<td><a href="${escapeHtml(product.url)}" target="_blank" rel="noopener">bekijken</a></td>
<td>${formatPrice(product.last_price)}</td>
<td>${product.last_checked_at ? escapeHtml(product.last_checked_at) : '—'}</td>
<td>${escapeHtml(product.last_check_status || '—')}</td>
<td>
<form method="post" action="/products/${product.id}/delete" onsubmit="return confirm('Product verwijderen?');">
<button type="submit">Verwijder</button>
</form>
</td>
</tr>`;
}
function renderIndex({ products, message }) {
return `<!doctype html>
<html lang="nl">
<head>
<meta charset="utf-8">
<title>Korting — prijstracker</title>
<style>
body { font-family: system-ui, sans-serif; max-width: 800px; margin: 2rem auto; padding: 0 1rem; }
table { width: 100%; border-collapse: collapse; margin-top: 1rem; }
th, td { text-align: left; padding: 0.5rem; border-bottom: 1px solid #ddd; }
.message { background: #eef; padding: 0.5rem 1rem; border-radius: 4px; }
</style>
</head>
<body>
<h1>Korting — prijstracker</h1>
${message ? `<p class="message">${escapeHtml(message)}</p>` : ''}
<form method="post" action="/products">
<input name="name" placeholder="Naam" required>
<input name="url" type="url" placeholder="https://..." required>
<button type="submit">Toevoegen</button>
</form>
<form method="post" action="/check-now">
<button type="submit">Nu controleren</button>
</form>
<table>
<thead>
<tr><th>Naam</th><th>URL</th><th>Prijs</th><th>Laatst gecontroleerd</th><th>Status</th><th></th></tr>
</thead>
<tbody>
${products.map(renderProductRow).join('')}
</tbody>
</table>
</body>
</html>`;
}
module.exports = { renderIndex, escapeHtml, formatPrice };
```
- [ ] **Step 4: Write `src/app.js`**
```js
const express = require('express');
const basicAuth = require('express-basic-auth');
const { renderIndex } = require('./views');
function createApp(deps) {
const {
listProducts, addProduct, deleteProduct,
checkAllProducts, authUser, authPass,
} = deps;
const app = express();
app.use(express.urlencoded({ extended: false }));
app.use(basicAuth({ users: { [authUser]: authPass }, challenge: true }));
app.get('/', (req, res) => {
const products = listProducts();
const message = req.query.checked ? 'Controle uitgevoerd.' : undefined;
res.send(renderIndex({ products, message }));
});
app.post('/products', (req, res) => {
const { name, url } = req.body;
if (name && url) {
addProduct({ name, url });
}
res.redirect('/');
});
app.post('/products/:id/delete', (req, res) => {
deleteProduct(Number(req.params.id));
res.redirect('/');
});
app.post('/check-now', async (req, res) => {
const products = listProducts();
await checkAllProducts(products);
res.redirect('/?checked=1');
});
return app;
}
module.exports = { createApp };
```
- [ ] **Step 5: Run tests to verify they pass**
Run: `npm test`
Expected: PASS (all `app.test.js` tests + previous tests)
- [ ] **Step 6: Commit**
```bash
git add src/views.js src/app.js test/app.test.js
git commit -m "feat: add web UI for managing products"
```
---
### Task 8: Composition root (server entry point)
**Files:**
- Create: `server.js`
**Interfaces:**
- Consumes: `openDb`, `listProducts`, `addProduct`, `deleteProduct`, `recordCheckSuccess`, `recordCheckFailure` (Task 2); `fetchAndExtractPrice` (Task 3); `createTransport`, `sendPriceChangeEmail` (Task 4); `checkAllProducts` (Task 5); `start` as `startScheduler` (Task 6); `createApp` (Task 7).
- Produces: the running HTTP server (no further consumers — this is the top of the dependency graph).
No new automated tests here — this file only wires together already-tested pieces. It is verified with a manual smoke check.
- [ ] **Step 1: Write `server.js`**
```js
const path = require('node:path');
const {
openDb, listProducts, addProduct, deleteProduct,
recordCheckSuccess, recordCheckFailure,
} = require('./src/db');
const { fetchAndExtractPrice } = require('./src/scraper');
const { createTransport, sendPriceChangeEmail } = require('./src/mailer');
const { checkAllProducts } = require('./src/checkProducts');
const { start: startScheduler } = require('./src/scheduler');
const { createApp } = require('./src/app');
const PORT = process.env.PORT || 3000;
const DB_PATH = process.env.DB_PATH || path.join(__dirname, 'data', 'korting.db');
const db = openDb(DB_PATH);
const transport = createTransport(process.env);
function runCheck() {
const products = listProducts(db);
const deps = {
fetchAndExtractPrice,
sendMail: (payload) => sendPriceChangeEmail(transport, {
from: process.env.SMTP_FROM,
to: process.env.NOTIFY_EMAIL,
...payload,
}),
recordCheckFailure: (id, status) => recordCheckFailure(db, id, status),
recordCheckSuccess: (id, data) => recordCheckSuccess(db, id, data),
now: () => new Date().toISOString(),
};
return checkAllProducts(products, deps);
}
startScheduler(runCheck);
const app = createApp({
listProducts: () => listProducts(db),
addProduct: (data) => addProduct(db, data),
deleteProduct: (id) => deleteProduct(db, id),
checkAllProducts: runCheck,
authUser: process.env.BASIC_AUTH_USER,
authPass: process.env.BASIC_AUTH_PASS,
});
app.listen(PORT, () => {
console.log(`Korting draait op poort ${PORT}`);
});
```
- [ ] **Step 2: Manual smoke check**
Run:
```bash
cp .env.example .env
# edit .env: fill in a real BASIC_AUTH_USER/PASS at minimum
set -a; source .env; set +a
node server.js
```
In another terminal:
```bash
curl -u "$BASIC_AUTH_USER:$BASIC_AUTH_PASS" http://localhost:3000/
curl -u "$BASIC_AUTH_USER:$BASIC_AUTH_PASS" -X POST http://localhost:3000/products \
-d "name=Testproduct" -d "url=https://example.com"
curl -u "$BASIC_AUTH_USER:$BASIC_AUTH_PASS" http://localhost:3000/
```
Expected: first `curl` returns the HTML page; second redirects (302); third shows "Testproduct" in the table. Stop the server with Ctrl-C.
- [ ] **Step 3: Commit**
```bash
git add server.js
git commit -m "feat: wire modules together into the running server"
```
---
### Task 9: Dockerfile
**Files:**
- Create: `Dockerfile`
- Create: `.dockerignore`
**Interfaces:**
- Produces: a Docker image that runs `node server.js` on `$PORT` (default 3000), expecting `/data` to be mounted as a persistent volume when `DB_PATH=/data/korting.db` is set.
- [ ] **Step 1: Write `Dockerfile`**
`better-sqlite3` needs a native build step, so this uses a two-stage build: the first stage has build tools to compile it, the second stage is the slim runtime image.
```dockerfile
FROM node:22-slim AS build
RUN apt-get update && apt-get install -y --no-install-recommends python3 build-essential \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY . .
FROM node:22-slim
WORKDIR /app
COPY --from=build /app .
ENV NODE_ENV=production
EXPOSE 3000
CMD ["node", "server.js"]
```
- [ ] **Step 2: Write `.dockerignore`**
```
node_modules
data
.env
.git
docs
test
```
- [ ] **Step 3: Manual build/run verification**
Run:
```bash
docker build -t korting .
docker run --rm -p 3000:3000 \
-e BASIC_AUTH_USER=admin -e BASIC_AUTH_PASS=test \
-e SMTP_HOST=mail.sjoerd.app -e SMTP_PORT=587 \
-e SMTP_FROM=korting@sjoerd.app -e NOTIFY_EMAIL=test@example.com \
-e DB_PATH=/data/korting.db -v korting-data:/data \
korting
```
In another terminal: `curl -u admin:test http://localhost:3000/` should return the HTML page. Stop with Ctrl-C.
- [ ] **Step 4: Commit**
```bash
git add Dockerfile .dockerignore
git commit -m "chore: add Dockerfile for deployment"
```
---
### Task 10: README
**Files:**
- Create: `README.md`
- [ ] **Step 1: Write `README.md`**
```markdown
# Korting — prijstracker
Houdt een lijst productpagina's dagelijks in de gaten en mailt wanneer
de prijs verandert.
## Lokaal draaien
\`\`\`bash
npm install
cp .env.example .env # env vars invullen
npm test
npm start
\`\`\`
De UI staat achter basic auth (`BASIC_AUTH_USER` / `BASIC_AUTH_PASS`) op
`http://localhost:3000`.
## Environment variables
| Variabele | Omschrijving |
|---|---|
| `PORT` | HTTP-poort (default `3000`) |
| `DB_PATH` | Pad naar het SQLite-bestand (default `./data/korting.db`) |
| `BASIC_AUTH_USER` / `BASIC_AUTH_PASS` | Login voor de web-UI |
| `SMTP_HOST` / `SMTP_PORT` / `SMTP_USER` / `SMTP_PASS` | SMTP-server (Postal op `mail.sjoerd.app`) |
| `SMTP_FROM` | Afzenderadres van de notificatiemails |
| `NOTIFY_EMAIL` | Ontvangeradres van de notificatiemails |
## Hoe het werkt
Elke dag om 08:00 (Europe/Amsterdam) doorloopt de app alle producten,
haalt de huidige prijs op (JSON-LD → meta tags → regex-fallback), en
stuurt een email als de prijs is veranderd sinds de vorige geslaagde
check. Een mislukte poging (pagina onbereikbaar, geen prijs gevonden,
of de mail kon niet verstuurd worden) verandert de opgeslagen prijs
niet, zodat de volgende dag opnieuw wordt geprobeerd. Via de "Nu
controleren"-knop in de UI kan dit ook handmatig getriggerd worden.
```
- [ ] **Step 2: Commit**
```bash
git add README.md
git commit -m "docs: add README"
```
---
## After this plan
Deployment (Gitea repo + Coolify application, domain `korting.sjoerd.app`,
persistent volume, env vars, auto-deploy on push) is handled separately
via the `deploying-to-coolify` skill — it is an operational step, not a
coding task.