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

83 KiB
Raw Blame History

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):

  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:

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:

  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
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 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.