83 KiB
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
{
"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
{
"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
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
export default { plugins: { '@tailwindcss/postcss': {} } }
- Step 5: Create
eslint.config.mjs
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
@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
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
export default function Home() {
return <main className="p-8"><h1 className="text-2xl">Preis-Tracker</h1></main>
}
- Step 11: Install + verify boot
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
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
# 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
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
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
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
bun run db:generate
bun run db:push
Expected: drizzle/ directory created with SQL, DB has 3 tables.
- Step 6: Verify tables exist
PGPASSWORD="<preistracker-pw>" psql -h localhost -p 54320 -U preistracker -d preistracker -c "\dt"
Expected: shows products, price_snapshots, alerts.
- Step 7: Commit
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
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
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
bun run test
Expected: FAIL — detectShop is not exported.
- Step 4: Implement
src/lib/shops.ts
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
bun run test
Expected: All tests pass.
- Step 6: Commit
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
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
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
bun run test tests/scrapers/registry.test.ts
Expected: FAIL — module missing.
- Step 4: Implement
src/lib/scrapers/index.ts
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
bun run test tests/scrapers/registry.test.ts
Expected: PASS.
- Step 6: Commit
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
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:
grep -oE 'class="[^"]*price[^"]*"' tests/fixtures/geizhals-gpu.html | head -3
- Step 2: Write failing test
tests/scrapers/geizhals.test.ts
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
bun run test tests/scrapers/geizhals.test.ts
Expected: FAIL — module missing.
- Step 4: Implement
src/lib/scrapers/geizhals.ts
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
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
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
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
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
bun run test tests/scrapers/idealo.test.ts
Expected: FAIL — module missing.
- Step 4: Implement
src/lib/scrapers/idealo.ts
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
bun run test tests/scrapers/idealo.test.ts
Expected: PASS.
- Step 6: Commit
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
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
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
bun run test tests/scrapers/amazon.test.ts
Expected: FAIL — module missing.
- Step 4: Implement
src/lib/scrapers/amazon.ts
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
bun run test tests/scrapers/amazon.test.ts
Expected: PASS.
- Step 6: Install Playwright Chromium for dev runs
bunx playwright install chromium
- Step 7: Manual smoke test
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
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
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
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
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
bun run test tests/pushover.test.ts
Expected: FAIL.
- Step 3: Implement
src/lib/pushover.ts
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
bun run test tests/pushover.test.ts
Expected: PASS.
- Step 5: Commit
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
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
bun run test tests/alerts/evaluate.test.ts
Expected: FAIL — module missing.
- Step 3: Implement
src/lib/alerts/evaluate.ts
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
bun run test tests/alerts/evaluate.test.ts
Expected: PASS — all cases green.
- Step 5: Commit
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
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
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
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
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
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
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
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):
- Project → Applications → New → Web → Name
preis-tracker - Auth Method:
PKCE - Redirect URIs:
https://preis.kuns.dev/api/auth/callback,http://localhost:3000/api/auth/callback - Post Logout URIs:
https://preis.kuns.dev/,http://localhost:3000/ - Copy
Client ID→ put into.envasZITADEL_CLIENT_ID - Get your user-sub: User Profile → ID → put into
.envasALLOWED_USER_IDS
Generate session password:
openssl rand -base64 48 | head -c 48
Put into .env as SESSION_PASSWORD.
- Step 9: Smoke test login flow locally
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
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
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
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
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
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
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
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
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
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
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
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
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
'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
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
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
bun run dev
# Open http://localhost:3000, login, verify dashboard renders (empty state ok)
- Step 5: Commit
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
'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
bun run dev
# Browser: /add → paste Geizhals URL → submit → redirect to /products/<id>
- Step 3: Commit
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
'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
'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
'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
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
bun run dev
# /products/<id> → verify chart, alerts list, form, refresh button
- Step 6: Commit
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
# 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
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
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
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:
- Coolify UI → New Resource → Application → Public Repository →
https://git.kuns.dev/kuns/preis-tracker - Build Pack: Dockerfile
- Domain:
https://preis.kuns.dev - Ports:
3000:3000 - Env Vars (mark sensitive ones as Build/Runtime Secret):
DATABASE_URL=postgresql://preistracker:<pw>@l8kogcggsc80sgcgk8kswww4:5432/preistrackerZITADEL_ISSUER=https://auth.kuns.devZITADEL_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
- Webhook → enable, copy URL into Gitea repo settings → Webhooks
- 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
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
# 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 forpreis-trackerproject (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.