# preis-tracker Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Eine Single-User-Webapp, die Produktpreise bei Amazon, Idealo und Geizhals täglich trackt, einen Preisverlauf anzeigt und bei konfigurierbaren Bedingungen Pushover-Notifications sendet. **Architecture:** Next.js 16 App Router auf Coolify, Shared PostgreSQL (DB `preistracker`), Playwright für Amazon + Cheerio für Idealo/Geizhals via Adapter-Pattern. Zitadel OIDC (manueller PKCE) sichert die App; ein Coolify Scheduled Task triggert daily um 06:00 die Scrape-API. Alerts (Zielpreis / Allzeit-Tief / Prozent-Drop) lösen Pushover-Pushes aus. **Tech Stack:** Next.js 16.2.2, React 19, TypeScript 5, Tailwind CSS 4, Drizzle 0.45 + postgres-js, Playwright 1.50, Cheerio 1.0, iron-session 8, jose 5 (JWKS), Recharts 3, Bun 1.3 (dev), Node 22 (Container), Vitest 2 (tests). **Spec:** `docs/superpowers/specs/2026-05-25-preis-tracker-design.md` **⚠ Next.js 16 Hinweis:** Diese Next-Version hat Breaking Changes gegenüber 14/15. **Vor dem Schreiben von App-Router-Code** in `node_modules/next/dist/docs/` (oder via context7 MCP) die aktuellen API-Signaturen für `route.ts`, `middleware.ts`, `params`-Async, `cookies()`, `headers()` prüfen. Trainingsdaten sind potentiell veraltet. --- ## File Map ``` preis-tracker/ ├── package.json ├── tsconfig.json ├── next.config.ts ├── drizzle.config.ts ├── postcss.config.mjs ├── eslint.config.mjs ├── Dockerfile ├── .dockerignore ├── .gitignore ├── .env.example ├── README.md ├── vitest.config.ts ├── drizzle/ # Auto-generated migrations ├── tests/ │ ├── fixtures/ │ │ ├── amazon-ps5.html │ │ ├── idealo-headphones.html │ │ └── geizhals-gpu.html │ ├── scrapers/ │ │ ├── geizhals.test.ts │ │ ├── idealo.test.ts │ │ └── amazon.test.ts │ ├── alerts/evaluate.test.ts │ └── pushover.test.ts └── src/ ├── middleware.ts # Auth gate ├── app/ │ ├── layout.tsx │ ├── globals.css │ ├── page.tsx # Dashboard │ ├── add/page.tsx # Add-Product Form │ ├── products/[id]/page.tsx # Detail + Chart │ └── api/ │ ├── auth/ │ │ ├── login/route.ts │ │ ├── callback/route.ts │ │ └── logout/route.ts │ ├── products/ │ │ ├── route.ts # POST add, GET list │ │ └── [id]/ │ │ ├── route.ts # GET detail, DELETE │ │ └── scrape/route.ts # POST manual scrape │ ├── alerts/ │ │ ├── route.ts # POST create │ │ └── [id]/route.ts # DELETE │ └── cron/scrape/route.ts # Daily scrape entry ├── lib/ │ ├── db/ │ │ ├── index.ts # drizzle client │ │ └── schema.ts # tables + types │ ├── auth/ │ │ ├── session.ts # iron-session config │ │ ├── pkce.ts # PKCE helpers │ │ └── zitadel.ts # OIDC token exchange + JWKS │ ├── scrapers/ │ │ ├── types.ts │ │ ├── index.ts # registry + scrapeUrl() │ │ ├── amazon.ts │ │ ├── idealo.ts │ │ └── geizhals.ts │ ├── alerts/ │ │ └── evaluate.ts # pure alert-trigger logic │ ├── pushover.ts │ └── shops.ts # shop detection from URL └── components/ ├── ProductCard.tsx ├── Sparkline.tsx ├── PriceChart.tsx ├── AlertList.tsx └── AlertForm.tsx ``` --- ## Task 1: Project Bootstrap **Files:** - Create: `package.json` - Create: `tsconfig.json` - Create: `next.config.ts` - Create: `postcss.config.mjs` - Create: `eslint.config.mjs` - Create: `.gitignore` - Create: `.env.example` - Create: `src/app/layout.tsx` - Create: `src/app/page.tsx` - Create: `src/app/globals.css` - [ ] **Step 1: Create `package.json`** ```json { "name": "preis-tracker", "version": "0.1.0", "private": true, "scripts": { "dev": "next dev", "build": "next build", "start": "next start", "lint": "eslint", "test": "vitest run", "test:watch": "vitest", "db:generate": "drizzle-kit generate", "db:push": "drizzle-kit push", "db:studio": "drizzle-kit studio" }, "dependencies": { "@types/cheerio": "^0.22.35", "cheerio": "^1.0.0", "drizzle-orm": "^0.45.2", "iron-session": "^8.0.4", "jose": "^5.9.6", "next": "16.2.2", "playwright": "^1.50.0", "postgres": "^3.4.8", "react": "19.2.4", "react-dom": "19.2.4", "recharts": "^3.8.1", "zod": "^3.23.8" }, "devDependencies": { "@tailwindcss/postcss": "^4", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", "drizzle-kit": "^0.31.10", "eslint": "^9", "eslint-config-next": "16.2.2", "tailwindcss": "^4", "tsx": "^4.21.0", "typescript": "^5", "vitest": "^2.1.8" } } ``` - [ ] **Step 2: Create `tsconfig.json`** ```json { "compilerOptions": { "target": "ES2022", "lib": ["dom", "dom.iterable", "esnext"], "allowJs": false, "skipLibCheck": true, "strict": true, "noEmit": true, "esModuleInterop": true, "module": "esnext", "moduleResolution": "bundler", "resolveJsonModule": true, "isolatedModules": true, "jsx": "preserve", "incremental": true, "plugins": [{ "name": "next" }], "paths": { "@/*": ["./src/*"] } }, "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], "exclude": ["node_modules"] } ``` - [ ] **Step 3: Create `next.config.ts`** ```typescript import type { NextConfig } from 'next' const config: NextConfig = { output: 'standalone', experimental: { serverActions: { allowedOrigins: ['preis.kuns.dev'] }, }, images: { remotePatterns: [ { protocol: 'https', hostname: '**.amazon.com' }, { protocol: 'https', hostname: '**.amazon.de' }, { protocol: 'https', hostname: '**.media-amazon.com' }, { protocol: 'https', hostname: '**.idealo.com' }, { protocol: 'https', hostname: '**.geizhals.de' }, ], }, } export default config ``` - [ ] **Step 4: Create `postcss.config.mjs`** ```javascript export default { plugins: { '@tailwindcss/postcss': {} } } ``` - [ ] **Step 5: Create `eslint.config.mjs`** ```javascript import next from 'eslint-config-next' export default [...next, { rules: { '@typescript-eslint/no-explicit-any': 'warn' } }] ``` - [ ] **Step 6: Create `.gitignore`** ``` node_modules/ .next/ .env .env.local *.log .DS_Store out/ build/ .turbo/ coverage/ test-results/ ``` - [ ] **Step 7: Create `.env.example`** ``` # Database DATABASE_URL=postgresql://preistracker:CHANGEME@localhost:5432/preistracker # Zitadel OIDC ZITADEL_ISSUER=https://auth.kuns.dev ZITADEL_CLIENT_ID= ALLOWED_USER_IDS= # Session (32+ chars, random) SESSION_PASSWORD= # Pushover PUSHOVER_TOKEN= PUSHOVER_USER= # Cron secret (any random string) CRON_SECRET= # Public URL (for OIDC redirects) NEXT_PUBLIC_BASE_URL=http://localhost:3000 ``` - [ ] **Step 8: Create `src/app/globals.css`** ```css @import "tailwindcss"; :root { --background: #0a0a0a; --foreground: #ededed; } body { background: var(--background); color: var(--foreground); font-family: ui-sans-serif, system-ui, sans-serif; } ``` - [ ] **Step 9: Create `src/app/layout.tsx`** ```tsx import './globals.css' import type { Metadata } from 'next' export const metadata: Metadata = { title: 'Preis-Tracker', description: 'Tracking von Produktpreisen bei Amazon, Idealo, Geizhals', } export default function RootLayout({ children }: { children: React.ReactNode }) { return ( {children} ) } ``` - [ ] **Step 10: Create stub `src/app/page.tsx`** ```tsx export default function Home() { return

Preis-Tracker

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

Preis-Tracker

+ Produkt Logout
{rows.length === 0 ? (
Noch keine Produkte. Erstes hinzufügen.
) : (
{rows.map((r) => ( ))}
)}
) } ``` - [ ] **Step 4: Smoke test in browser** ```bash bun run dev # Open http://localhost:3000, login, verify dashboard renders (empty state ok) ``` - [ ] **Step 5: Commit** ```bash git add -A git commit -m "feat: dashboard UI with product cards + sparklines" ``` --- ## Task 16: UI — Add Product **Files:** - Create: `src/app/add/page.tsx` - [ ] **Step 1: Create `src/app/add/page.tsx`** ```tsx 'use client' import { useState } from 'react' import { useRouter } from 'next/navigation' export default function AddPage() { const [url, setUrl] = useState('') const [submitting, setSubmitting] = useState(false) const [error, setError] = useState(null) const router = useRouter() async function submit(e: React.FormEvent) { e.preventDefault() setSubmitting(true) setError(null) try { const res = await fetch('/api/products', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ url }), }) if (!res.ok) { const j = await res.json().catch(() => ({ error: 'unknown' })) setError(j.error || `HTTP ${res.status}`) return } const { id } = await res.json() router.push(`/products/${id}`) } finally { setSubmitting(false) } } return (

Produkt hinzufügen

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

Unterstützt: Amazon, Idealo, Geizhals

{error &&
{error}
}
) } ``` - [ ] **Step 2: Smoke test — add a Geizhals URL** ```bash bun run dev # Browser: /add → paste Geizhals URL → submit → redirect to /products/ ``` - [ ] **Step 3: Commit** ```bash git add -A git commit -m "feat: add-product page" ``` --- ## Task 17: UI — Product Detail (Chart + Alerts) **Files:** - Create: `src/components/PriceChart.tsx` - Create: `src/components/AlertList.tsx` - Create: `src/components/AlertForm.tsx` - Create: `src/app/products/[id]/page.tsx` - [ ] **Step 1: Create `src/components/PriceChart.tsx`** ```tsx 'use client' import { useState } from 'react' import { LineChart, Line, ResponsiveContainer, XAxis, YAxis, Tooltip, CartesianGrid } from 'recharts' interface Snap { price: number; t: string } export function PriceChart({ data }: { data: Snap[] }) { const [range, setRange] = useState<'30d' | '90d' | 'all'>('30d') const cutoff = range === 'all' ? 0 : Date.now() - (range === '30d' ? 30 : 90) * 86400_000 const filtered = data.filter((d) => new Date(d.t).getTime() >= cutoff) return (
{(['30d', '90d', 'all'] as const).map((r) => ( ))}
new Date(v).toLocaleDateString('de-DE')} /> `${v}€`} domain={['dataMin', 'dataMax']} /> new Date(v).toLocaleString('de-DE')} formatter={(v: number) => [`${v}€`, 'Preis']} />
) } ``` - [ ] **Step 2: Create `src/components/AlertList.tsx`** ```tsx 'use client' import { useRouter } from 'next/navigation' interface Alert { id: string; type: string; config: Record; enabled: boolean } export function AlertList({ alerts }: { alerts: Alert[] }) { const router = useRouter() async function del(id: string) { await fetch(`/api/alerts/${id}`, { method: 'DELETE' }) router.refresh() } if (alerts.length === 0) return

Keine Alerts.

return (
    {alerts.map((a) => (
  • {labelFor(a)}
  • ))}
) } function labelFor(a: Alert): string { switch (a.type) { case 'target_price': return `Zielpreis ≤ ${a.config.threshold}€` case 'all_time_low': return `Allzeit-Tief` case 'percent_drop': return `−${a.config.percent}% in ${a.config.lookback_days} Tagen` default: return a.type } } ``` - [ ] **Step 3: Create `src/components/AlertForm.tsx`** ```tsx 'use client' import { useState } from 'react' import { useRouter } from 'next/navigation' export function AlertForm({ productId }: { productId: string }) { const [type, setType] = useState<'target_price' | 'all_time_low' | 'percent_drop'>('target_price') const [threshold, setThreshold] = useState('') const [percent, setPercent] = useState('10') const [lookback, setLookback] = useState('7') const router = useRouter() async function submit(e: React.FormEvent) { e.preventDefault() let config: Record = {} if (type === 'target_price') config = { threshold: Number(threshold) } if (type === 'percent_drop') config = { percent: Number(percent), lookback_days: Number(lookback) } const res = await fetch('/api/alerts', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ productId, type, config }), }) if (res.ok) { setThreshold('') router.refresh() } } return (
{type === 'target_price' && ( setThreshold(e.target.value)} placeholder="€" className="w-24 rounded border border-zinc-700 bg-zinc-900 px-2 py-1 text-sm" /> )} {type === 'percent_drop' && ( <> setPercent(e.target.value)} className="w-16 rounded border border-zinc-700 bg-zinc-900 px-2 py-1 text-sm" /> % in setLookback(e.target.value)} className="w-16 rounded border border-zinc-700 bg-zinc-900 px-2 py-1 text-sm" /> Tagen )}
) } ``` - [ ] **Step 4: Create `src/app/products/[id]/page.tsx`** ```tsx import { notFound } from 'next/navigation' import { eq, desc } from 'drizzle-orm' import { db, products, priceSnapshots, alerts } from '@/lib/db' import { PriceChart } from '@/components/PriceChart' import { AlertList } from '@/components/AlertList' import { AlertForm } from '@/components/AlertForm' export const dynamic = 'force-dynamic' export default async function ProductPage({ params }: { params: Promise<{ id: string }> }) { const { id } = await params const [product] = await db.select().from(products).where(eq(products.id, id)) if (!product) notFound() const snaps = await db.select().from(priceSnapshots) .where(eq(priceSnapshots.productId, id)) .orderBy(desc(priceSnapshots.scrapedAt)) .limit(500) const chartData = snaps .filter((s) => s.price !== null) .reverse() .map((s) => ({ price: Number(s.price), t: s.scrapedAt.toISOString() })) const productAlerts = await db.select().from(alerts).where(eq(alerts.productId, id)) return (
← Zurück
{product.imageUrl && }
{product.shop}

{product.name}

Zum Shop ↗

Preisverlauf

Alerts

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