# preis-tracker 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:** Eine Single-User-Webapp, die Produktpreise bei Amazon, Idealo und Geizhals täglich trackt, einen Preisverlauf anzeigt und bei konfigurierbaren Bedingungen Pushover-Notifications sendet.
**Architecture:** Next.js 16 App Router auf Coolify, Shared PostgreSQL (DB `preistracker`), Playwright für Amazon + Cheerio für Idealo/Geizhals via Adapter-Pattern. Zitadel OIDC (manueller PKCE) sichert die App; ein Coolify Scheduled Task triggert daily um 06:00 die Scrape-API. Alerts (Zielpreis / Allzeit-Tief / Prozent-Drop) lösen Pushover-Pushes aus.
**Tech Stack:** Next.js 16.2.2, React 19, TypeScript 5, Tailwind CSS 4, Drizzle 0.45 + postgres-js, Playwright 1.50, Cheerio 1.0, iron-session 8, jose 5 (JWKS), Recharts 3, Bun 1.3 (dev), Node 22 (Container), Vitest 2 (tests).
**Spec:** `docs/superpowers/specs/2026-05-25-preis-tracker-design.md`
**⚠ Next.js 16 Hinweis:** Diese Next-Version hat Breaking Changes gegenüber 14/15. **Vor dem Schreiben von App-Router-Code** in `node_modules/next/dist/docs/` (oder via context7 MCP) die aktuellen API-Signaturen für `route.ts`, `middleware.ts`, `params`-Async, `cookies()`, `headers()` prüfen. Trainingsdaten sind potentiell veraltet.
---
## File Map
```
preis-tracker/
├── package.json
├── tsconfig.json
├── next.config.ts
├── drizzle.config.ts
├── postcss.config.mjs
├── eslint.config.mjs
├── Dockerfile
├── .dockerignore
├── .gitignore
├── .env.example
├── README.md
├── vitest.config.ts
├── drizzle/ # Auto-generated migrations
├── tests/
│ ├── fixtures/
│ │ ├── amazon-ps5.html
│ │ ├── idealo-headphones.html
│ │ └── geizhals-gpu.html
│ ├── scrapers/
│ │ ├── geizhals.test.ts
│ │ ├── idealo.test.ts
│ │ └── amazon.test.ts
│ ├── alerts/evaluate.test.ts
│ └── pushover.test.ts
└── src/
├── middleware.ts # Auth gate
├── app/
│ ├── layout.tsx
│ ├── globals.css
│ ├── page.tsx # Dashboard
│ ├── add/page.tsx # Add-Product Form
│ ├── products/[id]/page.tsx # Detail + Chart
│ └── api/
│ ├── auth/
│ │ ├── login/route.ts
│ │ ├── callback/route.ts
│ │ └── logout/route.ts
│ ├── products/
│ │ ├── route.ts # POST add, GET list
│ │ └── [id]/
│ │ ├── route.ts # GET detail, DELETE
│ │ └── scrape/route.ts # POST manual scrape
│ ├── alerts/
│ │ ├── route.ts # POST create
│ │ └── [id]/route.ts # DELETE
│ └── cron/scrape/route.ts # Daily scrape entry
├── lib/
│ ├── db/
│ │ ├── index.ts # drizzle client
│ │ └── schema.ts # tables + types
│ ├── auth/
│ │ ├── session.ts # iron-session config
│ │ ├── pkce.ts # PKCE helpers
│ │ └── zitadel.ts # OIDC token exchange + JWKS
│ ├── scrapers/
│ │ ├── types.ts
│ │ ├── index.ts # registry + scrapeUrl()
│ │ ├── amazon.ts
│ │ ├── idealo.ts
│ │ └── geizhals.ts
│ ├── alerts/
│ │ └── evaluate.ts # pure alert-trigger logic
│ ├── pushover.ts
│ └── shops.ts # shop detection from URL
└── components/
├── ProductCard.tsx
├── Sparkline.tsx
├── PriceChart.tsx
├── AlertList.tsx
└── AlertForm.tsx
```
---
## Task 1: Project Bootstrap
**Files:**
- Create: `package.json`
- Create: `tsconfig.json`
- Create: `next.config.ts`
- Create: `postcss.config.mjs`
- Create: `eslint.config.mjs`
- Create: `.gitignore`
- Create: `.env.example`
- Create: `src/app/layout.tsx`
- Create: `src/app/page.tsx`
- Create: `src/app/globals.css`
- [ ] **Step 1: Create `package.json`**
```json
{
"name": "preis-tracker",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint",
"test": "vitest run",
"test:watch": "vitest",
"db:generate": "drizzle-kit generate",
"db:push": "drizzle-kit push",
"db:studio": "drizzle-kit studio"
},
"dependencies": {
"@types/cheerio": "^0.22.35",
"cheerio": "^1.0.0",
"drizzle-orm": "^0.45.2",
"iron-session": "^8.0.4",
"jose": "^5.9.6",
"next": "16.2.2",
"playwright": "^1.50.0",
"postgres": "^3.4.8",
"react": "19.2.4",
"react-dom": "19.2.4",
"recharts": "^3.8.1",
"zod": "^3.23.8"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"drizzle-kit": "^0.31.10",
"eslint": "^9",
"eslint-config-next": "16.2.2",
"tailwindcss": "^4",
"tsx": "^4.21.0",
"typescript": "^5",
"vitest": "^2.1.8"
}
}
```
- [ ] **Step 2: Create `tsconfig.json`**
```json
{
"compilerOptions": {
"target": "ES2022",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": false,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [{ "name": "next" }],
"paths": { "@/*": ["./src/*"] }
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}
```
- [ ] **Step 3: Create `next.config.ts`**
```typescript
import type { NextConfig } from 'next'
const config: NextConfig = {
output: 'standalone',
experimental: {
serverActions: { allowedOrigins: ['preis.kuns.dev'] },
},
images: {
remotePatterns: [
{ protocol: 'https', hostname: '**.amazon.com' },
{ protocol: 'https', hostname: '**.amazon.de' },
{ protocol: 'https', hostname: '**.media-amazon.com' },
{ protocol: 'https', hostname: '**.idealo.com' },
{ protocol: 'https', hostname: '**.geizhals.de' },
],
},
}
export default config
```
- [ ] **Step 4: Create `postcss.config.mjs`**
```javascript
export default { plugins: { '@tailwindcss/postcss': {} } }
```
- [ ] **Step 5: Create `eslint.config.mjs`**
```javascript
import next from 'eslint-config-next'
export default [...next, { rules: { '@typescript-eslint/no-explicit-any': 'warn' } }]
```
- [ ] **Step 6: Create `.gitignore`**
```
node_modules/
.next/
.env
.env.local
*.log
.DS_Store
out/
build/
.turbo/
coverage/
test-results/
```
- [ ] **Step 7: Create `.env.example`**
```
# Database
DATABASE_URL=postgresql://preistracker:CHANGEME@localhost:5432/preistracker
# Zitadel OIDC
ZITADEL_ISSUER=https://auth.kuns.dev
ZITADEL_CLIENT_ID=
ALLOWED_USER_IDS=
# Session (32+ chars, random)
SESSION_PASSWORD=
# Pushover
PUSHOVER_TOKEN=
PUSHOVER_USER=
# Cron secret (any random string)
CRON_SECRET=
# Public URL (for OIDC redirects)
NEXT_PUBLIC_BASE_URL=http://localhost:3000
```
- [ ] **Step 8: Create `src/app/globals.css`**
```css
@import "tailwindcss";
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
body {
background: var(--background);
color: var(--foreground);
font-family: ui-sans-serif, system-ui, sans-serif;
}
```
- [ ] **Step 9: Create `src/app/layout.tsx`**
```tsx
import './globals.css'
import type { Metadata } from 'next'
export const metadata: Metadata = {
title: 'Preis-Tracker',
description: 'Tracking von Produktpreisen bei Amazon, Idealo, Geizhals',
}
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
{children}
)
}
```
- [ ] **Step 10: Create stub `src/app/page.tsx`**
```tsx
export default function Home() {
return Preis-Tracker
}
```
- [ ] **Step 11: Install + verify boot**
```bash
cd ~/preis-tracker
bun install
bun run dev &
sleep 5
curl -sf http://localhost:3000 > /dev/null && echo OK
kill %1 2>/dev/null
```
Expected: `OK`
- [ ] **Step 12: Commit**
```bash
git add -A
git commit -m "feat: bootstrap next.js + tailwind + deps"
```
---
## Task 2: Database Schema + Migrations
**Files:**
- Create: `drizzle.config.ts`
- Create: `src/lib/db/schema.ts`
- Create: `src/lib/db/index.ts`
- [ ] **Step 1: Provision DB in Shared PostgreSQL**
```bash
# As user mika on kuns.dev
source ~/.secrets/coolify-tokens.env
PGPASSWORD="$SHARED_POSTGRES_PASSWORD" psql -h localhost -p 54320 -U mika -d postgres \
-c "CREATE DATABASE preistracker;" \
-c "CREATE USER preistracker WITH PASSWORD 'GENERATE_AND_NOTE_DOWN';" \
-c "GRANT ALL PRIVILEGES ON DATABASE preistracker TO preistracker;"
PGPASSWORD="$SHARED_POSTGRES_PASSWORD" psql -h localhost -p 54320 -U mika -d preistracker \
-c "GRANT ALL ON SCHEMA public TO preistracker;"
```
Update `.env` (NOT `.env.example`) with the generated password.
- [ ] **Step 2: Create `drizzle.config.ts`**
```typescript
import 'dotenv/config'
import { defineConfig } from 'drizzle-kit'
export default defineConfig({
schema: './src/lib/db/schema.ts',
out: './drizzle',
dialect: 'postgresql',
dbCredentials: { url: process.env.DATABASE_URL! },
})
```
- [ ] **Step 3: Create `src/lib/db/schema.ts`**
```typescript
import { pgTable, uuid, text, timestamp, boolean, integer, numeric, bigserial, jsonb, index, check } from 'drizzle-orm/pg-core'
import { sql } from 'drizzle-orm'
export const products = pgTable('products', {
id: uuid('id').primaryKey().defaultRandom(),
url: text('url').notNull().unique(),
shop: text('shop').notNull(),
name: text('name').notNull(),
imageUrl: text('image_url'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
enabled: boolean('enabled').notNull().default(true),
lastScrapedAt: timestamp('last_scraped_at', { withTimezone: true }),
consecutiveFailures: integer('consecutive_failures').notNull().default(0),
}, (t) => ({
shopCheck: check('shop_check', sql`${t.shop} in ('amazon','idealo','geizhals')`),
}))
export const priceSnapshots = pgTable('price_snapshots', {
id: bigserial('id', { mode: 'number' }).primaryKey(),
productId: uuid('product_id').notNull().references(() => products.id, { onDelete: 'cascade' }),
price: numeric('price', { precision: 10, scale: 2 }),
currency: text('currency').notNull().default('EUR'),
availability: text('availability'),
error: text('error'),
scrapedAt: timestamp('scraped_at', { withTimezone: true }).notNull().defaultNow(),
}, (t) => ({
productScrapedIdx: index('snapshots_product_scraped_idx').on(t.productId, t.scrapedAt.desc()),
}))
export const alerts = pgTable('alerts', {
id: uuid('id').primaryKey().defaultRandom(),
productId: uuid('product_id').notNull().references(() => products.id, { onDelete: 'cascade' }),
type: text('type').notNull(),
config: jsonb('config').notNull(),
enabled: boolean('enabled').notNull().default(true),
lastTriggeredAt: timestamp('last_triggered_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
}, (t) => ({
typeCheck: check('alert_type_check', sql`${t.type} in ('target_price','all_time_low','percent_drop')`),
}))
export type Product = typeof products.$inferSelect
export type NewProduct = typeof products.$inferInsert
export type PriceSnapshot = typeof priceSnapshots.$inferSelect
export type Alert = typeof alerts.$inferSelect
export type NewAlert = typeof alerts.$inferInsert
```
- [ ] **Step 4: Create `src/lib/db/index.ts`**
```typescript
import { drizzle } from 'drizzle-orm/postgres-js'
import postgres from 'postgres'
import * as schema from './schema'
const connectionString = process.env.DATABASE_URL
if (!connectionString) throw new Error('DATABASE_URL not set')
const client = postgres(connectionString, { max: 5 })
export const db = drizzle(client, { schema })
export * from './schema'
```
- [ ] **Step 5: Generate + run migration**
```bash
bun run db:generate
bun run db:push
```
Expected: `drizzle/` directory created with SQL, DB has 3 tables.
- [ ] **Step 6: Verify tables exist**
```bash
PGPASSWORD="" psql -h localhost -p 54320 -U preistracker -d preistracker -c "\dt"
```
Expected: shows `products`, `price_snapshots`, `alerts`.
- [ ] **Step 7: Commit**
```bash
git add -A
git commit -m "feat: drizzle schema + migrations for products/snapshots/alerts"
```
---
## Task 3: Vitest Setup + Shop Detection
**Files:**
- Create: `vitest.config.ts`
- Create: `src/lib/shops.ts`
- Create: `tests/shops.test.ts`
- [ ] **Step 1: Create `vitest.config.ts`**
```typescript
import { defineConfig } from 'vitest/config'
import path from 'node:path'
export default defineConfig({
test: {
environment: 'node',
include: ['tests/**/*.test.ts'],
},
resolve: { alias: { '@': path.resolve(__dirname, './src') } },
})
```
- [ ] **Step 2: Write failing test `tests/shops.test.ts`**
```typescript
import { describe, it, expect } from 'vitest'
import { detectShop } from '@/lib/shops'
describe('detectShop', () => {
it.each([
['https://www.amazon.de/dp/B0C5BMMJTL', 'amazon'],
['https://amazon.com/gp/product/B0C5BMMJTL', 'amazon'],
['https://www.idealo.de/preisvergleich/OffersOfProduct/123456_-foo.html', 'idealo'],
['https://geizhals.de/sony-playstation-5-a2362829.html', 'geizhals'],
['https://www.geizhals.eu/foo', 'geizhals'],
])('detects %s as %s', (url, expected) => {
expect(detectShop(url)).toBe(expected)
})
it('returns null for unknown shop', () => {
expect(detectShop('https://example.com/foo')).toBeNull()
})
it('returns null for invalid url', () => {
expect(detectShop('not a url')).toBeNull()
})
})
```
- [ ] **Step 3: Run test, expect failure**
```bash
bun run test
```
Expected: FAIL — `detectShop` is not exported.
- [ ] **Step 4: Implement `src/lib/shops.ts`**
```typescript
export type Shop = 'amazon' | 'idealo' | 'geizhals'
const PATTERNS: Array<{ shop: Shop; hostMatch: RegExp }> = [
{ shop: 'amazon', hostMatch: /(^|\.)amazon\.(de|com|co\.uk|fr|it|es|nl)$/i },
{ shop: 'idealo', hostMatch: /(^|\.)idealo\.(de|com|at|fr|it|es|co\.uk)$/i },
{ shop: 'geizhals', hostMatch: /(^|\.)geizhals\.(de|at|eu)$/i },
]
export function detectShop(input: string): Shop | null {
let host: string
try {
host = new URL(input).hostname
} catch {
return null
}
return PATTERNS.find((p) => p.hostMatch.test(host))?.shop ?? null
}
```
- [ ] **Step 5: Run test, expect pass**
```bash
bun run test
```
Expected: All tests pass.
- [ ] **Step 6: Commit**
```bash
git add -A
git commit -m "feat: shop detection from URL + vitest setup"
```
---
## Task 4: Scraper Adapter Framework
**Files:**
- Create: `src/lib/scrapers/types.ts`
- Create: `src/lib/scrapers/index.ts`
- Create: `tests/scrapers/registry.test.ts`
- [ ] **Step 1: Create `src/lib/scrapers/types.ts`**
```typescript
import type { Shop } from '@/lib/shops'
export interface ScrapeResult {
price: number | null
currency: string
availability: 'in_stock' | 'out_of_stock' | 'unknown'
name?: string
imageUrl?: string
error?: string
}
export interface PriceScraper {
shop: Shop
scrape(url: string): Promise
}
```
- [ ] **Step 2: Write failing test `tests/scrapers/registry.test.ts`**
```typescript
import { describe, it, expect, vi } from 'vitest'
import { scrapeUrl, registerScraperForTest, resetScrapersForTest } from '@/lib/scrapers'
import type { PriceScraper } from '@/lib/scrapers/types'
describe('scrapeUrl', () => {
it('dispatches to matching shop scraper', async () => {
const fake: PriceScraper = {
shop: 'geizhals',
scrape: vi.fn().mockResolvedValue({
price: 42, currency: 'EUR', availability: 'in_stock', name: 'X',
}),
}
resetScrapersForTest()
registerScraperForTest(fake)
const result = await scrapeUrl('https://geizhals.de/foo')
expect(result.price).toBe(42)
expect(fake.scrape).toHaveBeenCalledWith('https://geizhals.de/foo')
})
it('throws on unknown shop', async () => {
resetScrapersForTest()
await expect(scrapeUrl('https://example.com')).rejects.toThrow(/unsupported/i)
})
})
```
- [ ] **Step 3: Run test, expect failure**
```bash
bun run test tests/scrapers/registry.test.ts
```
Expected: FAIL — module missing.
- [ ] **Step 4: Implement `src/lib/scrapers/index.ts`**
```typescript
import { detectShop, type Shop } from '@/lib/shops'
import type { PriceScraper, ScrapeResult } from './types'
const registry = new Map()
export function registerScraper(scraper: PriceScraper) {
registry.set(scraper.shop, scraper)
}
export async function scrapeUrl(url: string): Promise {
const shop = detectShop(url)
if (!shop) throw new Error(`Unsupported URL: ${url}`)
const scraper = registry.get(shop)
if (!scraper) throw new Error(`No scraper registered for shop: ${shop}`)
return scraper.scrape(url)
}
export function registerScraperForTest(s: PriceScraper) { registry.set(s.shop, s) }
export function resetScrapersForTest() { registry.clear() }
// Real scrapers register themselves at module init when imported from a server entrypoint.
// See src/lib/scrapers/{amazon,idealo,geizhals}.ts and src/lib/scrapers/register.ts in Task 8.
```
- [ ] **Step 5: Run test, expect pass**
```bash
bun run test tests/scrapers/registry.test.ts
```
Expected: PASS.
- [ ] **Step 6: Commit**
```bash
git add -A
git commit -m "feat: scraper registry + adapter interface"
```
---
## Task 5: Geizhals Scraper
**Files:**
- Create: `src/lib/scrapers/geizhals.ts`
- Create: `tests/fixtures/geizhals-gpu.html`
- Create: `tests/scrapers/geizhals.test.ts`
- [ ] **Step 1: Capture fixture HTML**
```bash
curl -sL -A 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36' \
'https://geizhals.de/sony-playstation-5-pro-a3163879.html' \
> tests/fixtures/geizhals-gpu.html
```
Verify file is >50KB and contains a price tag:
```bash
grep -oE 'class="[^"]*price[^"]*"' tests/fixtures/geizhals-gpu.html | head -3
```
- [ ] **Step 2: Write failing test `tests/scrapers/geizhals.test.ts`**
```typescript
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { geizhalsScraper } from '@/lib/scrapers/geizhals'
const fixture = readFileSync(join(__dirname, '../fixtures/geizhals-gpu.html'), 'utf-8')
beforeEach(() => {
global.fetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
text: async () => fixture,
}) as unknown as typeof fetch
})
describe('geizhalsScraper', () => {
it('extracts price and name', async () => {
const r = await geizhalsScraper.scrape('https://geizhals.de/test')
expect(r.price).toBeGreaterThan(0)
expect(r.currency).toBe('EUR')
expect(r.name).toBeTruthy()
})
it('returns error on HTTP failure', async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: false, status: 503, text: async () => '',
}) as unknown as typeof fetch
const r = await geizhalsScraper.scrape('https://geizhals.de/test')
expect(r.price).toBeNull()
expect(r.error).toMatch(/HTTP 503/)
})
})
```
- [ ] **Step 3: Run test, expect failure**
```bash
bun run test tests/scrapers/geizhals.test.ts
```
Expected: FAIL — module missing.
- [ ] **Step 4: Implement `src/lib/scrapers/geizhals.ts`**
```typescript
import * as cheerio from 'cheerio'
import type { PriceScraper, ScrapeResult } from './types'
const UA = 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36'
export const geizhalsScraper: PriceScraper = {
shop: 'geizhals',
async scrape(url: string): Promise {
try {
const res = await fetch(url, {
headers: { 'User-Agent': UA, 'Accept-Language': 'de-DE,de;q=0.9' },
signal: AbortSignal.timeout(20_000),
})
if (!res.ok) {
return { price: null, currency: 'EUR', availability: 'unknown', error: `HTTP ${res.status}` }
}
const $ = cheerio.load(await res.text())
// Try multiple selectors (price layout varies)
const priceTexts = [
$('.gh_price').first().text(),
$('span.gh_price strong').first().text(),
$('[itemprop="price"]').attr('content'),
$('meta[itemprop="price"]').attr('content'),
].filter(Boolean) as string[]
const price = parsePrice(priceTexts[0] ?? '')
const name = ($('h1[itemprop="name"]').text() || $('h1').first().text() || '').trim()
const imageUrl = $('img.product-gallery__image').first().attr('src')
|| $('meta[property="og:image"]').attr('content')
|| undefined
if (price === null) {
return { price: null, currency: 'EUR', availability: 'unknown', name, imageUrl, error: 'price-selector-missed' }
}
return { price, currency: 'EUR', availability: 'in_stock', name, imageUrl }
} catch (err) {
return { price: null, currency: 'EUR', availability: 'unknown', error: (err as Error).message }
}
},
}
function parsePrice(text: string): number | null {
// "€ 123,45" or "123,45 €" or "123.45"
const cleaned = text.replace(/[^\d.,]/g, '').replace(/\.(?=\d{3}(\D|$))/g, '').replace(',', '.')
if (!cleaned) return null
const n = parseFloat(cleaned)
return Number.isFinite(n) && n > 0 ? n : null
}
```
- [ ] **Step 5: Run test, expect pass**
```bash
bun run test tests/scrapers/geizhals.test.ts
```
Expected: PASS. If price extraction fails, inspect the fixture, adjust selectors in the scraper.
- [ ] **Step 6: Commit**
```bash
git add -A
git commit -m "feat: geizhals scraper with cheerio + tests"
```
---
## Task 6: Idealo Scraper
**Files:**
- Create: `src/lib/scrapers/idealo.ts`
- Create: `tests/fixtures/idealo-headphones.html`
- Create: `tests/scrapers/idealo.test.ts`
- [ ] **Step 1: Capture fixture**
```bash
curl -sL -A 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 Chrome/120.0' \
'https://www.idealo.de/preisvergleich/OffersOfProduct/202740303_-wh-1000xm5-sony.html' \
> tests/fixtures/idealo-headphones.html
ls -lh tests/fixtures/idealo-headphones.html
```
Verify size >20KB. If Cloudflare blocks (file <5KB), capture from a browser dev-tools "Save as HTML" instead.
- [ ] **Step 2: Write failing test `tests/scrapers/idealo.test.ts`**
```typescript
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { idealoScraper } from '@/lib/scrapers/idealo'
const fixture = readFileSync(join(__dirname, '../fixtures/idealo-headphones.html'), 'utf-8')
beforeEach(() => {
global.fetch = vi.fn().mockResolvedValue({
ok: true, status: 200, text: async () => fixture,
}) as unknown as typeof fetch
})
describe('idealoScraper', () => {
it('extracts price and name', async () => {
const r = await idealoScraper.scrape('https://www.idealo.de/foo')
expect(r.price).toBeGreaterThan(0)
expect(r.currency).toBe('EUR')
expect(r.name).toBeTruthy()
})
it('flags cloudflare challenge', async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: false, status: 403, text: async () => 'Cloudflare',
}) as unknown as typeof fetch
const r = await idealoScraper.scrape('https://www.idealo.de/foo')
expect(r.price).toBeNull()
expect(r.error).toMatch(/403|cloudflare/i)
})
})
```
- [ ] **Step 3: Run test, expect failure**
```bash
bun run test tests/scrapers/idealo.test.ts
```
Expected: FAIL — module missing.
- [ ] **Step 4: Implement `src/lib/scrapers/idealo.ts`**
```typescript
import * as cheerio from 'cheerio'
import type { PriceScraper, ScrapeResult } from './types'
const UA = 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36'
export const idealoScraper: PriceScraper = {
shop: 'idealo',
async scrape(url: string): Promise {
try {
const res = await fetch(url, {
headers: {
'User-Agent': UA,
'Accept': 'text/html,application/xhtml+xml',
'Accept-Language': 'de-DE,de;q=0.9',
},
signal: AbortSignal.timeout(20_000),
})
if (!res.ok) {
return { price: null, currency: 'EUR', availability: 'unknown', error: `HTTP ${res.status}` }
}
const $ = cheerio.load(await res.text())
const priceTexts = [
$('[data-testid="detail-offer-price"]').first().text(),
$('meta[itemprop="price"]').attr('content'),
$('span.oopStage-conditionButton-price').first().text(),
$('strong.oopStage-price').first().text(),
].filter(Boolean) as string[]
const price = parsePrice(priceTexts[0] ?? '')
const name = ($('h1[data-testid="product-title"]').text() || $('h1').first().text() || '').trim()
const imageUrl = $('meta[property="og:image"]').attr('content') || undefined
if (price === null) {
return { price: null, currency: 'EUR', availability: 'unknown', name, imageUrl, error: 'price-selector-missed' }
}
return { price, currency: 'EUR', availability: 'in_stock', name, imageUrl }
} catch (err) {
return { price: null, currency: 'EUR', availability: 'unknown', error: (err as Error).message }
}
},
}
function parsePrice(text: string): number | null {
const cleaned = text.replace(/[^\d.,]/g, '').replace(/\.(?=\d{3}(\D|$))/g, '').replace(',', '.')
if (!cleaned) return null
const n = parseFloat(cleaned)
return Number.isFinite(n) && n > 0 ? n : null
}
```
- [ ] **Step 5: Run test, expect pass**
```bash
bun run test tests/scrapers/idealo.test.ts
```
Expected: PASS.
- [ ] **Step 6: Commit**
```bash
git add -A
git commit -m "feat: idealo scraper"
```
---
## Task 7: Amazon Scraper (Playwright)
**Files:**
- Create: `src/lib/scrapers/amazon.ts`
- Create: `tests/fixtures/amazon-ps5.html`
- Create: `tests/scrapers/amazon.test.ts`
The Amazon scraper extracts via cheerio from rendered HTML; Playwright is only used to load the page (Amazon serves much content via JS). Tests use a saved fixture without running Playwright.
- [ ] **Step 1: Capture fixture**
```bash
curl -sL -A 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 Chrome/120.0' \
-H 'Accept-Language: de-DE,de;q=0.9' \
'https://www.amazon.de/dp/B0C5BMMJTL' \
> tests/fixtures/amazon-ps5.html
ls -lh tests/fixtures/amazon-ps5.html
```
If the file is <30KB it likely shows a robot check page; in that case open the URL in a real browser and "Save Page As → HTML Only" into the fixture file.
- [ ] **Step 2: Write failing test `tests/scrapers/amazon.test.ts`**
```typescript
import { describe, it, expect } from 'vitest'
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { parseAmazonHtml } from '@/lib/scrapers/amazon'
const fixture = readFileSync(join(__dirname, '../fixtures/amazon-ps5.html'), 'utf-8')
describe('parseAmazonHtml', () => {
it('extracts price, name, image', () => {
const r = parseAmazonHtml(fixture)
expect(r.price).toBeGreaterThan(0)
expect(r.currency).toBe('EUR')
expect(r.name).toBeTruthy()
expect(r.imageUrl).toMatch(/^https?:\/\//)
})
it('detects captcha page', () => {
const captchaHtml = ''
const r = parseAmazonHtml(captchaHtml)
expect(r.price).toBeNull()
expect(r.error).toBe('captcha')
})
})
```
- [ ] **Step 3: Run test, expect failure**
```bash
bun run test tests/scrapers/amazon.test.ts
```
Expected: FAIL — module missing.
- [ ] **Step 4: Implement `src/lib/scrapers/amazon.ts`**
```typescript
import * as cheerio from 'cheerio'
import type { PriceScraper, ScrapeResult } from './types'
export function parseAmazonHtml(html: string): ScrapeResult {
if (/validateCaptcha|api-services-support@amazon/i.test(html)) {
return { price: null, currency: 'EUR', availability: 'unknown', error: 'captcha' }
}
const $ = cheerio.load(html)
const priceTexts = [
$('#corePrice_feature_div .a-offscreen').first().text(),
$('#corePriceDisplay_desktop_feature_div .a-offscreen').first().text(),
$('span.priceToPay .a-offscreen').first().text(),
$('#priceblock_ourprice').first().text(),
$('#priceblock_dealprice').first().text(),
$('.a-price .a-offscreen').first().text(),
].filter(Boolean) as string[]
const price = parsePrice(priceTexts[0] ?? '')
const name = ($('#productTitle').text() || $('h1#title').text() || '').trim()
const imageUrl = $('#landingImage').attr('src')
|| $('#imgBlkFront').attr('src')
|| $('meta[property="og:image"]').attr('content')
|| undefined
const outOfStock = /derzeit nicht verf|currently unavailable/i.test($('#availability').text())
const availability = outOfStock ? 'out_of_stock' : (price !== null ? 'in_stock' : 'unknown')
if (price === null) {
return { price: null, currency: 'EUR', availability, name, imageUrl, error: 'price-selector-missed' }
}
return { price, currency: 'EUR', availability, name, imageUrl }
}
function parsePrice(text: string): number | null {
const cleaned = text.replace(/[^\d.,]/g, '').replace(/\.(?=\d{3}(\D|$))/g, '').replace(',', '.')
if (!cleaned) return null
const n = parseFloat(cleaned)
return Number.isFinite(n) && n > 0 ? n : null
}
export const amazonScraper: PriceScraper = {
shop: 'amazon',
async scrape(url: string): Promise {
// Import lazily so test runs don't pull in playwright
const { chromium } = await import('playwright')
const browser = await chromium.launch({ args: ['--no-sandbox', '--disable-dev-shm-usage'] })
try {
const ctx = await browser.newContext({
userAgent: 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36',
locale: 'de-DE',
extraHTTPHeaders: { 'Accept-Language': 'de-DE,de;q=0.9' },
})
const page = await ctx.newPage()
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30_000 })
const html = await page.content()
return parseAmazonHtml(html)
} catch (err) {
return { price: null, currency: 'EUR', availability: 'unknown', error: (err as Error).message }
} finally {
await browser.close()
}
},
}
```
- [ ] **Step 5: Run test, expect pass**
```bash
bun run test tests/scrapers/amazon.test.ts
```
Expected: PASS.
- [ ] **Step 6: Install Playwright Chromium for dev runs**
```bash
bunx playwright install chromium
```
- [ ] **Step 7: Manual smoke test**
```bash
bun run -e "
import { amazonScraper } from './src/lib/scrapers/amazon.ts'
const r = await amazonScraper.scrape('https://www.amazon.de/dp/B0C5BMMJTL')
console.log(r)
"
```
If you hit captcha, that's expected on shared IPs — daily cron from the Coolify container should be fine. Log it and move on.
- [ ] **Step 8: Commit**
```bash
git add -A
git commit -m "feat: amazon scraper with playwright + html parser tests"
```
---
## Task 8: Scraper Registration Entrypoint
**Files:**
- Create: `src/lib/scrapers/register.ts`
- [ ] **Step 1: Create `src/lib/scrapers/register.ts`**
```typescript
import { registerScraper } from './index'
import { amazonScraper } from './amazon'
import { idealoScraper } from './idealo'
import { geizhalsScraper } from './geizhals'
let registered = false
export function ensureScrapersRegistered() {
if (registered) return
registerScraper(amazonScraper)
registerScraper(idealoScraper)
registerScraper(geizhalsScraper)
registered = true
}
```
Every API route that calls `scrapeUrl` MUST call `ensureScrapersRegistered()` at the top.
- [ ] **Step 2: Commit**
```bash
git add -A
git commit -m "feat: scraper registration entrypoint"
```
---
## Task 9: Pushover Client
**Files:**
- Create: `src/lib/pushover.ts`
- Create: `tests/pushover.test.ts`
- [ ] **Step 1: Write failing test `tests/pushover.test.ts`**
```typescript
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { sendPush } from '@/lib/pushover'
beforeEach(() => {
process.env.PUSHOVER_TOKEN = 'tok'
process.env.PUSHOVER_USER = 'user'
global.fetch = vi.fn().mockResolvedValue({ ok: true, status: 200, json: async () => ({ status: 1 }) }) as unknown as typeof fetch
})
describe('sendPush', () => {
it('posts to pushover with token/user/payload', async () => {
await sendPush({ title: 'T', message: 'M', url: 'https://x' })
const fetchMock = global.fetch as unknown as ReturnType
const [url, init] = fetchMock.mock.calls[0]
expect(url).toBe('https://api.pushover.net/1/messages.json')
const body = init.body as URLSearchParams
expect(body.get('token')).toBe('tok')
expect(body.get('user')).toBe('user')
expect(body.get('title')).toBe('T')
expect(body.get('message')).toBe('M')
expect(body.get('url')).toBe('https://x')
})
it('throws if env vars missing', async () => {
delete process.env.PUSHOVER_TOKEN
await expect(sendPush({ title: 'T', message: 'M' })).rejects.toThrow(/PUSHOVER/)
})
})
```
- [ ] **Step 2: Run test, expect failure**
```bash
bun run test tests/pushover.test.ts
```
Expected: FAIL.
- [ ] **Step 3: Implement `src/lib/pushover.ts`**
```typescript
export interface PushOpts {
title: string
message: string
url?: string
urlTitle?: string
priority?: -2 | -1 | 0 | 1 | 2
}
export async function sendPush(opts: PushOpts): Promise {
const token = process.env.PUSHOVER_TOKEN
const user = process.env.PUSHOVER_USER
if (!token || !user) throw new Error('PUSHOVER_TOKEN/PUSHOVER_USER not set')
const body = new URLSearchParams({
token,
user,
title: opts.title,
message: opts.message,
priority: String(opts.priority ?? 0),
})
if (opts.url) body.set('url', opts.url)
if (opts.urlTitle) body.set('url_title', opts.urlTitle)
const res = await fetch('https://api.pushover.net/1/messages.json', {
method: 'POST',
body,
signal: AbortSignal.timeout(10_000),
})
if (!res.ok) {
const txt = await res.text().catch(() => '')
throw new Error(`Pushover ${res.status}: ${txt}`)
}
}
```
- [ ] **Step 4: Run test, expect pass**
```bash
bun run test tests/pushover.test.ts
```
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add -A
git commit -m "feat: pushover client"
```
---
## Task 10: Alert Evaluation Logic
**Files:**
- Create: `src/lib/alerts/evaluate.ts`
- Create: `tests/alerts/evaluate.test.ts`
- [ ] **Step 1: Write failing test `tests/alerts/evaluate.test.ts`**
```typescript
import { describe, it, expect } from 'vitest'
import { evaluateAlert, type SnapshotInput } from '@/lib/alerts/evaluate'
function snap(price: number, daysAgo: number): SnapshotInput {
const d = new Date()
d.setDate(d.getDate() - daysAgo)
return { price, scrapedAt: d }
}
describe('evaluateAlert', () => {
describe('target_price', () => {
it('triggers when current price <= threshold', () => {
const r = evaluateAlert({
alert: { type: 'target_price', config: { threshold: 100 }, lastTriggeredAt: null },
currentPrice: 99,
history: [],
})
expect(r.triggered).toBe(true)
})
it('does not trigger when price > threshold', () => {
const r = evaluateAlert({
alert: { type: 'target_price', config: { threshold: 100 }, lastTriggeredAt: null },
currentPrice: 101,
history: [],
})
expect(r.triggered).toBe(false)
})
})
describe('all_time_low', () => {
it('triggers when current is below all previous successful prices', () => {
const r = evaluateAlert({
alert: { type: 'all_time_low', config: {}, lastTriggeredAt: null },
currentPrice: 50,
history: [snap(60, 1), snap(70, 5), snap(80, 10)],
})
expect(r.triggered).toBe(true)
expect(r.context.prevMin).toBe(60)
})
it('does not trigger when current equals previous min', () => {
const r = evaluateAlert({
alert: { type: 'all_time_low', config: {}, lastTriggeredAt: null },
currentPrice: 60,
history: [snap(60, 1)],
})
expect(r.triggered).toBe(false)
})
it('does not trigger with no history (first scrape)', () => {
const r = evaluateAlert({
alert: { type: 'all_time_low', config: {}, lastTriggeredAt: null },
currentPrice: 50,
history: [],
})
expect(r.triggered).toBe(false)
})
})
describe('percent_drop', () => {
it('triggers when price drops >= percent vs lookback avg', () => {
const r = evaluateAlert({
alert: { type: 'percent_drop', config: { lookback_days: 7, percent: 10 }, lastTriggeredAt: null },
currentPrice: 80,
history: [snap(100, 1), snap(100, 3), snap(100, 6), snap(50, 30)],
})
expect(r.triggered).toBe(true)
expect(r.context.percent).toBeGreaterThanOrEqual(10)
})
it('does not trigger when drop is smaller than threshold', () => {
const r = evaluateAlert({
alert: { type: 'percent_drop', config: { lookback_days: 7, percent: 10 }, lastTriggeredAt: null },
currentPrice: 95,
history: [snap(100, 1), snap(100, 3)],
})
expect(r.triggered).toBe(false)
})
it('does not trigger with no history in lookback window', () => {
const r = evaluateAlert({
alert: { type: 'percent_drop', config: { lookback_days: 7, percent: 10 }, lastTriggeredAt: null },
currentPrice: 50,
history: [snap(100, 30)],
})
expect(r.triggered).toBe(false)
})
})
describe('dedup', () => {
it('does not trigger if last triggered within 24h', () => {
const recent = new Date(Date.now() - 1000 * 60 * 60 * 12)
const r = evaluateAlert({
alert: { type: 'target_price', config: { threshold: 100 }, lastTriggeredAt: recent },
currentPrice: 50,
history: [],
})
expect(r.triggered).toBe(false)
})
it('triggers if last triggered > 24h ago', () => {
const old = new Date(Date.now() - 1000 * 60 * 60 * 25)
const r = evaluateAlert({
alert: { type: 'target_price', config: { threshold: 100 }, lastTriggeredAt: old },
currentPrice: 50,
history: [],
})
expect(r.triggered).toBe(true)
})
})
})
```
- [ ] **Step 2: Run test, expect failure**
```bash
bun run test tests/alerts/evaluate.test.ts
```
Expected: FAIL — module missing.
- [ ] **Step 3: Implement `src/lib/alerts/evaluate.ts`**
```typescript
export type AlertType = 'target_price' | 'all_time_low' | 'percent_drop'
export interface AlertInput {
type: AlertType
config: Record
lastTriggeredAt: Date | null
}
export interface SnapshotInput {
price: number
scrapedAt: Date
}
export interface EvalInput {
alert: AlertInput
currentPrice: number
history: SnapshotInput[]
}
export interface EvalResult {
triggered: boolean
context: Record
}
const DEDUP_HOURS = 24
export function evaluateAlert(input: EvalInput): EvalResult {
const { alert, currentPrice, history } = input
if (alert.lastTriggeredAt) {
const ageMs = Date.now() - alert.lastTriggeredAt.getTime()
if (ageMs < DEDUP_HOURS * 60 * 60 * 1000) {
return { triggered: false, context: { reason: 'dedup-cooldown' } }
}
}
switch (alert.type) {
case 'target_price': {
const threshold = Number(alert.config.threshold)
if (!Number.isFinite(threshold)) return { triggered: false, context: { reason: 'bad-config' } }
return { triggered: currentPrice <= threshold, context: { threshold, currentPrice } }
}
case 'all_time_low': {
if (history.length === 0) return { triggered: false, context: { reason: 'no-history' } }
const prevMin = Math.min(...history.map((s) => s.price))
return { triggered: currentPrice < prevMin, context: { prevMin, currentPrice } }
}
case 'percent_drop': {
const lookbackDays = Number(alert.config.lookback_days)
const percent = Number(alert.config.percent)
if (!Number.isFinite(lookbackDays) || !Number.isFinite(percent)) {
return { triggered: false, context: { reason: 'bad-config' } }
}
const cutoff = Date.now() - lookbackDays * 86400_000
const window = history.filter((s) => s.scrapedAt.getTime() >= cutoff)
if (window.length === 0) return { triggered: false, context: { reason: 'no-history-in-window' } }
const avg = window.reduce((s, x) => s + x.price, 0) / window.length
const dropPct = ((avg - currentPrice) / avg) * 100
return { triggered: dropPct >= percent, context: { avg, percent: Number(dropPct.toFixed(2)), currentPrice } }
}
}
}
```
- [ ] **Step 4: Run test, expect pass**
```bash
bun run test tests/alerts/evaluate.test.ts
```
Expected: PASS — all cases green.
- [ ] **Step 5: Commit**
```bash
git add -A
git commit -m "feat: alert evaluation logic (target/atl/drop) with dedup"
```
---
## Task 11: Zitadel Auth (PKCE + iron-session)
**Files:**
- Create: `src/lib/auth/pkce.ts`
- Create: `src/lib/auth/session.ts`
- Create: `src/lib/auth/zitadel.ts`
- Create: `src/app/api/auth/login/route.ts`
- Create: `src/app/api/auth/callback/route.ts`
- Create: `src/app/api/auth/logout/route.ts`
- Create: `src/middleware.ts`
**Before coding:** Read `node_modules/next/dist/docs/` for Next.js 16 middleware + route-handler API. Confirm: `cookies()` async, `NextRequest.headers`, `Response.redirect` patterns.
- [ ] **Step 1: Create `src/lib/auth/pkce.ts`**
```typescript
import { randomBytes, createHash } from 'node:crypto'
export function generateVerifier(): string {
return randomBytes(64).toString('base64url')
}
export function challengeFromVerifier(verifier: string): string {
return createHash('sha256').update(verifier).digest('base64url')
}
export function generateState(): string {
return randomBytes(32).toString('base64url')
}
```
- [ ] **Step 2: Create `src/lib/auth/session.ts`**
```typescript
import type { SessionOptions } from 'iron-session'
import { getIronSession } from 'iron-session'
import { cookies } from 'next/headers'
export interface SessionData {
userId?: string
email?: string
name?: string
loginInProgress?: { state: string; codeVerifier: string }
}
const opts: SessionOptions = {
password: process.env.SESSION_PASSWORD || '',
cookieName: 'preis_tracker_session',
cookieOptions: {
httpOnly: true,
sameSite: 'lax',
secure: process.env.NODE_ENV === 'production',
maxAge: 7 * 24 * 60 * 60,
},
}
export async function getSession() {
if (!opts.password || opts.password.length < 32) {
throw new Error('SESSION_PASSWORD must be set to a 32+ char value')
}
const c = await cookies()
return getIronSession(c, opts)
}
```
- [ ] **Step 3: Create `src/lib/auth/zitadel.ts`**
```typescript
import { createRemoteJWKSet, jwtVerify } from 'jose'
const issuer = process.env.ZITADEL_ISSUER!
const clientId = process.env.ZITADEL_CLIENT_ID!
let jwksCache: ReturnType | null = null
function getJWKS() {
if (!jwksCache) {
jwksCache = createRemoteJWKSet(new URL(`${issuer}/oauth/v2/keys`))
}
return jwksCache
}
export interface TokenSet {
access_token: string
id_token: string
refresh_token?: string
expires_in: number
token_type: string
}
export async function exchangeCode(args: {
code: string
codeVerifier: string
redirectUri: string
}): Promise {
const body = new URLSearchParams({
grant_type: 'authorization_code',
client_id: clientId,
code: args.code,
code_verifier: args.codeVerifier,
redirect_uri: args.redirectUri,
})
const res = await fetch(`${issuer}/oauth/v2/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body,
})
if (!res.ok) {
throw new Error(`Zitadel token exchange failed: ${res.status} ${await res.text()}`)
}
return res.json()
}
export interface IdTokenClaims {
sub: string
email?: string
name?: string
iss: string
aud: string | string[]
exp: number
}
export async function verifyIdToken(idToken: string): Promise {
const { payload } = await jwtVerify(idToken, getJWKS(), {
issuer,
audience: clientId,
})
return payload as unknown as IdTokenClaims
}
export function buildAuthorizeUrl(args: {
state: string
codeChallenge: string
redirectUri: string
}): string {
const params = new URLSearchParams({
response_type: 'code',
client_id: clientId,
scope: 'openid email profile',
redirect_uri: args.redirectUri,
state: args.state,
code_challenge: args.codeChallenge,
code_challenge_method: 'S256',
})
return `${issuer}/oauth/v2/authorize?${params.toString()}`
}
export function buildEndSessionUrl(redirectTo: string): string {
const params = new URLSearchParams({ post_logout_redirect_uri: redirectTo })
return `${issuer}/oidc/v1/end_session?${params.toString()}`
}
export function isAllowedUser(sub: string): boolean {
const allowed = (process.env.ALLOWED_USER_IDS || '').split(',').map((s) => s.trim()).filter(Boolean)
return allowed.includes(sub)
}
```
- [ ] **Step 4: Create `src/app/api/auth/login/route.ts`**
```typescript
import { NextResponse } from 'next/server'
import { getSession } from '@/lib/auth/session'
import { generateVerifier, challengeFromVerifier, generateState } from '@/lib/auth/pkce'
import { buildAuthorizeUrl } from '@/lib/auth/zitadel'
export async function GET() {
const verifier = generateVerifier()
const challenge = challengeFromVerifier(verifier)
const state = generateState()
const redirectUri = `${process.env.NEXT_PUBLIC_BASE_URL}/api/auth/callback`
const session = await getSession()
session.loginInProgress = { state, codeVerifier: verifier }
await session.save()
const url = buildAuthorizeUrl({ state, codeChallenge: challenge, redirectUri })
return NextResponse.redirect(url)
}
```
- [ ] **Step 5: Create `src/app/api/auth/callback/route.ts`**
```typescript
import { NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth/session'
import { exchangeCode, verifyIdToken, isAllowedUser } from '@/lib/auth/zitadel'
export async function GET(req: NextRequest) {
const url = new URL(req.url)
const code = url.searchParams.get('code')
const state = url.searchParams.get('state')
if (!code || !state) return NextResponse.json({ error: 'missing code/state' }, { status: 400 })
const session = await getSession()
const pending = session.loginInProgress
if (!pending || pending.state !== state) {
return NextResponse.json({ error: 'state mismatch' }, { status: 400 })
}
const redirectUri = `${process.env.NEXT_PUBLIC_BASE_URL}/api/auth/callback`
const tokens = await exchangeCode({ code, codeVerifier: pending.codeVerifier, redirectUri })
const claims = await verifyIdToken(tokens.id_token)
if (!isAllowedUser(claims.sub)) {
session.destroy()
return NextResponse.json({ error: 'user not allowed' }, { status: 403 })
}
session.userId = claims.sub
session.email = claims.email
session.name = claims.name
delete session.loginInProgress
await session.save()
return NextResponse.redirect(new URL('/', req.url))
}
```
- [ ] **Step 6: Create `src/app/api/auth/logout/route.ts`**
```typescript
import { NextResponse } from 'next/server'
import { getSession } from '@/lib/auth/session'
import { buildEndSessionUrl } from '@/lib/auth/zitadel'
export async function GET() {
const session = await getSession()
session.destroy()
return NextResponse.redirect(buildEndSessionUrl(`${process.env.NEXT_PUBLIC_BASE_URL}/`))
}
```
- [ ] **Step 7: Create `src/middleware.ts`**
```typescript
import { NextRequest, NextResponse } from 'next/server'
import { getIronSession } from 'iron-session'
import type { SessionData } from '@/lib/auth/session'
const PUBLIC_PREFIXES = ['/api/auth/', '/api/cron/', '/_next/', '/favicon']
export async function middleware(req: NextRequest) {
const { pathname } = req.nextUrl
if (PUBLIC_PREFIXES.some((p) => pathname.startsWith(p))) return NextResponse.next()
const res = NextResponse.next()
const session = await getIronSession(req.cookies, res.cookies, {
password: process.env.SESSION_PASSWORD || '',
cookieName: 'preis_tracker_session',
})
if (!session.userId) {
if (pathname.startsWith('/api/')) {
return NextResponse.json({ error: 'unauthenticated' }, { status: 401 })
}
return NextResponse.redirect(new URL('/api/auth/login', req.url))
}
return res
}
export const config = {
matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
}
```
- [ ] **Step 8: Zitadel app setup (manual, one-time)**
In Zitadel UI (`https://auth.kuns.dev`):
1. Project → Applications → New → Web → Name `preis-tracker`
2. Auth Method: `PKCE`
3. Redirect URIs: `https://preis.kuns.dev/api/auth/callback`, `http://localhost:3000/api/auth/callback`
4. Post Logout URIs: `https://preis.kuns.dev/`, `http://localhost:3000/`
5. Copy `Client ID` → put into `.env` as `ZITADEL_CLIENT_ID`
6. Get your user-sub: User Profile → ID → put into `.env` as `ALLOWED_USER_IDS`
Generate session password:
```bash
openssl rand -base64 48 | head -c 48
```
Put into `.env` as `SESSION_PASSWORD`.
- [ ] **Step 9: Smoke test login flow locally**
```bash
bun run dev &
sleep 5
# Open browser: http://localhost:3000
# Expect redirect to Zitadel → login → back to /
# Verify cookie `preis_tracker_session` is set
kill %1
```
- [ ] **Step 10: Commit**
```bash
git add -A
git commit -m "feat: zitadel oidc auth with pkce, iron-session, middleware"
```
---
## Task 12: Products API
**Files:**
- Create: `src/app/api/products/route.ts`
- Create: `src/app/api/products/[id]/route.ts`
- Create: `src/app/api/products/[id]/scrape/route.ts`
- [ ] **Step 1: Create `src/app/api/products/route.ts`**
```typescript
import { NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { eq, desc, sql } from 'drizzle-orm'
import { db, products, priceSnapshots } from '@/lib/db'
import { detectShop } from '@/lib/shops'
import { scrapeUrl } from '@/lib/scrapers'
import { ensureScrapersRegistered } from '@/lib/scrapers/register'
const AddSchema = z.object({ url: z.string().url() })
export async function GET() {
// List with latest snapshot
const rows = await db.execute<{
id: string; url: string; shop: string; name: string; image_url: string | null;
last_price: string | null; last_scraped_at: Date | null; min_price: string | null;
}>(sql`
select p.id, p.url, p.shop, p.name, p.image_url, p.last_scraped_at,
(select price from price_snapshots s where s.product_id = p.id and s.price is not null order by scraped_at desc limit 1) as last_price,
(select min(price) from price_snapshots s where s.product_id = p.id and s.price is not null) as min_price
from products p
where p.enabled = true
order by p.created_at desc
`)
return NextResponse.json(rows)
}
export async function POST(req: NextRequest) {
ensureScrapersRegistered()
const body = await req.json()
const parsed = AddSchema.safeParse(body)
if (!parsed.success) return NextResponse.json({ error: 'invalid url' }, { status: 400 })
const shop = detectShop(parsed.data.url)
if (!shop) return NextResponse.json({ error: 'unsupported shop' }, { status: 400 })
// Initial scrape (use minimal name/image to insert, then update)
const result = await scrapeUrl(parsed.data.url)
const name = result.name || parsed.data.url
const [inserted] = await db.insert(products).values({
url: parsed.data.url,
shop,
name,
imageUrl: result.imageUrl ?? null,
}).returning()
await db.insert(priceSnapshots).values({
productId: inserted.id,
price: result.price !== null ? String(result.price) : null,
currency: result.currency,
availability: result.availability,
error: result.error ?? null,
})
await db.update(products).set({
lastScrapedAt: new Date(),
consecutiveFailures: result.price === null ? 1 : 0,
}).where(eq(products.id, inserted.id))
return NextResponse.json({ id: inserted.id }, { status: 201 })
}
```
- [ ] **Step 2: Create `src/app/api/products/[id]/route.ts`**
```typescript
import { NextRequest, NextResponse } from 'next/server'
import { eq, desc } from 'drizzle-orm'
import { db, products, priceSnapshots, alerts } from '@/lib/db'
export async function GET(_req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params
const [product] = await db.select().from(products).where(eq(products.id, id))
if (!product) return NextResponse.json({ error: 'not found' }, { status: 404 })
const snapshots = await db.select().from(priceSnapshots)
.where(eq(priceSnapshots.productId, id))
.orderBy(desc(priceSnapshots.scrapedAt))
.limit(1000)
const productAlerts = await db.select().from(alerts).where(eq(alerts.productId, id))
return NextResponse.json({ product, snapshots, alerts: productAlerts })
}
export async function DELETE(_req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params
await db.delete(products).where(eq(products.id, id))
return NextResponse.json({ ok: true })
}
```
- [ ] **Step 3: Create `src/app/api/products/[id]/scrape/route.ts`**
```typescript
import { NextRequest, NextResponse } from 'next/server'
import { eq } from 'drizzle-orm'
import { db, products, priceSnapshots } from '@/lib/db'
import { scrapeUrl } from '@/lib/scrapers'
import { ensureScrapersRegistered } from '@/lib/scrapers/register'
export async function POST(_req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
ensureScrapersRegistered()
const { id } = await params
const [product] = await db.select().from(products).where(eq(products.id, id))
if (!product) return NextResponse.json({ error: 'not found' }, { status: 404 })
const result = await scrapeUrl(product.url)
await db.insert(priceSnapshots).values({
productId: id,
price: result.price !== null ? String(result.price) : null,
currency: result.currency,
availability: result.availability,
error: result.error ?? null,
})
await db.update(products).set({
lastScrapedAt: new Date(),
consecutiveFailures: result.price === null ? product.consecutiveFailures + 1 : 0,
}).where(eq(products.id, id))
return NextResponse.json(result)
}
```
- [ ] **Step 4: Manual smoke test**
```bash
bun run dev &
sleep 5
# In another shell — get a session cookie first by visiting localhost:3000 in browser and copying it
COOKIE='preis_tracker_session=...'
curl -s -X POST -H "Cookie: $COOKIE" -H 'Content-Type: application/json' \
-d '{"url":"https://geizhals.de/sony-playstation-5-pro-a3163879.html"}' \
http://localhost:3000/api/products
curl -s -H "Cookie: $COOKIE" http://localhost:3000/api/products | head -c 500
kill %1
```
Expected: `{"id":"..."}` then list contains the entry.
- [ ] **Step 5: Commit**
```bash
git add -A
git commit -m "feat: products api (list/add/detail/delete/manual-scrape)"
```
---
## Task 13: Alerts API
**Files:**
- Create: `src/app/api/alerts/route.ts`
- Create: `src/app/api/alerts/[id]/route.ts`
- [ ] **Step 1: Create `src/app/api/alerts/route.ts`**
```typescript
import { NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { db, alerts } from '@/lib/db'
const CreateSchema = z.object({
productId: z.string().uuid(),
type: z.enum(['target_price', 'all_time_low', 'percent_drop']),
config: z.record(z.string(), z.unknown()),
})
export async function POST(req: NextRequest) {
const body = await req.json()
const parsed = CreateSchema.safeParse(body)
if (!parsed.success) return NextResponse.json({ error: parsed.error.message }, { status: 400 })
// Per-type config validation
const cfg = parsed.data.config
switch (parsed.data.type) {
case 'target_price':
if (typeof cfg.threshold !== 'number' || cfg.threshold <= 0) {
return NextResponse.json({ error: 'threshold (number > 0) required' }, { status: 400 })
}
break
case 'percent_drop':
if (typeof cfg.lookback_days !== 'number' || typeof cfg.percent !== 'number') {
return NextResponse.json({ error: 'lookback_days + percent required' }, { status: 400 })
}
break
case 'all_time_low':
break
}
const [inserted] = await db.insert(alerts).values({
productId: parsed.data.productId,
type: parsed.data.type,
config: parsed.data.config,
}).returning()
return NextResponse.json(inserted, { status: 201 })
}
```
- [ ] **Step 2: Create `src/app/api/alerts/[id]/route.ts`**
```typescript
import { NextRequest, NextResponse } from 'next/server'
import { eq } from 'drizzle-orm'
import { db, alerts } from '@/lib/db'
export async function DELETE(_req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params
await db.delete(alerts).where(eq(alerts.id, id))
return NextResponse.json({ ok: true })
}
```
- [ ] **Step 3: Commit**
```bash
git add -A
git commit -m "feat: alerts api (create/delete)"
```
---
## Task 14: Cron Scrape Endpoint
**Files:**
- Create: `src/app/api/cron/scrape/route.ts`
- [ ] **Step 1: Create `src/app/api/cron/scrape/route.ts`**
```typescript
import { NextRequest, NextResponse } from 'next/server'
import { eq, and } from 'drizzle-orm'
import { db, products, priceSnapshots, alerts } from '@/lib/db'
import { scrapeUrl } from '@/lib/scrapers'
import { ensureScrapersRegistered } from '@/lib/scrapers/register'
import { evaluateAlert, type SnapshotInput } from '@/lib/alerts/evaluate'
import { sendPush } from '@/lib/pushover'
export const maxDuration = 300 // 5 min
const CONCURRENCY = 2
const FAIL_WARN_THRESHOLD = 3
export async function POST(req: NextRequest) {
const auth = req.headers.get('authorization')
if (auth !== `Bearer ${process.env.CRON_SECRET}`) {
return NextResponse.json({ error: 'unauthorized' }, { status: 401 })
}
ensureScrapersRegistered()
const productsToScrape = await db.select().from(products).where(eq(products.enabled, true))
console.log(`[cron] scraping ${productsToScrape.length} products`)
// Run with concurrency limit
const queue = [...productsToScrape]
const summary = { ok: 0, failed: 0, alertsTriggered: 0 }
async function worker() {
while (queue.length > 0) {
const p = queue.shift()
if (!p) break
try {
await processProduct(p, summary)
} catch (err) {
console.error(`[cron] product ${p.id} crashed`, err)
}
}
}
await Promise.all(Array.from({ length: CONCURRENCY }, () => worker()))
return NextResponse.json(summary)
}
async function processProduct(p: typeof products.$inferSelect, summary: { ok: number; failed: number; alertsTriggered: number }) {
const result = await scrapeUrl(p.url)
await db.insert(priceSnapshots).values({
productId: p.id,
price: result.price !== null ? String(result.price) : null,
currency: result.currency,
availability: result.availability,
error: result.error ?? null,
})
if (result.price === null) {
const failures = p.consecutiveFailures + 1
await db.update(products).set({
lastScrapedAt: new Date(),
consecutiveFailures: failures,
}).where(eq(products.id, p.id))
summary.failed++
if (failures === FAIL_WARN_THRESHOLD) {
await sendPush({
title: `⚠ Scrape-Fehler`,
message: `${p.name} ist ${failures}× hintereinander fehlgeschlagen: ${result.error ?? 'unknown'}`,
url: p.url,
}).catch((e) => console.error('pushover failed', e))
}
return
}
summary.ok++
await db.update(products).set({ lastScrapedAt: new Date(), consecutiveFailures: 0 }).where(eq(products.id, p.id))
// Load history (price snapshots only) and product alerts
const allSnapshots = await db.select().from(priceSnapshots)
.where(and(eq(priceSnapshots.productId, p.id)))
const history: SnapshotInput[] = allSnapshots
.filter((s) => s.price !== null)
.filter((s) => s.id !== Math.max(...allSnapshots.map((x) => x.id))) // exclude the just-inserted one
.map((s) => ({ price: Number(s.price), scrapedAt: s.scrapedAt }))
const productAlerts = await db.select().from(alerts)
.where(and(eq(alerts.productId, p.id), eq(alerts.enabled, true)))
for (const alert of productAlerts) {
const r = evaluateAlert({
alert: {
type: alert.type as 'target_price' | 'all_time_low' | 'percent_drop',
config: alert.config as Record,
lastTriggeredAt: alert.lastTriggeredAt,
},
currentPrice: result.price,
history,
})
if (r.triggered) {
summary.alertsTriggered++
await sendPush({
title: alertTitle(alert.type as string, r.context, p.name),
message: alertMessage(alert.type as string, r.context, result.price),
url: p.url,
urlTitle: 'Zum Shop',
}).catch((e) => console.error('pushover failed', e))
await db.update(alerts).set({ lastTriggeredAt: new Date() }).where(eq(alerts.id, alert.id))
}
}
}
function alertTitle(type: string, ctx: Record, name: string): string {
switch (type) {
case 'target_price': return `📉 ${name} unter ${ctx.threshold}€`
case 'all_time_low': return `🎯 Allzeit-Tief: ${name}`
case 'percent_drop': return `⬇️ ${name} −${ctx.percent}%`
default: return name
}
}
function alertMessage(type: string, ctx: Record, price: number): string {
switch (type) {
case 'target_price': return `Jetzt ${price}€ (Ziel: ${ctx.threshold}€)`
case 'all_time_low': return `Jetzt ${price}€ (vorher min: ${ctx.prevMin}€)`
case 'percent_drop': return `Jetzt ${price}€ (vorher Ø ${ctx.avg}€ in ${ctx.percent ?? '?'}% Drop)`
default: return `Jetzt ${price}€`
}
}
```
- [ ] **Step 2: Manual smoke test**
```bash
bun run dev &
sleep 5
curl -s -X POST -H "Authorization: Bearer $(grep CRON_SECRET .env | cut -d= -f2)" \
http://localhost:3000/api/cron/scrape
kill %1
```
Expected: JSON `{ok, failed, alertsTriggered}`. New snapshots in DB.
- [ ] **Step 3: Commit**
```bash
git add -A
git commit -m "feat: daily cron scrape endpoint with concurrency + alert dispatch"
```
---
## Task 15: UI — Dashboard
**Files:**
- Create: `src/components/Sparkline.tsx`
- Create: `src/components/ProductCard.tsx`
- Modify: `src/app/page.tsx`
- [ ] **Step 1: Create `src/components/Sparkline.tsx`**
```tsx
'use client'
import { LineChart, Line, ResponsiveContainer, YAxis } from 'recharts'
export function Sparkline({ data }: { data: Array<{ price: number; t: string }> }) {
if (data.length === 0) return keine Daten
return (
)
}
```
- [ ] **Step 2: Create `src/components/ProductCard.tsx`**
```tsx
import Link from 'next/link'
import { Sparkline } from './Sparkline'
interface Props {
id: string
name: string
shop: string
imageUrl: string | null
lastPrice: string | null
minPrice: string | null
sparkline: Array<{ price: number; t: string }>
}
export function ProductCard(p: Props) {
const last = p.lastPrice ? Number(p.lastPrice) : null
const min = p.minPrice ? Number(p.minPrice) : null
const deltaFromMin = last !== null && min !== null ? (last - min).toFixed(2) : null
return (
{p.imageUrl &&

}
{p.shop}
{p.name}
{last !== null ? `${last.toFixed(2)} €` : '—'}
{deltaFromMin !== null && (
+{deltaFromMin} € vom Tief
)}
)
}
```
- [ ] **Step 3: Replace `src/app/page.tsx`**
```tsx
import Link from 'next/link'
import { desc, eq, sql } from 'drizzle-orm'
import { db, products, priceSnapshots } from '@/lib/db'
import { ProductCard } from '@/components/ProductCard'
export const dynamic = 'force-dynamic'
export default async function Home() {
const rows = await db.execute<{
id: string; url: string; shop: string; name: string; image_url: string | null;
last_price: string | null; min_price: string | null;
}>(sql`
select p.id, p.url, p.shop, p.name, p.image_url,
(select price from price_snapshots s where s.product_id = p.id and s.price is not null order by scraped_at desc limit 1) as last_price,
(select min(price) from price_snapshots s where s.product_id = p.id and s.price is not null) as min_price
from products p where p.enabled = true order by p.created_at desc
`)
const sparklines = new Map>()
for (const r of rows) {
const snaps = await db.select({ price: priceSnapshots.price, scrapedAt: priceSnapshots.scrapedAt })
.from(priceSnapshots)
.where(eq(priceSnapshots.productId, r.id))
.orderBy(desc(priceSnapshots.scrapedAt))
.limit(30)
sparklines.set(r.id, snaps
.filter((s) => s.price !== null)
.reverse()
.map((s) => ({ price: Number(s.price), t: s.scrapedAt.toISOString() })))
}
return (
Preis-Tracker
+ Produkt
Logout
{rows.length === 0 ? (
Noch keine Produkte. Erstes hinzufügen.
) : (
)}
)
}
```
- [ ] **Step 4: Smoke test in browser**
```bash
bun run dev
# Open http://localhost:3000, login, verify dashboard renders (empty state ok)
```
- [ ] **Step 5: Commit**
```bash
git add -A
git commit -m "feat: dashboard UI with product cards + sparklines"
```
---
## Task 16: UI — Add Product
**Files:**
- Create: `src/app/add/page.tsx`
- [ ] **Step 1: Create `src/app/add/page.tsx`**
```tsx
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
export default function AddPage() {
const [url, setUrl] = useState('')
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState(null)
const router = useRouter()
async function submit(e: React.FormEvent) {
e.preventDefault()
setSubmitting(true)
setError(null)
try {
const res = await fetch('/api/products', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url }),
})
if (!res.ok) {
const j = await res.json().catch(() => ({ error: 'unknown' }))
setError(j.error || `HTTP ${res.status}`)
return
}
const { id } = await res.json()
router.push(`/products/${id}`)
} finally {
setSubmitting(false)
}
}
return (
Produkt hinzufügen
)
}
```
- [ ] **Step 2: Smoke test — add a Geizhals URL**
```bash
bun run dev
# Browser: /add → paste Geizhals URL → submit → redirect to /products/
```
- [ ] **Step 3: Commit**
```bash
git add -A
git commit -m "feat: add-product page"
```
---
## Task 17: UI — Product Detail (Chart + Alerts)
**Files:**
- Create: `src/components/PriceChart.tsx`
- Create: `src/components/AlertList.tsx`
- Create: `src/components/AlertForm.tsx`
- Create: `src/app/products/[id]/page.tsx`
- [ ] **Step 1: Create `src/components/PriceChart.tsx`**
```tsx
'use client'
import { useState } from 'react'
import { LineChart, Line, ResponsiveContainer, XAxis, YAxis, Tooltip, CartesianGrid } from 'recharts'
interface Snap { price: number; t: string }
export function PriceChart({ data }: { data: Snap[] }) {
const [range, setRange] = useState<'30d' | '90d' | 'all'>('30d')
const cutoff = range === 'all' ? 0 : Date.now() - (range === '30d' ? 30 : 90) * 86400_000
const filtered = data.filter((d) => new Date(d.t).getTime() >= cutoff)
return (
{(['30d', '90d', 'all'] as const).map((r) => (
))}
new Date(v).toLocaleDateString('de-DE')} />
`${v}€`} domain={['dataMin', 'dataMax']} />
new Date(v).toLocaleString('de-DE')}
formatter={(v: number) => [`${v}€`, 'Preis']} />
)
}
```
- [ ] **Step 2: Create `src/components/AlertList.tsx`**
```tsx
'use client'
import { useRouter } from 'next/navigation'
interface Alert { id: string; type: string; config: Record; enabled: boolean }
export function AlertList({ alerts }: { alerts: Alert[] }) {
const router = useRouter()
async function del(id: string) {
await fetch(`/api/alerts/${id}`, { method: 'DELETE' })
router.refresh()
}
if (alerts.length === 0) return Keine Alerts.
return (
{alerts.map((a) => (
-
{labelFor(a)}
))}
)
}
function labelFor(a: Alert): string {
switch (a.type) {
case 'target_price': return `Zielpreis ≤ ${a.config.threshold}€`
case 'all_time_low': return `Allzeit-Tief`
case 'percent_drop': return `−${a.config.percent}% in ${a.config.lookback_days} Tagen`
default: return a.type
}
}
```
- [ ] **Step 3: Create `src/components/AlertForm.tsx`**
```tsx
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
export function AlertForm({ productId }: { productId: string }) {
const [type, setType] = useState<'target_price' | 'all_time_low' | 'percent_drop'>('target_price')
const [threshold, setThreshold] = useState('')
const [percent, setPercent] = useState('10')
const [lookback, setLookback] = useState('7')
const router = useRouter()
async function submit(e: React.FormEvent) {
e.preventDefault()
let config: Record = {}
if (type === 'target_price') config = { threshold: Number(threshold) }
if (type === 'percent_drop') config = { percent: Number(percent), lookback_days: Number(lookback) }
const res = await fetch('/api/alerts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ productId, type, config }),
})
if (res.ok) {
setThreshold('')
router.refresh()
}
}
return (
)
}
```
- [ ] **Step 4: Create `src/app/products/[id]/page.tsx`**
```tsx
import { notFound } from 'next/navigation'
import { eq, desc } from 'drizzle-orm'
import { db, products, priceSnapshots, alerts } from '@/lib/db'
import { PriceChart } from '@/components/PriceChart'
import { AlertList } from '@/components/AlertList'
import { AlertForm } from '@/components/AlertForm'
export const dynamic = 'force-dynamic'
export default async function ProductPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params
const [product] = await db.select().from(products).where(eq(products.id, id))
if (!product) notFound()
const snaps = await db.select().from(priceSnapshots)
.where(eq(priceSnapshots.productId, id))
.orderBy(desc(priceSnapshots.scrapedAt))
.limit(500)
const chartData = snaps
.filter((s) => s.price !== null)
.reverse()
.map((s) => ({ price: Number(s.price), t: s.scrapedAt.toISOString() }))
const productAlerts = await db.select().from(alerts).where(eq(alerts.productId, id))
return (
← Zurück
{product.imageUrl &&

}
Alerts
({ id: a.id, type: a.type, config: a.config as Record, enabled: a.enabled }))} />
)
}
```
- [ ] **Step 5: Smoke test in browser**
```bash
bun run dev
# /products/ → verify chart, alerts list, form, refresh button
```
- [ ] **Step 6: Commit**
```bash
git add -A
git commit -m "feat: product detail page with chart, alert list, alert form"
```
---
## Task 18: Dockerfile + .dockerignore
**Files:**
- Create: `Dockerfile`
- Create: `.dockerignore`
- [ ] **Step 1: Create `Dockerfile`**
```dockerfile
# syntax=docker/dockerfile:1.7
FROM mcr.microsoft.com/playwright:v1.50.0-jammy AS base
WORKDIR /app
ENV NODE_ENV=production
FROM base AS deps
ENV NODE_ENV=development
COPY package.json package-lock.json* bun.lockb* ./
RUN npm install --frozen-lockfile || npm install
FROM base AS build
ENV NODE_ENV=development
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
FROM base AS runner
ENV PORT=3000
COPY --from=build /app/.next/standalone ./
COPY --from=build /app/.next/static ./.next/static
COPY --from=build /app/public ./public 2>/dev/null || true
COPY --from=build /app/drizzle ./drizzle
COPY --from=build /app/drizzle.config.ts ./
COPY --from=build /app/src/lib/db/schema.ts ./schema.ts
EXPOSE 3000
CMD ["node", "server.js"]
```
- [ ] **Step 2: Create `.dockerignore`**
```
node_modules
.next
.git
.env
.env.local
*.log
tests
docs
README.md
```
- [ ] **Step 3: Local build test**
```bash
docker build -t preis-tracker:local .
docker run --rm -p 3001:3000 \
--env-file .env \
-e NEXT_PUBLIC_BASE_URL=http://localhost:3001 \
preis-tracker:local &
sleep 8
curl -sf -o /dev/null -w '%{http_code}\n' http://localhost:3001
docker stop $(docker ps -q --filter ancestor=preis-tracker:local)
```
Expected: `307` (redirect to /api/auth/login). Image builds without errors.
- [ ] **Step 4: Commit**
```bash
git add -A
git commit -m "build: dockerfile based on playwright image, standalone next.js"
```
---
## Task 19: Coolify Deployment
**Files:**
- Create: `README.md`
- [ ] **Step 1: Push to Gitea**
```bash
cd ~/preis-tracker
git remote add origin ssh://git@git.kuns.dev:22222/kuns/preis-tracker.git
# In another shell — create the repo on Gitea first
source ~/.secrets/coolify-tokens.env
curl -s -X POST -H "Authorization: token $GITEA_TOKEN" \
-H 'Content-Type: application/json' \
-d '{"name":"preis-tracker","private":true,"default_branch":"main"}' \
https://git.kuns.dev/api/v1/user/repos
git branch -M main
git push -u origin main
```
- [ ] **Step 2: Create Coolify app via API**
Follow the playbook in `memory/coolify-deploy.md`. Roughly:
1. Coolify UI → New Resource → Application → Public Repository → `https://git.kuns.dev/kuns/preis-tracker`
2. Build Pack: Dockerfile
3. Domain: `https://preis.kuns.dev`
4. Ports: `3000:3000`
5. Env Vars (mark sensitive ones as Build/Runtime Secret):
- `DATABASE_URL=postgresql://preistracker:@l8kogcggsc80sgcgk8kswww4:5432/preistracker`
- `ZITADEL_ISSUER=https://auth.kuns.dev`
- `ZITADEL_CLIENT_ID=`
- `ALLOWED_USER_IDS=`
- `SESSION_PASSWORD=<32+ chars>`
- `PUSHOVER_TOKEN=`
- `PUSHOVER_USER=`
- `CRON_SECRET=`
- `NEXT_PUBLIC_BASE_URL=https://preis.kuns.dev`
6. Webhook → enable, copy URL into Gitea repo settings → Webhooks
7. Deploy
- [ ] **Step 3: Add Scheduled Task in Coolify**
In the app settings → Scheduled Tasks → New:
- Name: `daily-scrape`
- Command: `curl -fsS -X POST -H "Authorization: Bearer $CRON_SECRET" http://localhost:3000/api/cron/scrape`
- Schedule: `0 6 * * *`
- [ ] **Step 4: Update Zitadel app config**
In Zitadel UI: add redirect URI `https://preis.kuns.dev/api/auth/callback` and post-logout URI `https://preis.kuns.dev/`.
- [ ] **Step 5: Verify deployment**
```bash
sleep 60 # wait for deploy
curl -sI https://preis.kuns.dev | head -5
# Expect 307 redirect to /api/auth/login
# Trigger manual cron run to test
curl -s -X POST -H "Authorization: Bearer " \
https://preis.kuns.dev/api/cron/scrape
```
- [ ] **Step 6: Create `README.md`**
```markdown
# preis-tracker
Tracking von Produktpreisen bei Amazon, Idealo, Geizhals mit Pushover-Alerts.
## Stack
Next.js 16, TypeScript, Drizzle + PostgreSQL, Playwright + Cheerio, Zitadel OIDC, iron-session, Recharts.
## Dev
```bash
cp .env.example .env # fill in
bun install
bunx playwright install chromium
bun run db:push
bun run dev
```
## Deploy
Coolify-App `preis.kuns.dev`. Daily scrape via Scheduled Task `0 6 * * *`.
Spec: `docs/superpowers/specs/2026-05-25-preis-tracker-design.md`
```
- [ ] **Step 7: Commit**
```bash
git add README.md
git commit -m "docs: readme"
git push
```
- [ ] **Step 8: Update memory**
After successful deploy, write to `~/.claude/projects/-home-mika/memory/`:
- Append to `MEMORY.md`: line for `preis-tracker` project (Coolify UUID, repo, domain, DB)
- If learnings emerged (selector drift, Playwright in container quirks), add to `learnings.md`
---
## Self-Review Notes
**Spec coverage:** All 16 sections of the spec map to tasks 1-19. Auth (§8) → Task 11. Scrapers (§6) → Tasks 4-8. Alerts (§4, §9) → Tasks 10, 13. Cron flow (§7) → Task 14. UI (§10) → Tasks 15-17. Deployment (§12) → Task 19.
**Known fragility:** The HTML-fixture-based tests are only as good as the captured fixtures. If a fixture is from a captcha/cloudflare page, the scraper tests "pass" by extracting nothing. Step 1 of each scraper task instructs to verify fixture size — that's the guardrail.
**Concurrency in cron:** The simple `worker()` pattern in Task 14 is fine for single-digit product counts. If list grows >50, consider proper batching with backpressure.
**Type consistency:** `AlertType` defined in `evaluate.ts` (Task 10) is the authoritative source; API routes (Task 13) and Cron (Task 14) reference it indirectly via the literal union. Schema (Task 2) uses the same string values in the CHECK constraint.