diff --git a/docs/superpowers/plans/2026-05-25-preis-tracker.md b/docs/superpowers/plans/2026-05-25-preis-tracker.md new file mode 100644 index 0000000..1e1c1c0 --- /dev/null +++ b/docs/superpowers/plans/2026-05-25-preis-tracker.md @@ -0,0 +1,2783 @@ +# 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. +
+ ) : ( +
+ {rows.map((r) => ( + + ))} +
+ )} +
+ ) +} +``` + +- [ ] **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

+
+ setUrl(e.target.value)} + placeholder="https://www.amazon.de/dp/..." + className="w-full rounded border border-zinc-700 bg-zinc-900 px-3 py-2 text-sm" + /> +

Unterstützt: Amazon, Idealo, Geizhals

+ {error &&
{error}
} + +
+
+ ) +} +``` + +- [ ] **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 ( +
+
+ + {type === 'target_price' && ( + setThreshold(e.target.value)} + placeholder="€" className="w-24 rounded border border-zinc-700 bg-zinc-900 px-2 py-1 text-sm" /> + )} + {type === 'percent_drop' && ( + <> + setPercent(e.target.value)} + className="w-16 rounded border border-zinc-700 bg-zinc-900 px-2 py-1 text-sm" /> + % in + setLookback(e.target.value)} + className="w-16 rounded border border-zinc-700 bg-zinc-900 px-2 py-1 text-sm" /> + Tagen + + )} + +
+
+ ) +} +``` + +- [ ] **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 && } +
+
{product.shop}
+

{product.name}

+ Zum Shop ↗ +
+
+ +
+

Preisverlauf

+ +
+ +
+

Alerts

+ ({ id: a.id, type: a.type, config: a.config as Record, enabled: a.enabled }))} /> +
+
+ +
+
+ +
+
{ e.preventDefault(); if (confirm('Wirklich löschen?')) fetch(`/api/products/${id}`, { method: 'DELETE' }).then(() => location.href = '/') }}> + +
+
+
+ ) +} +``` + +- [ ] **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.