Files
preis-tracker/docs/superpowers/plans/2026-05-25-preis-tracker.md

2784 lines
83 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 (
<html lang="de">
<body>{children}</body>
</html>
)
}
```
- [ ] **Step 10: Create stub `src/app/page.tsx`**
```tsx
export default function Home() {
return <main className="p-8"><h1 className="text-2xl">Preis-Tracker</h1></main>
}
```
- [ ] **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="<preistracker-pw>" 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<ScrapeResult>
}
```
- [ ] **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<Shop, PriceScraper>()
export function registerScraper(scraper: PriceScraper) {
registry.set(scraper.shop, scraper)
}
export async function scrapeUrl(url: string): Promise<ScrapeResult> {
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<ScrapeResult> {
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 () => '<html>Cloudflare</html>',
}) 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<ScrapeResult> {
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 = '<html><body><form action="/errors/validateCaptcha"></form></body></html>'
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<ScrapeResult> {
// 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<typeof vi.fn>
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<void> {
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<string, unknown>
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<string, number | string>
}
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<SessionData>(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<typeof createRemoteJWKSet> | 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<TokenSet> {
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<IdTokenClaims> {
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<SessionData>(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<string, unknown>,
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<string, number | string>, 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<string, number | string>, 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 <div className="h-10 text-xs text-zinc-500">keine Daten</div>
return (
<ResponsiveContainer width="100%" height={40}>
<LineChart data={data}>
<YAxis hide domain={['dataMin', 'dataMax']} />
<Line type="monotone" dataKey="price" stroke="#22c55e" strokeWidth={2} dot={false} isAnimationActive={false} />
</LineChart>
</ResponsiveContainer>
)
}
```
- [ ] **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 (
<Link href={`/products/${p.id}`} className="block rounded-lg border border-zinc-800 bg-zinc-900 p-4 hover:border-zinc-700 transition">
<div className="flex gap-3">
{p.imageUrl && <img src={p.imageUrl} alt="" className="h-16 w-16 object-contain rounded bg-white" />}
<div className="flex-1 min-w-0">
<div className="text-xs uppercase tracking-wide text-zinc-500">{p.shop}</div>
<div className="truncate text-sm font-medium">{p.name}</div>
<div className="mt-1 flex items-baseline gap-2">
<span className="text-lg font-semibold">{last !== null ? `${last.toFixed(2)}` : '—'}</span>
{deltaFromMin !== null && (
<span className="text-xs text-zinc-400">+{deltaFromMin} vom Tief</span>
)}
</div>
</div>
</div>
<div className="mt-2"><Sparkline data={p.sparkline} /></div>
</Link>
)
}
```
- [ ] **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<string, Array<{ price: number; t: string }>>()
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 (
<main className="mx-auto max-w-5xl p-6">
<header className="mb-6 flex items-center justify-between">
<h1 className="text-2xl font-bold">Preis-Tracker</h1>
<div className="flex gap-2">
<Link href="/add" className="rounded bg-emerald-600 px-3 py-1.5 text-sm font-medium hover:bg-emerald-500">+ Produkt</Link>
<Link href="/api/auth/logout" className="rounded bg-zinc-800 px-3 py-1.5 text-sm hover:bg-zinc-700">Logout</Link>
</div>
</header>
{rows.length === 0 ? (
<div className="rounded border border-dashed border-zinc-700 p-12 text-center text-zinc-400">
Noch keine Produkte. <Link href="/add" className="text-emerald-400 underline">Erstes hinzufügen</Link>.
</div>
) : (
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 lg:grid-cols-3">
{rows.map((r) => (
<ProductCard
key={r.id}
id={r.id}
name={r.name}
shop={r.shop}
imageUrl={r.image_url}
lastPrice={r.last_price}
minPrice={r.min_price}
sparkline={sparklines.get(r.id) ?? []}
/>
))}
</div>
)}
</main>
)
}
```
- [ ] **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<string | null>(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 (
<main className="mx-auto max-w-xl p-6">
<h1 className="mb-4 text-2xl font-bold">Produkt hinzufügen</h1>
<form onSubmit={submit} className="space-y-3">
<input
type="url"
required
value={url}
onChange={(e) => 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"
/>
<p className="text-xs text-zinc-500">Unterstützt: Amazon, Idealo, Geizhals</p>
{error && <div className="rounded bg-red-950 px-3 py-2 text-sm text-red-300">{error}</div>}
<button
type="submit"
disabled={submitting}
className="rounded bg-emerald-600 px-4 py-2 text-sm font-medium hover:bg-emerald-500 disabled:opacity-50"
>
{submitting ? 'Wird abgerufen…' : 'Hinzufügen'}
</button>
</form>
</main>
)
}
```
- [ ] **Step 2: Smoke test — add a Geizhals URL**
```bash
bun run dev
# Browser: /add → paste Geizhals URL → submit → redirect to /products/<id>
```
- [ ] **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 (
<div>
<div className="mb-2 flex gap-2">
{(['30d', '90d', 'all'] as const).map((r) => (
<button key={r} onClick={() => setRange(r)}
className={`rounded px-2 py-1 text-xs ${range === r ? 'bg-emerald-600' : 'bg-zinc-800'}`}>
{r}
</button>
))}
</div>
<ResponsiveContainer width="100%" height={300}>
<LineChart data={filtered}>
<CartesianGrid stroke="#27272a" strokeDasharray="3 3" />
<XAxis dataKey="t" tick={{ fill: '#a1a1aa', fontSize: 11 }} tickFormatter={(v) => new Date(v).toLocaleDateString('de-DE')} />
<YAxis tick={{ fill: '#a1a1aa', fontSize: 11 }} tickFormatter={(v) => `${v}`} domain={['dataMin', 'dataMax']} />
<Tooltip contentStyle={{ background: '#18181b', border: '1px solid #3f3f46' }}
labelFormatter={(v) => new Date(v).toLocaleString('de-DE')}
formatter={(v: number) => [`${v}`, 'Preis']} />
<Line type="monotone" dataKey="price" stroke="#22c55e" strokeWidth={2} dot={false} />
</LineChart>
</ResponsiveContainer>
</div>
)
}
```
- [ ] **Step 2: Create `src/components/AlertList.tsx`**
```tsx
'use client'
import { useRouter } from 'next/navigation'
interface Alert { id: string; type: string; config: Record<string, unknown>; 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 <p className="text-sm text-zinc-500">Keine Alerts.</p>
return (
<ul className="space-y-2">
{alerts.map((a) => (
<li key={a.id} className="flex items-center justify-between rounded border border-zinc-800 px-3 py-2 text-sm">
<span>{labelFor(a)}</span>
<button onClick={() => del(a.id)} className="text-xs text-red-400 hover:underline">Löschen</button>
</li>
))}
</ul>
)
}
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<string, unknown> = {}
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 (
<form onSubmit={submit} className="space-y-2 rounded border border-zinc-800 p-3">
<div className="flex gap-2">
<select value={type} onChange={(e) => setType(e.target.value as typeof type)} className="rounded border border-zinc-700 bg-zinc-900 px-2 py-1 text-sm">
<option value="target_price">Zielpreis</option>
<option value="all_time_low">Allzeit-Tief</option>
<option value="percent_drop">% Drop</option>
</select>
{type === 'target_price' && (
<input type="number" step="0.01" required value={threshold} onChange={(e) => 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' && (
<>
<input type="number" required value={percent} onChange={(e) => setPercent(e.target.value)}
className="w-16 rounded border border-zinc-700 bg-zinc-900 px-2 py-1 text-sm" />
<span className="self-center text-xs">% in</span>
<input type="number" required value={lookback} onChange={(e) => setLookback(e.target.value)}
className="w-16 rounded border border-zinc-700 bg-zinc-900 px-2 py-1 text-sm" />
<span className="self-center text-xs">Tagen</span>
</>
)}
<button type="submit" className="rounded bg-emerald-600 px-3 py-1 text-sm hover:bg-emerald-500">+ Alert</button>
</div>
</form>
)
}
```
- [ ] **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 (
<main className="mx-auto max-w-4xl p-6">
<a href="/" className="text-sm text-zinc-500 hover:underline"> Zurück</a>
<div className="mt-2 flex items-start gap-4">
{product.imageUrl && <img src={product.imageUrl} alt="" className="h-24 w-24 rounded bg-white object-contain" />}
<div className="flex-1">
<div className="text-xs uppercase text-zinc-500">{product.shop}</div>
<h1 className="text-xl font-bold">{product.name}</h1>
<a href={product.url} target="_blank" rel="noreferrer" className="text-sm text-emerald-400 hover:underline">Zum Shop </a>
</div>
</div>
<section className="mt-6">
<h2 className="mb-2 text-sm font-semibold uppercase text-zinc-400">Preisverlauf</h2>
<PriceChart data={chartData} />
</section>
<section className="mt-6">
<h2 className="mb-2 text-sm font-semibold uppercase text-zinc-400">Alerts</h2>
<AlertList alerts={productAlerts.map((a) => ({ id: a.id, type: a.type, config: a.config as Record<string, unknown>, enabled: a.enabled }))} />
<div className="mt-3"><AlertForm productId={id} /></div>
</section>
<section className="mt-6 flex gap-2">
<form action={`/api/products/${id}/scrape`} method="post">
<button className="rounded bg-zinc-800 px-3 py-1.5 text-sm hover:bg-zinc-700">Jetzt aktualisieren</button>
</form>
<form action={`/api/products/${id}`} method="post" onSubmit={(e) => { e.preventDefault(); if (confirm('Wirklich löschen?')) fetch(`/api/products/${id}`, { method: 'DELETE' }).then(() => location.href = '/') }}>
<button className="rounded bg-red-900 px-3 py-1.5 text-sm hover:bg-red-800">Löschen</button>
</form>
</section>
</main>
)
}
```
- [ ] **Step 5: Smoke test in browser**
```bash
bun run dev
# /products/<id> → 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:<pw>@l8kogcggsc80sgcgk8kswww4:5432/preistracker`
- `ZITADEL_ISSUER=https://auth.kuns.dev`
- `ZITADEL_CLIENT_ID=<from Zitadel UI>`
- `ALLOWED_USER_IDS=<your sub>`
- `SESSION_PASSWORD=<32+ chars>`
- `PUSHOVER_TOKEN=<your app token>`
- `PUSHOVER_USER=<your user key>`
- `CRON_SECRET=<random>`
- `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 <CRON_SECRET>" \
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.