2784 lines
83 KiB
Markdown
2784 lines
83 KiB
Markdown
# preis-tracker Implementation Plan
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** Eine Single-User-Webapp, die Produktpreise bei Amazon, Idealo und Geizhals täglich trackt, einen Preisverlauf anzeigt und bei konfigurierbaren Bedingungen Pushover-Notifications sendet.
|
||
|
||
**Architecture:** Next.js 16 App Router auf Coolify, Shared PostgreSQL (DB `preistracker`), Playwright für Amazon + Cheerio für Idealo/Geizhals via Adapter-Pattern. Zitadel OIDC (manueller PKCE) sichert die App; ein Coolify Scheduled Task triggert daily um 06:00 die Scrape-API. Alerts (Zielpreis / Allzeit-Tief / Prozent-Drop) lösen Pushover-Pushes aus.
|
||
|
||
**Tech Stack:** Next.js 16.2.2, React 19, TypeScript 5, Tailwind CSS 4, Drizzle 0.45 + postgres-js, Playwright 1.50, Cheerio 1.0, iron-session 8, jose 5 (JWKS), Recharts 3, Bun 1.3 (dev), Node 22 (Container), Vitest 2 (tests).
|
||
|
||
**Spec:** `docs/superpowers/specs/2026-05-25-preis-tracker-design.md`
|
||
|
||
**⚠ Next.js 16 Hinweis:** Diese Next-Version hat Breaking Changes gegenüber 14/15. **Vor dem Schreiben von App-Router-Code** in `node_modules/next/dist/docs/` (oder via context7 MCP) die aktuellen API-Signaturen für `route.ts`, `middleware.ts`, `params`-Async, `cookies()`, `headers()` prüfen. Trainingsdaten sind potentiell veraltet.
|
||
|
||
---
|
||
|
||
## File Map
|
||
|
||
```
|
||
preis-tracker/
|
||
├── package.json
|
||
├── tsconfig.json
|
||
├── next.config.ts
|
||
├── drizzle.config.ts
|
||
├── postcss.config.mjs
|
||
├── eslint.config.mjs
|
||
├── Dockerfile
|
||
├── .dockerignore
|
||
├── .gitignore
|
||
├── .env.example
|
||
├── README.md
|
||
├── vitest.config.ts
|
||
├── drizzle/ # Auto-generated migrations
|
||
├── tests/
|
||
│ ├── fixtures/
|
||
│ │ ├── amazon-ps5.html
|
||
│ │ ├── idealo-headphones.html
|
||
│ │ └── geizhals-gpu.html
|
||
│ ├── scrapers/
|
||
│ │ ├── geizhals.test.ts
|
||
│ │ ├── idealo.test.ts
|
||
│ │ └── amazon.test.ts
|
||
│ ├── alerts/evaluate.test.ts
|
||
│ └── pushover.test.ts
|
||
└── src/
|
||
├── middleware.ts # Auth gate
|
||
├── app/
|
||
│ ├── layout.tsx
|
||
│ ├── globals.css
|
||
│ ├── page.tsx # Dashboard
|
||
│ ├── add/page.tsx # Add-Product Form
|
||
│ ├── products/[id]/page.tsx # Detail + Chart
|
||
│ └── api/
|
||
│ ├── auth/
|
||
│ │ ├── login/route.ts
|
||
│ │ ├── callback/route.ts
|
||
│ │ └── logout/route.ts
|
||
│ ├── products/
|
||
│ │ ├── route.ts # POST add, GET list
|
||
│ │ └── [id]/
|
||
│ │ ├── route.ts # GET detail, DELETE
|
||
│ │ └── scrape/route.ts # POST manual scrape
|
||
│ ├── alerts/
|
||
│ │ ├── route.ts # POST create
|
||
│ │ └── [id]/route.ts # DELETE
|
||
│ └── cron/scrape/route.ts # Daily scrape entry
|
||
├── lib/
|
||
│ ├── db/
|
||
│ │ ├── index.ts # drizzle client
|
||
│ │ └── schema.ts # tables + types
|
||
│ ├── auth/
|
||
│ │ ├── session.ts # iron-session config
|
||
│ │ ├── pkce.ts # PKCE helpers
|
||
│ │ └── zitadel.ts # OIDC token exchange + JWKS
|
||
│ ├── scrapers/
|
||
│ │ ├── types.ts
|
||
│ │ ├── index.ts # registry + scrapeUrl()
|
||
│ │ ├── amazon.ts
|
||
│ │ ├── idealo.ts
|
||
│ │ └── geizhals.ts
|
||
│ ├── alerts/
|
||
│ │ └── evaluate.ts # pure alert-trigger logic
|
||
│ ├── pushover.ts
|
||
│ └── shops.ts # shop detection from URL
|
||
└── components/
|
||
├── ProductCard.tsx
|
||
├── Sparkline.tsx
|
||
├── PriceChart.tsx
|
||
├── AlertList.tsx
|
||
└── AlertForm.tsx
|
||
```
|
||
|
||
---
|
||
|
||
## Task 1: Project Bootstrap
|
||
|
||
**Files:**
|
||
- Create: `package.json`
|
||
- Create: `tsconfig.json`
|
||
- Create: `next.config.ts`
|
||
- Create: `postcss.config.mjs`
|
||
- Create: `eslint.config.mjs`
|
||
- Create: `.gitignore`
|
||
- Create: `.env.example`
|
||
- Create: `src/app/layout.tsx`
|
||
- Create: `src/app/page.tsx`
|
||
- Create: `src/app/globals.css`
|
||
|
||
- [ ] **Step 1: Create `package.json`**
|
||
|
||
```json
|
||
{
|
||
"name": "preis-tracker",
|
||
"version": "0.1.0",
|
||
"private": true,
|
||
"scripts": {
|
||
"dev": "next dev",
|
||
"build": "next build",
|
||
"start": "next start",
|
||
"lint": "eslint",
|
||
"test": "vitest run",
|
||
"test:watch": "vitest",
|
||
"db:generate": "drizzle-kit generate",
|
||
"db:push": "drizzle-kit push",
|
||
"db:studio": "drizzle-kit studio"
|
||
},
|
||
"dependencies": {
|
||
"@types/cheerio": "^0.22.35",
|
||
"cheerio": "^1.0.0",
|
||
"drizzle-orm": "^0.45.2",
|
||
"iron-session": "^8.0.4",
|
||
"jose": "^5.9.6",
|
||
"next": "16.2.2",
|
||
"playwright": "^1.50.0",
|
||
"postgres": "^3.4.8",
|
||
"react": "19.2.4",
|
||
"react-dom": "19.2.4",
|
||
"recharts": "^3.8.1",
|
||
"zod": "^3.23.8"
|
||
},
|
||
"devDependencies": {
|
||
"@tailwindcss/postcss": "^4",
|
||
"@types/node": "^20",
|
||
"@types/react": "^19",
|
||
"@types/react-dom": "^19",
|
||
"drizzle-kit": "^0.31.10",
|
||
"eslint": "^9",
|
||
"eslint-config-next": "16.2.2",
|
||
"tailwindcss": "^4",
|
||
"tsx": "^4.21.0",
|
||
"typescript": "^5",
|
||
"vitest": "^2.1.8"
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Create `tsconfig.json`**
|
||
|
||
```json
|
||
{
|
||
"compilerOptions": {
|
||
"target": "ES2022",
|
||
"lib": ["dom", "dom.iterable", "esnext"],
|
||
"allowJs": false,
|
||
"skipLibCheck": true,
|
||
"strict": true,
|
||
"noEmit": true,
|
||
"esModuleInterop": true,
|
||
"module": "esnext",
|
||
"moduleResolution": "bundler",
|
||
"resolveJsonModule": true,
|
||
"isolatedModules": true,
|
||
"jsx": "preserve",
|
||
"incremental": true,
|
||
"plugins": [{ "name": "next" }],
|
||
"paths": { "@/*": ["./src/*"] }
|
||
},
|
||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||
"exclude": ["node_modules"]
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Create `next.config.ts`**
|
||
|
||
```typescript
|
||
import type { NextConfig } from 'next'
|
||
|
||
const config: NextConfig = {
|
||
output: 'standalone',
|
||
experimental: {
|
||
serverActions: { allowedOrigins: ['preis.kuns.dev'] },
|
||
},
|
||
images: {
|
||
remotePatterns: [
|
||
{ protocol: 'https', hostname: '**.amazon.com' },
|
||
{ protocol: 'https', hostname: '**.amazon.de' },
|
||
{ protocol: 'https', hostname: '**.media-amazon.com' },
|
||
{ protocol: 'https', hostname: '**.idealo.com' },
|
||
{ protocol: 'https', hostname: '**.geizhals.de' },
|
||
],
|
||
},
|
||
}
|
||
|
||
export default config
|
||
```
|
||
|
||
- [ ] **Step 4: Create `postcss.config.mjs`**
|
||
|
||
```javascript
|
||
export default { plugins: { '@tailwindcss/postcss': {} } }
|
||
```
|
||
|
||
- [ ] **Step 5: Create `eslint.config.mjs`**
|
||
|
||
```javascript
|
||
import next from 'eslint-config-next'
|
||
export default [...next, { rules: { '@typescript-eslint/no-explicit-any': 'warn' } }]
|
||
```
|
||
|
||
- [ ] **Step 6: Create `.gitignore`**
|
||
|
||
```
|
||
node_modules/
|
||
.next/
|
||
.env
|
||
.env.local
|
||
*.log
|
||
.DS_Store
|
||
out/
|
||
build/
|
||
.turbo/
|
||
coverage/
|
||
test-results/
|
||
```
|
||
|
||
- [ ] **Step 7: Create `.env.example`**
|
||
|
||
```
|
||
# Database
|
||
DATABASE_URL=postgresql://preistracker:CHANGEME@localhost:5432/preistracker
|
||
|
||
# Zitadel OIDC
|
||
ZITADEL_ISSUER=https://auth.kuns.dev
|
||
ZITADEL_CLIENT_ID=
|
||
ALLOWED_USER_IDS=
|
||
|
||
# Session (32+ chars, random)
|
||
SESSION_PASSWORD=
|
||
|
||
# Pushover
|
||
PUSHOVER_TOKEN=
|
||
PUSHOVER_USER=
|
||
|
||
# Cron secret (any random string)
|
||
CRON_SECRET=
|
||
|
||
# Public URL (for OIDC redirects)
|
||
NEXT_PUBLIC_BASE_URL=http://localhost:3000
|
||
```
|
||
|
||
- [ ] **Step 8: Create `src/app/globals.css`**
|
||
|
||
```css
|
||
@import "tailwindcss";
|
||
|
||
:root {
|
||
--background: #0a0a0a;
|
||
--foreground: #ededed;
|
||
}
|
||
|
||
body {
|
||
background: var(--background);
|
||
color: var(--foreground);
|
||
font-family: ui-sans-serif, system-ui, sans-serif;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 9: Create `src/app/layout.tsx`**
|
||
|
||
```tsx
|
||
import './globals.css'
|
||
import type { Metadata } from 'next'
|
||
|
||
export const metadata: Metadata = {
|
||
title: 'Preis-Tracker',
|
||
description: 'Tracking von Produktpreisen bei Amazon, Idealo, Geizhals',
|
||
}
|
||
|
||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||
return (
|
||
<html lang="de">
|
||
<body>{children}</body>
|
||
</html>
|
||
)
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 10: Create stub `src/app/page.tsx`**
|
||
|
||
```tsx
|
||
export default function Home() {
|
||
return <main className="p-8"><h1 className="text-2xl">Preis-Tracker</h1></main>
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 11: Install + verify boot**
|
||
|
||
```bash
|
||
cd ~/preis-tracker
|
||
bun install
|
||
bun run dev &
|
||
sleep 5
|
||
curl -sf http://localhost:3000 > /dev/null && echo OK
|
||
kill %1 2>/dev/null
|
||
```
|
||
|
||
Expected: `OK`
|
||
|
||
- [ ] **Step 12: Commit**
|
||
|
||
```bash
|
||
git add -A
|
||
git commit -m "feat: bootstrap next.js + tailwind + deps"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 2: Database Schema + Migrations
|
||
|
||
**Files:**
|
||
- Create: `drizzle.config.ts`
|
||
- Create: `src/lib/db/schema.ts`
|
||
- Create: `src/lib/db/index.ts`
|
||
|
||
- [ ] **Step 1: Provision DB in Shared PostgreSQL**
|
||
|
||
```bash
|
||
# As user mika on kuns.dev
|
||
source ~/.secrets/coolify-tokens.env
|
||
PGPASSWORD="$SHARED_POSTGRES_PASSWORD" psql -h localhost -p 54320 -U mika -d postgres \
|
||
-c "CREATE DATABASE preistracker;" \
|
||
-c "CREATE USER preistracker WITH PASSWORD 'GENERATE_AND_NOTE_DOWN';" \
|
||
-c "GRANT ALL PRIVILEGES ON DATABASE preistracker TO preistracker;"
|
||
PGPASSWORD="$SHARED_POSTGRES_PASSWORD" psql -h localhost -p 54320 -U mika -d preistracker \
|
||
-c "GRANT ALL ON SCHEMA public TO preistracker;"
|
||
```
|
||
|
||
Update `.env` (NOT `.env.example`) with the generated password.
|
||
|
||
- [ ] **Step 2: Create `drizzle.config.ts`**
|
||
|
||
```typescript
|
||
import 'dotenv/config'
|
||
import { defineConfig } from 'drizzle-kit'
|
||
|
||
export default defineConfig({
|
||
schema: './src/lib/db/schema.ts',
|
||
out: './drizzle',
|
||
dialect: 'postgresql',
|
||
dbCredentials: { url: process.env.DATABASE_URL! },
|
||
})
|
||
```
|
||
|
||
- [ ] **Step 3: Create `src/lib/db/schema.ts`**
|
||
|
||
```typescript
|
||
import { pgTable, uuid, text, timestamp, boolean, integer, numeric, bigserial, jsonb, index, check } from 'drizzle-orm/pg-core'
|
||
import { sql } from 'drizzle-orm'
|
||
|
||
export const products = pgTable('products', {
|
||
id: uuid('id').primaryKey().defaultRandom(),
|
||
url: text('url').notNull().unique(),
|
||
shop: text('shop').notNull(),
|
||
name: text('name').notNull(),
|
||
imageUrl: text('image_url'),
|
||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||
enabled: boolean('enabled').notNull().default(true),
|
||
lastScrapedAt: timestamp('last_scraped_at', { withTimezone: true }),
|
||
consecutiveFailures: integer('consecutive_failures').notNull().default(0),
|
||
}, (t) => ({
|
||
shopCheck: check('shop_check', sql`${t.shop} in ('amazon','idealo','geizhals')`),
|
||
}))
|
||
|
||
export const priceSnapshots = pgTable('price_snapshots', {
|
||
id: bigserial('id', { mode: 'number' }).primaryKey(),
|
||
productId: uuid('product_id').notNull().references(() => products.id, { onDelete: 'cascade' }),
|
||
price: numeric('price', { precision: 10, scale: 2 }),
|
||
currency: text('currency').notNull().default('EUR'),
|
||
availability: text('availability'),
|
||
error: text('error'),
|
||
scrapedAt: timestamp('scraped_at', { withTimezone: true }).notNull().defaultNow(),
|
||
}, (t) => ({
|
||
productScrapedIdx: index('snapshots_product_scraped_idx').on(t.productId, t.scrapedAt.desc()),
|
||
}))
|
||
|
||
export const alerts = pgTable('alerts', {
|
||
id: uuid('id').primaryKey().defaultRandom(),
|
||
productId: uuid('product_id').notNull().references(() => products.id, { onDelete: 'cascade' }),
|
||
type: text('type').notNull(),
|
||
config: jsonb('config').notNull(),
|
||
enabled: boolean('enabled').notNull().default(true),
|
||
lastTriggeredAt: timestamp('last_triggered_at', { withTimezone: true }),
|
||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||
}, (t) => ({
|
||
typeCheck: check('alert_type_check', sql`${t.type} in ('target_price','all_time_low','percent_drop')`),
|
||
}))
|
||
|
||
export type Product = typeof products.$inferSelect
|
||
export type NewProduct = typeof products.$inferInsert
|
||
export type PriceSnapshot = typeof priceSnapshots.$inferSelect
|
||
export type Alert = typeof alerts.$inferSelect
|
||
export type NewAlert = typeof alerts.$inferInsert
|
||
```
|
||
|
||
- [ ] **Step 4: Create `src/lib/db/index.ts`**
|
||
|
||
```typescript
|
||
import { drizzle } from 'drizzle-orm/postgres-js'
|
||
import postgres from 'postgres'
|
||
import * as schema from './schema'
|
||
|
||
const connectionString = process.env.DATABASE_URL
|
||
if (!connectionString) throw new Error('DATABASE_URL not set')
|
||
|
||
const client = postgres(connectionString, { max: 5 })
|
||
export const db = drizzle(client, { schema })
|
||
export * from './schema'
|
||
```
|
||
|
||
- [ ] **Step 5: Generate + run migration**
|
||
|
||
```bash
|
||
bun run db:generate
|
||
bun run db:push
|
||
```
|
||
|
||
Expected: `drizzle/` directory created with SQL, DB has 3 tables.
|
||
|
||
- [ ] **Step 6: Verify tables exist**
|
||
|
||
```bash
|
||
PGPASSWORD="<preistracker-pw>" psql -h localhost -p 54320 -U preistracker -d preistracker -c "\dt"
|
||
```
|
||
|
||
Expected: shows `products`, `price_snapshots`, `alerts`.
|
||
|
||
- [ ] **Step 7: Commit**
|
||
|
||
```bash
|
||
git add -A
|
||
git commit -m "feat: drizzle schema + migrations for products/snapshots/alerts"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 3: Vitest Setup + Shop Detection
|
||
|
||
**Files:**
|
||
- Create: `vitest.config.ts`
|
||
- Create: `src/lib/shops.ts`
|
||
- Create: `tests/shops.test.ts`
|
||
|
||
- [ ] **Step 1: Create `vitest.config.ts`**
|
||
|
||
```typescript
|
||
import { defineConfig } from 'vitest/config'
|
||
import path from 'node:path'
|
||
|
||
export default defineConfig({
|
||
test: {
|
||
environment: 'node',
|
||
include: ['tests/**/*.test.ts'],
|
||
},
|
||
resolve: { alias: { '@': path.resolve(__dirname, './src') } },
|
||
})
|
||
```
|
||
|
||
- [ ] **Step 2: Write failing test `tests/shops.test.ts`**
|
||
|
||
```typescript
|
||
import { describe, it, expect } from 'vitest'
|
||
import { detectShop } from '@/lib/shops'
|
||
|
||
describe('detectShop', () => {
|
||
it.each([
|
||
['https://www.amazon.de/dp/B0C5BMMJTL', 'amazon'],
|
||
['https://amazon.com/gp/product/B0C5BMMJTL', 'amazon'],
|
||
['https://www.idealo.de/preisvergleich/OffersOfProduct/123456_-foo.html', 'idealo'],
|
||
['https://geizhals.de/sony-playstation-5-a2362829.html', 'geizhals'],
|
||
['https://www.geizhals.eu/foo', 'geizhals'],
|
||
])('detects %s as %s', (url, expected) => {
|
||
expect(detectShop(url)).toBe(expected)
|
||
})
|
||
|
||
it('returns null for unknown shop', () => {
|
||
expect(detectShop('https://example.com/foo')).toBeNull()
|
||
})
|
||
|
||
it('returns null for invalid url', () => {
|
||
expect(detectShop('not a url')).toBeNull()
|
||
})
|
||
})
|
||
```
|
||
|
||
- [ ] **Step 3: Run test, expect failure**
|
||
|
||
```bash
|
||
bun run test
|
||
```
|
||
|
||
Expected: FAIL — `detectShop` is not exported.
|
||
|
||
- [ ] **Step 4: Implement `src/lib/shops.ts`**
|
||
|
||
```typescript
|
||
export type Shop = 'amazon' | 'idealo' | 'geizhals'
|
||
|
||
const PATTERNS: Array<{ shop: Shop; hostMatch: RegExp }> = [
|
||
{ shop: 'amazon', hostMatch: /(^|\.)amazon\.(de|com|co\.uk|fr|it|es|nl)$/i },
|
||
{ shop: 'idealo', hostMatch: /(^|\.)idealo\.(de|com|at|fr|it|es|co\.uk)$/i },
|
||
{ shop: 'geizhals', hostMatch: /(^|\.)geizhals\.(de|at|eu)$/i },
|
||
]
|
||
|
||
export function detectShop(input: string): Shop | null {
|
||
let host: string
|
||
try {
|
||
host = new URL(input).hostname
|
||
} catch {
|
||
return null
|
||
}
|
||
return PATTERNS.find((p) => p.hostMatch.test(host))?.shop ?? null
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: Run test, expect pass**
|
||
|
||
```bash
|
||
bun run test
|
||
```
|
||
|
||
Expected: All tests pass.
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add -A
|
||
git commit -m "feat: shop detection from URL + vitest setup"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 4: Scraper Adapter Framework
|
||
|
||
**Files:**
|
||
- Create: `src/lib/scrapers/types.ts`
|
||
- Create: `src/lib/scrapers/index.ts`
|
||
- Create: `tests/scrapers/registry.test.ts`
|
||
|
||
- [ ] **Step 1: Create `src/lib/scrapers/types.ts`**
|
||
|
||
```typescript
|
||
import type { Shop } from '@/lib/shops'
|
||
|
||
export interface ScrapeResult {
|
||
price: number | null
|
||
currency: string
|
||
availability: 'in_stock' | 'out_of_stock' | 'unknown'
|
||
name?: string
|
||
imageUrl?: string
|
||
error?: string
|
||
}
|
||
|
||
export interface PriceScraper {
|
||
shop: Shop
|
||
scrape(url: string): Promise<ScrapeResult>
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Write failing test `tests/scrapers/registry.test.ts`**
|
||
|
||
```typescript
|
||
import { describe, it, expect, vi } from 'vitest'
|
||
import { scrapeUrl, registerScraperForTest, resetScrapersForTest } from '@/lib/scrapers'
|
||
import type { PriceScraper } from '@/lib/scrapers/types'
|
||
|
||
describe('scrapeUrl', () => {
|
||
it('dispatches to matching shop scraper', async () => {
|
||
const fake: PriceScraper = {
|
||
shop: 'geizhals',
|
||
scrape: vi.fn().mockResolvedValue({
|
||
price: 42, currency: 'EUR', availability: 'in_stock', name: 'X',
|
||
}),
|
||
}
|
||
resetScrapersForTest()
|
||
registerScraperForTest(fake)
|
||
const result = await scrapeUrl('https://geizhals.de/foo')
|
||
expect(result.price).toBe(42)
|
||
expect(fake.scrape).toHaveBeenCalledWith('https://geizhals.de/foo')
|
||
})
|
||
|
||
it('throws on unknown shop', async () => {
|
||
resetScrapersForTest()
|
||
await expect(scrapeUrl('https://example.com')).rejects.toThrow(/unsupported/i)
|
||
})
|
||
})
|
||
```
|
||
|
||
- [ ] **Step 3: Run test, expect failure**
|
||
|
||
```bash
|
||
bun run test tests/scrapers/registry.test.ts
|
||
```
|
||
|
||
Expected: FAIL — module missing.
|
||
|
||
- [ ] **Step 4: Implement `src/lib/scrapers/index.ts`**
|
||
|
||
```typescript
|
||
import { detectShop, type Shop } from '@/lib/shops'
|
||
import type { PriceScraper, ScrapeResult } from './types'
|
||
|
||
const registry = new Map<Shop, PriceScraper>()
|
||
|
||
export function registerScraper(scraper: PriceScraper) {
|
||
registry.set(scraper.shop, scraper)
|
||
}
|
||
|
||
export async function scrapeUrl(url: string): Promise<ScrapeResult> {
|
||
const shop = detectShop(url)
|
||
if (!shop) throw new Error(`Unsupported URL: ${url}`)
|
||
const scraper = registry.get(shop)
|
||
if (!scraper) throw new Error(`No scraper registered for shop: ${shop}`)
|
||
return scraper.scrape(url)
|
||
}
|
||
|
||
export function registerScraperForTest(s: PriceScraper) { registry.set(s.shop, s) }
|
||
export function resetScrapersForTest() { registry.clear() }
|
||
|
||
// Real scrapers register themselves at module init when imported from a server entrypoint.
|
||
// See src/lib/scrapers/{amazon,idealo,geizhals}.ts and src/lib/scrapers/register.ts in Task 8.
|
||
```
|
||
|
||
- [ ] **Step 5: Run test, expect pass**
|
||
|
||
```bash
|
||
bun run test tests/scrapers/registry.test.ts
|
||
```
|
||
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add -A
|
||
git commit -m "feat: scraper registry + adapter interface"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 5: Geizhals Scraper
|
||
|
||
**Files:**
|
||
- Create: `src/lib/scrapers/geizhals.ts`
|
||
- Create: `tests/fixtures/geizhals-gpu.html`
|
||
- Create: `tests/scrapers/geizhals.test.ts`
|
||
|
||
- [ ] **Step 1: Capture fixture HTML**
|
||
|
||
```bash
|
||
curl -sL -A 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36' \
|
||
'https://geizhals.de/sony-playstation-5-pro-a3163879.html' \
|
||
> tests/fixtures/geizhals-gpu.html
|
||
```
|
||
|
||
Verify file is >50KB and contains a price tag:
|
||
|
||
```bash
|
||
grep -oE 'class="[^"]*price[^"]*"' tests/fixtures/geizhals-gpu.html | head -3
|
||
```
|
||
|
||
- [ ] **Step 2: Write failing test `tests/scrapers/geizhals.test.ts`**
|
||
|
||
```typescript
|
||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||
import { readFileSync } from 'node:fs'
|
||
import { join } from 'node:path'
|
||
import { geizhalsScraper } from '@/lib/scrapers/geizhals'
|
||
|
||
const fixture = readFileSync(join(__dirname, '../fixtures/geizhals-gpu.html'), 'utf-8')
|
||
|
||
beforeEach(() => {
|
||
global.fetch = vi.fn().mockResolvedValue({
|
||
ok: true,
|
||
status: 200,
|
||
text: async () => fixture,
|
||
}) as unknown as typeof fetch
|
||
})
|
||
|
||
describe('geizhalsScraper', () => {
|
||
it('extracts price and name', async () => {
|
||
const r = await geizhalsScraper.scrape('https://geizhals.de/test')
|
||
expect(r.price).toBeGreaterThan(0)
|
||
expect(r.currency).toBe('EUR')
|
||
expect(r.name).toBeTruthy()
|
||
})
|
||
|
||
it('returns error on HTTP failure', async () => {
|
||
global.fetch = vi.fn().mockResolvedValue({
|
||
ok: false, status: 503, text: async () => '',
|
||
}) as unknown as typeof fetch
|
||
const r = await geizhalsScraper.scrape('https://geizhals.de/test')
|
||
expect(r.price).toBeNull()
|
||
expect(r.error).toMatch(/HTTP 503/)
|
||
})
|
||
})
|
||
```
|
||
|
||
- [ ] **Step 3: Run test, expect failure**
|
||
|
||
```bash
|
||
bun run test tests/scrapers/geizhals.test.ts
|
||
```
|
||
|
||
Expected: FAIL — module missing.
|
||
|
||
- [ ] **Step 4: Implement `src/lib/scrapers/geizhals.ts`**
|
||
|
||
```typescript
|
||
import * as cheerio from 'cheerio'
|
||
import type { PriceScraper, ScrapeResult } from './types'
|
||
|
||
const UA = 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36'
|
||
|
||
export const geizhalsScraper: PriceScraper = {
|
||
shop: 'geizhals',
|
||
async scrape(url: string): Promise<ScrapeResult> {
|
||
try {
|
||
const res = await fetch(url, {
|
||
headers: { 'User-Agent': UA, 'Accept-Language': 'de-DE,de;q=0.9' },
|
||
signal: AbortSignal.timeout(20_000),
|
||
})
|
||
if (!res.ok) {
|
||
return { price: null, currency: 'EUR', availability: 'unknown', error: `HTTP ${res.status}` }
|
||
}
|
||
const $ = cheerio.load(await res.text())
|
||
|
||
// Try multiple selectors (price layout varies)
|
||
const priceTexts = [
|
||
$('.gh_price').first().text(),
|
||
$('span.gh_price strong').first().text(),
|
||
$('[itemprop="price"]').attr('content'),
|
||
$('meta[itemprop="price"]').attr('content'),
|
||
].filter(Boolean) as string[]
|
||
|
||
const price = parsePrice(priceTexts[0] ?? '')
|
||
const name = ($('h1[itemprop="name"]').text() || $('h1').first().text() || '').trim()
|
||
const imageUrl = $('img.product-gallery__image').first().attr('src')
|
||
|| $('meta[property="og:image"]').attr('content')
|
||
|| undefined
|
||
|
||
if (price === null) {
|
||
return { price: null, currency: 'EUR', availability: 'unknown', name, imageUrl, error: 'price-selector-missed' }
|
||
}
|
||
|
||
return { price, currency: 'EUR', availability: 'in_stock', name, imageUrl }
|
||
} catch (err) {
|
||
return { price: null, currency: 'EUR', availability: 'unknown', error: (err as Error).message }
|
||
}
|
||
},
|
||
}
|
||
|
||
function parsePrice(text: string): number | null {
|
||
// "€ 123,45" or "123,45 €" or "123.45"
|
||
const cleaned = text.replace(/[^\d.,]/g, '').replace(/\.(?=\d{3}(\D|$))/g, '').replace(',', '.')
|
||
if (!cleaned) return null
|
||
const n = parseFloat(cleaned)
|
||
return Number.isFinite(n) && n > 0 ? n : null
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: Run test, expect pass**
|
||
|
||
```bash
|
||
bun run test tests/scrapers/geizhals.test.ts
|
||
```
|
||
|
||
Expected: PASS. If price extraction fails, inspect the fixture, adjust selectors in the scraper.
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add -A
|
||
git commit -m "feat: geizhals scraper with cheerio + tests"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 6: Idealo Scraper
|
||
|
||
**Files:**
|
||
- Create: `src/lib/scrapers/idealo.ts`
|
||
- Create: `tests/fixtures/idealo-headphones.html`
|
||
- Create: `tests/scrapers/idealo.test.ts`
|
||
|
||
- [ ] **Step 1: Capture fixture**
|
||
|
||
```bash
|
||
curl -sL -A 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 Chrome/120.0' \
|
||
'https://www.idealo.de/preisvergleich/OffersOfProduct/202740303_-wh-1000xm5-sony.html' \
|
||
> tests/fixtures/idealo-headphones.html
|
||
ls -lh tests/fixtures/idealo-headphones.html
|
||
```
|
||
|
||
Verify size >20KB. If Cloudflare blocks (file <5KB), capture from a browser dev-tools "Save as HTML" instead.
|
||
|
||
- [ ] **Step 2: Write failing test `tests/scrapers/idealo.test.ts`**
|
||
|
||
```typescript
|
||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||
import { readFileSync } from 'node:fs'
|
||
import { join } from 'node:path'
|
||
import { idealoScraper } from '@/lib/scrapers/idealo'
|
||
|
||
const fixture = readFileSync(join(__dirname, '../fixtures/idealo-headphones.html'), 'utf-8')
|
||
|
||
beforeEach(() => {
|
||
global.fetch = vi.fn().mockResolvedValue({
|
||
ok: true, status: 200, text: async () => fixture,
|
||
}) as unknown as typeof fetch
|
||
})
|
||
|
||
describe('idealoScraper', () => {
|
||
it('extracts price and name', async () => {
|
||
const r = await idealoScraper.scrape('https://www.idealo.de/foo')
|
||
expect(r.price).toBeGreaterThan(0)
|
||
expect(r.currency).toBe('EUR')
|
||
expect(r.name).toBeTruthy()
|
||
})
|
||
|
||
it('flags cloudflare challenge', async () => {
|
||
global.fetch = vi.fn().mockResolvedValue({
|
||
ok: false, status: 403, text: async () => '<html>Cloudflare</html>',
|
||
}) as unknown as typeof fetch
|
||
const r = await idealoScraper.scrape('https://www.idealo.de/foo')
|
||
expect(r.price).toBeNull()
|
||
expect(r.error).toMatch(/403|cloudflare/i)
|
||
})
|
||
})
|
||
```
|
||
|
||
- [ ] **Step 3: Run test, expect failure**
|
||
|
||
```bash
|
||
bun run test tests/scrapers/idealo.test.ts
|
||
```
|
||
|
||
Expected: FAIL — module missing.
|
||
|
||
- [ ] **Step 4: Implement `src/lib/scrapers/idealo.ts`**
|
||
|
||
```typescript
|
||
import * as cheerio from 'cheerio'
|
||
import type { PriceScraper, ScrapeResult } from './types'
|
||
|
||
const UA = 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36'
|
||
|
||
export const idealoScraper: PriceScraper = {
|
||
shop: 'idealo',
|
||
async scrape(url: string): Promise<ScrapeResult> {
|
||
try {
|
||
const res = await fetch(url, {
|
||
headers: {
|
||
'User-Agent': UA,
|
||
'Accept': 'text/html,application/xhtml+xml',
|
||
'Accept-Language': 'de-DE,de;q=0.9',
|
||
},
|
||
signal: AbortSignal.timeout(20_000),
|
||
})
|
||
if (!res.ok) {
|
||
return { price: null, currency: 'EUR', availability: 'unknown', error: `HTTP ${res.status}` }
|
||
}
|
||
const $ = cheerio.load(await res.text())
|
||
|
||
const priceTexts = [
|
||
$('[data-testid="detail-offer-price"]').first().text(),
|
||
$('meta[itemprop="price"]').attr('content'),
|
||
$('span.oopStage-conditionButton-price').first().text(),
|
||
$('strong.oopStage-price').first().text(),
|
||
].filter(Boolean) as string[]
|
||
|
||
const price = parsePrice(priceTexts[0] ?? '')
|
||
const name = ($('h1[data-testid="product-title"]').text() || $('h1').first().text() || '').trim()
|
||
const imageUrl = $('meta[property="og:image"]').attr('content') || undefined
|
||
|
||
if (price === null) {
|
||
return { price: null, currency: 'EUR', availability: 'unknown', name, imageUrl, error: 'price-selector-missed' }
|
||
}
|
||
return { price, currency: 'EUR', availability: 'in_stock', name, imageUrl }
|
||
} catch (err) {
|
||
return { price: null, currency: 'EUR', availability: 'unknown', error: (err as Error).message }
|
||
}
|
||
},
|
||
}
|
||
|
||
function parsePrice(text: string): number | null {
|
||
const cleaned = text.replace(/[^\d.,]/g, '').replace(/\.(?=\d{3}(\D|$))/g, '').replace(',', '.')
|
||
if (!cleaned) return null
|
||
const n = parseFloat(cleaned)
|
||
return Number.isFinite(n) && n > 0 ? n : null
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: Run test, expect pass**
|
||
|
||
```bash
|
||
bun run test tests/scrapers/idealo.test.ts
|
||
```
|
||
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add -A
|
||
git commit -m "feat: idealo scraper"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 7: Amazon Scraper (Playwright)
|
||
|
||
**Files:**
|
||
- Create: `src/lib/scrapers/amazon.ts`
|
||
- Create: `tests/fixtures/amazon-ps5.html`
|
||
- Create: `tests/scrapers/amazon.test.ts`
|
||
|
||
The Amazon scraper extracts via cheerio from rendered HTML; Playwright is only used to load the page (Amazon serves much content via JS). Tests use a saved fixture without running Playwright.
|
||
|
||
- [ ] **Step 1: Capture fixture**
|
||
|
||
```bash
|
||
curl -sL -A 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 Chrome/120.0' \
|
||
-H 'Accept-Language: de-DE,de;q=0.9' \
|
||
'https://www.amazon.de/dp/B0C5BMMJTL' \
|
||
> tests/fixtures/amazon-ps5.html
|
||
ls -lh tests/fixtures/amazon-ps5.html
|
||
```
|
||
|
||
If the file is <30KB it likely shows a robot check page; in that case open the URL in a real browser and "Save Page As → HTML Only" into the fixture file.
|
||
|
||
- [ ] **Step 2: Write failing test `tests/scrapers/amazon.test.ts`**
|
||
|
||
```typescript
|
||
import { describe, it, expect } from 'vitest'
|
||
import { readFileSync } from 'node:fs'
|
||
import { join } from 'node:path'
|
||
import { parseAmazonHtml } from '@/lib/scrapers/amazon'
|
||
|
||
const fixture = readFileSync(join(__dirname, '../fixtures/amazon-ps5.html'), 'utf-8')
|
||
|
||
describe('parseAmazonHtml', () => {
|
||
it('extracts price, name, image', () => {
|
||
const r = parseAmazonHtml(fixture)
|
||
expect(r.price).toBeGreaterThan(0)
|
||
expect(r.currency).toBe('EUR')
|
||
expect(r.name).toBeTruthy()
|
||
expect(r.imageUrl).toMatch(/^https?:\/\//)
|
||
})
|
||
|
||
it('detects captcha page', () => {
|
||
const captchaHtml = '<html><body><form action="/errors/validateCaptcha"></form></body></html>'
|
||
const r = parseAmazonHtml(captchaHtml)
|
||
expect(r.price).toBeNull()
|
||
expect(r.error).toBe('captcha')
|
||
})
|
||
})
|
||
```
|
||
|
||
- [ ] **Step 3: Run test, expect failure**
|
||
|
||
```bash
|
||
bun run test tests/scrapers/amazon.test.ts
|
||
```
|
||
|
||
Expected: FAIL — module missing.
|
||
|
||
- [ ] **Step 4: Implement `src/lib/scrapers/amazon.ts`**
|
||
|
||
```typescript
|
||
import * as cheerio from 'cheerio'
|
||
import type { PriceScraper, ScrapeResult } from './types'
|
||
|
||
export function parseAmazonHtml(html: string): ScrapeResult {
|
||
if (/validateCaptcha|api-services-support@amazon/i.test(html)) {
|
||
return { price: null, currency: 'EUR', availability: 'unknown', error: 'captcha' }
|
||
}
|
||
const $ = cheerio.load(html)
|
||
|
||
const priceTexts = [
|
||
$('#corePrice_feature_div .a-offscreen').first().text(),
|
||
$('#corePriceDisplay_desktop_feature_div .a-offscreen').first().text(),
|
||
$('span.priceToPay .a-offscreen').first().text(),
|
||
$('#priceblock_ourprice').first().text(),
|
||
$('#priceblock_dealprice').first().text(),
|
||
$('.a-price .a-offscreen').first().text(),
|
||
].filter(Boolean) as string[]
|
||
|
||
const price = parsePrice(priceTexts[0] ?? '')
|
||
const name = ($('#productTitle').text() || $('h1#title').text() || '').trim()
|
||
const imageUrl = $('#landingImage').attr('src')
|
||
|| $('#imgBlkFront').attr('src')
|
||
|| $('meta[property="og:image"]').attr('content')
|
||
|| undefined
|
||
|
||
const outOfStock = /derzeit nicht verf|currently unavailable/i.test($('#availability').text())
|
||
const availability = outOfStock ? 'out_of_stock' : (price !== null ? 'in_stock' : 'unknown')
|
||
|
||
if (price === null) {
|
||
return { price: null, currency: 'EUR', availability, name, imageUrl, error: 'price-selector-missed' }
|
||
}
|
||
return { price, currency: 'EUR', availability, name, imageUrl }
|
||
}
|
||
|
||
function parsePrice(text: string): number | null {
|
||
const cleaned = text.replace(/[^\d.,]/g, '').replace(/\.(?=\d{3}(\D|$))/g, '').replace(',', '.')
|
||
if (!cleaned) return null
|
||
const n = parseFloat(cleaned)
|
||
return Number.isFinite(n) && n > 0 ? n : null
|
||
}
|
||
|
||
export const amazonScraper: PriceScraper = {
|
||
shop: 'amazon',
|
||
async scrape(url: string): Promise<ScrapeResult> {
|
||
// Import lazily so test runs don't pull in playwright
|
||
const { chromium } = await import('playwright')
|
||
const browser = await chromium.launch({ args: ['--no-sandbox', '--disable-dev-shm-usage'] })
|
||
try {
|
||
const ctx = await browser.newContext({
|
||
userAgent: 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36',
|
||
locale: 'de-DE',
|
||
extraHTTPHeaders: { 'Accept-Language': 'de-DE,de;q=0.9' },
|
||
})
|
||
const page = await ctx.newPage()
|
||
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30_000 })
|
||
const html = await page.content()
|
||
return parseAmazonHtml(html)
|
||
} catch (err) {
|
||
return { price: null, currency: 'EUR', availability: 'unknown', error: (err as Error).message }
|
||
} finally {
|
||
await browser.close()
|
||
}
|
||
},
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: Run test, expect pass**
|
||
|
||
```bash
|
||
bun run test tests/scrapers/amazon.test.ts
|
||
```
|
||
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 6: Install Playwright Chromium for dev runs**
|
||
|
||
```bash
|
||
bunx playwright install chromium
|
||
```
|
||
|
||
- [ ] **Step 7: Manual smoke test**
|
||
|
||
```bash
|
||
bun run -e "
|
||
import { amazonScraper } from './src/lib/scrapers/amazon.ts'
|
||
const r = await amazonScraper.scrape('https://www.amazon.de/dp/B0C5BMMJTL')
|
||
console.log(r)
|
||
"
|
||
```
|
||
|
||
If you hit captcha, that's expected on shared IPs — daily cron from the Coolify container should be fine. Log it and move on.
|
||
|
||
- [ ] **Step 8: Commit**
|
||
|
||
```bash
|
||
git add -A
|
||
git commit -m "feat: amazon scraper with playwright + html parser tests"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 8: Scraper Registration Entrypoint
|
||
|
||
**Files:**
|
||
- Create: `src/lib/scrapers/register.ts`
|
||
|
||
- [ ] **Step 1: Create `src/lib/scrapers/register.ts`**
|
||
|
||
```typescript
|
||
import { registerScraper } from './index'
|
||
import { amazonScraper } from './amazon'
|
||
import { idealoScraper } from './idealo'
|
||
import { geizhalsScraper } from './geizhals'
|
||
|
||
let registered = false
|
||
|
||
export function ensureScrapersRegistered() {
|
||
if (registered) return
|
||
registerScraper(amazonScraper)
|
||
registerScraper(idealoScraper)
|
||
registerScraper(geizhalsScraper)
|
||
registered = true
|
||
}
|
||
```
|
||
|
||
Every API route that calls `scrapeUrl` MUST call `ensureScrapersRegistered()` at the top.
|
||
|
||
- [ ] **Step 2: Commit**
|
||
|
||
```bash
|
||
git add -A
|
||
git commit -m "feat: scraper registration entrypoint"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 9: Pushover Client
|
||
|
||
**Files:**
|
||
- Create: `src/lib/pushover.ts`
|
||
- Create: `tests/pushover.test.ts`
|
||
|
||
- [ ] **Step 1: Write failing test `tests/pushover.test.ts`**
|
||
|
||
```typescript
|
||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||
import { sendPush } from '@/lib/pushover'
|
||
|
||
beforeEach(() => {
|
||
process.env.PUSHOVER_TOKEN = 'tok'
|
||
process.env.PUSHOVER_USER = 'user'
|
||
global.fetch = vi.fn().mockResolvedValue({ ok: true, status: 200, json: async () => ({ status: 1 }) }) as unknown as typeof fetch
|
||
})
|
||
|
||
describe('sendPush', () => {
|
||
it('posts to pushover with token/user/payload', async () => {
|
||
await sendPush({ title: 'T', message: 'M', url: 'https://x' })
|
||
const fetchMock = global.fetch as unknown as ReturnType<typeof vi.fn>
|
||
const [url, init] = fetchMock.mock.calls[0]
|
||
expect(url).toBe('https://api.pushover.net/1/messages.json')
|
||
const body = init.body as URLSearchParams
|
||
expect(body.get('token')).toBe('tok')
|
||
expect(body.get('user')).toBe('user')
|
||
expect(body.get('title')).toBe('T')
|
||
expect(body.get('message')).toBe('M')
|
||
expect(body.get('url')).toBe('https://x')
|
||
})
|
||
|
||
it('throws if env vars missing', async () => {
|
||
delete process.env.PUSHOVER_TOKEN
|
||
await expect(sendPush({ title: 'T', message: 'M' })).rejects.toThrow(/PUSHOVER/)
|
||
})
|
||
})
|
||
```
|
||
|
||
- [ ] **Step 2: Run test, expect failure**
|
||
|
||
```bash
|
||
bun run test tests/pushover.test.ts
|
||
```
|
||
|
||
Expected: FAIL.
|
||
|
||
- [ ] **Step 3: Implement `src/lib/pushover.ts`**
|
||
|
||
```typescript
|
||
export interface PushOpts {
|
||
title: string
|
||
message: string
|
||
url?: string
|
||
urlTitle?: string
|
||
priority?: -2 | -1 | 0 | 1 | 2
|
||
}
|
||
|
||
export async function sendPush(opts: PushOpts): Promise<void> {
|
||
const token = process.env.PUSHOVER_TOKEN
|
||
const user = process.env.PUSHOVER_USER
|
||
if (!token || !user) throw new Error('PUSHOVER_TOKEN/PUSHOVER_USER not set')
|
||
|
||
const body = new URLSearchParams({
|
||
token,
|
||
user,
|
||
title: opts.title,
|
||
message: opts.message,
|
||
priority: String(opts.priority ?? 0),
|
||
})
|
||
if (opts.url) body.set('url', opts.url)
|
||
if (opts.urlTitle) body.set('url_title', opts.urlTitle)
|
||
|
||
const res = await fetch('https://api.pushover.net/1/messages.json', {
|
||
method: 'POST',
|
||
body,
|
||
signal: AbortSignal.timeout(10_000),
|
||
})
|
||
if (!res.ok) {
|
||
const txt = await res.text().catch(() => '')
|
||
throw new Error(`Pushover ${res.status}: ${txt}`)
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run test, expect pass**
|
||
|
||
```bash
|
||
bun run test tests/pushover.test.ts
|
||
```
|
||
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add -A
|
||
git commit -m "feat: pushover client"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 10: Alert Evaluation Logic
|
||
|
||
**Files:**
|
||
- Create: `src/lib/alerts/evaluate.ts`
|
||
- Create: `tests/alerts/evaluate.test.ts`
|
||
|
||
- [ ] **Step 1: Write failing test `tests/alerts/evaluate.test.ts`**
|
||
|
||
```typescript
|
||
import { describe, it, expect } from 'vitest'
|
||
import { evaluateAlert, type SnapshotInput } from '@/lib/alerts/evaluate'
|
||
|
||
function snap(price: number, daysAgo: number): SnapshotInput {
|
||
const d = new Date()
|
||
d.setDate(d.getDate() - daysAgo)
|
||
return { price, scrapedAt: d }
|
||
}
|
||
|
||
describe('evaluateAlert', () => {
|
||
describe('target_price', () => {
|
||
it('triggers when current price <= threshold', () => {
|
||
const r = evaluateAlert({
|
||
alert: { type: 'target_price', config: { threshold: 100 }, lastTriggeredAt: null },
|
||
currentPrice: 99,
|
||
history: [],
|
||
})
|
||
expect(r.triggered).toBe(true)
|
||
})
|
||
|
||
it('does not trigger when price > threshold', () => {
|
||
const r = evaluateAlert({
|
||
alert: { type: 'target_price', config: { threshold: 100 }, lastTriggeredAt: null },
|
||
currentPrice: 101,
|
||
history: [],
|
||
})
|
||
expect(r.triggered).toBe(false)
|
||
})
|
||
})
|
||
|
||
describe('all_time_low', () => {
|
||
it('triggers when current is below all previous successful prices', () => {
|
||
const r = evaluateAlert({
|
||
alert: { type: 'all_time_low', config: {}, lastTriggeredAt: null },
|
||
currentPrice: 50,
|
||
history: [snap(60, 1), snap(70, 5), snap(80, 10)],
|
||
})
|
||
expect(r.triggered).toBe(true)
|
||
expect(r.context.prevMin).toBe(60)
|
||
})
|
||
|
||
it('does not trigger when current equals previous min', () => {
|
||
const r = evaluateAlert({
|
||
alert: { type: 'all_time_low', config: {}, lastTriggeredAt: null },
|
||
currentPrice: 60,
|
||
history: [snap(60, 1)],
|
||
})
|
||
expect(r.triggered).toBe(false)
|
||
})
|
||
|
||
it('does not trigger with no history (first scrape)', () => {
|
||
const r = evaluateAlert({
|
||
alert: { type: 'all_time_low', config: {}, lastTriggeredAt: null },
|
||
currentPrice: 50,
|
||
history: [],
|
||
})
|
||
expect(r.triggered).toBe(false)
|
||
})
|
||
})
|
||
|
||
describe('percent_drop', () => {
|
||
it('triggers when price drops >= percent vs lookback avg', () => {
|
||
const r = evaluateAlert({
|
||
alert: { type: 'percent_drop', config: { lookback_days: 7, percent: 10 }, lastTriggeredAt: null },
|
||
currentPrice: 80,
|
||
history: [snap(100, 1), snap(100, 3), snap(100, 6), snap(50, 30)],
|
||
})
|
||
expect(r.triggered).toBe(true)
|
||
expect(r.context.percent).toBeGreaterThanOrEqual(10)
|
||
})
|
||
|
||
it('does not trigger when drop is smaller than threshold', () => {
|
||
const r = evaluateAlert({
|
||
alert: { type: 'percent_drop', config: { lookback_days: 7, percent: 10 }, lastTriggeredAt: null },
|
||
currentPrice: 95,
|
||
history: [snap(100, 1), snap(100, 3)],
|
||
})
|
||
expect(r.triggered).toBe(false)
|
||
})
|
||
|
||
it('does not trigger with no history in lookback window', () => {
|
||
const r = evaluateAlert({
|
||
alert: { type: 'percent_drop', config: { lookback_days: 7, percent: 10 }, lastTriggeredAt: null },
|
||
currentPrice: 50,
|
||
history: [snap(100, 30)],
|
||
})
|
||
expect(r.triggered).toBe(false)
|
||
})
|
||
})
|
||
|
||
describe('dedup', () => {
|
||
it('does not trigger if last triggered within 24h', () => {
|
||
const recent = new Date(Date.now() - 1000 * 60 * 60 * 12)
|
||
const r = evaluateAlert({
|
||
alert: { type: 'target_price', config: { threshold: 100 }, lastTriggeredAt: recent },
|
||
currentPrice: 50,
|
||
history: [],
|
||
})
|
||
expect(r.triggered).toBe(false)
|
||
})
|
||
|
||
it('triggers if last triggered > 24h ago', () => {
|
||
const old = new Date(Date.now() - 1000 * 60 * 60 * 25)
|
||
const r = evaluateAlert({
|
||
alert: { type: 'target_price', config: { threshold: 100 }, lastTriggeredAt: old },
|
||
currentPrice: 50,
|
||
history: [],
|
||
})
|
||
expect(r.triggered).toBe(true)
|
||
})
|
||
})
|
||
})
|
||
```
|
||
|
||
- [ ] **Step 2: Run test, expect failure**
|
||
|
||
```bash
|
||
bun run test tests/alerts/evaluate.test.ts
|
||
```
|
||
|
||
Expected: FAIL — module missing.
|
||
|
||
- [ ] **Step 3: Implement `src/lib/alerts/evaluate.ts`**
|
||
|
||
```typescript
|
||
export type AlertType = 'target_price' | 'all_time_low' | 'percent_drop'
|
||
|
||
export interface AlertInput {
|
||
type: AlertType
|
||
config: Record<string, unknown>
|
||
lastTriggeredAt: Date | null
|
||
}
|
||
|
||
export interface SnapshotInput {
|
||
price: number
|
||
scrapedAt: Date
|
||
}
|
||
|
||
export interface EvalInput {
|
||
alert: AlertInput
|
||
currentPrice: number
|
||
history: SnapshotInput[]
|
||
}
|
||
|
||
export interface EvalResult {
|
||
triggered: boolean
|
||
context: Record<string, number | string>
|
||
}
|
||
|
||
const DEDUP_HOURS = 24
|
||
|
||
export function evaluateAlert(input: EvalInput): EvalResult {
|
||
const { alert, currentPrice, history } = input
|
||
|
||
if (alert.lastTriggeredAt) {
|
||
const ageMs = Date.now() - alert.lastTriggeredAt.getTime()
|
||
if (ageMs < DEDUP_HOURS * 60 * 60 * 1000) {
|
||
return { triggered: false, context: { reason: 'dedup-cooldown' } }
|
||
}
|
||
}
|
||
|
||
switch (alert.type) {
|
||
case 'target_price': {
|
||
const threshold = Number(alert.config.threshold)
|
||
if (!Number.isFinite(threshold)) return { triggered: false, context: { reason: 'bad-config' } }
|
||
return { triggered: currentPrice <= threshold, context: { threshold, currentPrice } }
|
||
}
|
||
case 'all_time_low': {
|
||
if (history.length === 0) return { triggered: false, context: { reason: 'no-history' } }
|
||
const prevMin = Math.min(...history.map((s) => s.price))
|
||
return { triggered: currentPrice < prevMin, context: { prevMin, currentPrice } }
|
||
}
|
||
case 'percent_drop': {
|
||
const lookbackDays = Number(alert.config.lookback_days)
|
||
const percent = Number(alert.config.percent)
|
||
if (!Number.isFinite(lookbackDays) || !Number.isFinite(percent)) {
|
||
return { triggered: false, context: { reason: 'bad-config' } }
|
||
}
|
||
const cutoff = Date.now() - lookbackDays * 86400_000
|
||
const window = history.filter((s) => s.scrapedAt.getTime() >= cutoff)
|
||
if (window.length === 0) return { triggered: false, context: { reason: 'no-history-in-window' } }
|
||
const avg = window.reduce((s, x) => s + x.price, 0) / window.length
|
||
const dropPct = ((avg - currentPrice) / avg) * 100
|
||
return { triggered: dropPct >= percent, context: { avg, percent: Number(dropPct.toFixed(2)), currentPrice } }
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run test, expect pass**
|
||
|
||
```bash
|
||
bun run test tests/alerts/evaluate.test.ts
|
||
```
|
||
|
||
Expected: PASS — all cases green.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add -A
|
||
git commit -m "feat: alert evaluation logic (target/atl/drop) with dedup"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 11: Zitadel Auth (PKCE + iron-session)
|
||
|
||
**Files:**
|
||
- Create: `src/lib/auth/pkce.ts`
|
||
- Create: `src/lib/auth/session.ts`
|
||
- Create: `src/lib/auth/zitadel.ts`
|
||
- Create: `src/app/api/auth/login/route.ts`
|
||
- Create: `src/app/api/auth/callback/route.ts`
|
||
- Create: `src/app/api/auth/logout/route.ts`
|
||
- Create: `src/middleware.ts`
|
||
|
||
**Before coding:** Read `node_modules/next/dist/docs/` for Next.js 16 middleware + route-handler API. Confirm: `cookies()` async, `NextRequest.headers`, `Response.redirect` patterns.
|
||
|
||
- [ ] **Step 1: Create `src/lib/auth/pkce.ts`**
|
||
|
||
```typescript
|
||
import { randomBytes, createHash } from 'node:crypto'
|
||
|
||
export function generateVerifier(): string {
|
||
return randomBytes(64).toString('base64url')
|
||
}
|
||
|
||
export function challengeFromVerifier(verifier: string): string {
|
||
return createHash('sha256').update(verifier).digest('base64url')
|
||
}
|
||
|
||
export function generateState(): string {
|
||
return randomBytes(32).toString('base64url')
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Create `src/lib/auth/session.ts`**
|
||
|
||
```typescript
|
||
import type { SessionOptions } from 'iron-session'
|
||
import { getIronSession } from 'iron-session'
|
||
import { cookies } from 'next/headers'
|
||
|
||
export interface SessionData {
|
||
userId?: string
|
||
email?: string
|
||
name?: string
|
||
loginInProgress?: { state: string; codeVerifier: string }
|
||
}
|
||
|
||
const opts: SessionOptions = {
|
||
password: process.env.SESSION_PASSWORD || '',
|
||
cookieName: 'preis_tracker_session',
|
||
cookieOptions: {
|
||
httpOnly: true,
|
||
sameSite: 'lax',
|
||
secure: process.env.NODE_ENV === 'production',
|
||
maxAge: 7 * 24 * 60 * 60,
|
||
},
|
||
}
|
||
|
||
export async function getSession() {
|
||
if (!opts.password || opts.password.length < 32) {
|
||
throw new Error('SESSION_PASSWORD must be set to a 32+ char value')
|
||
}
|
||
const c = await cookies()
|
||
return getIronSession<SessionData>(c, opts)
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Create `src/lib/auth/zitadel.ts`**
|
||
|
||
```typescript
|
||
import { createRemoteJWKSet, jwtVerify } from 'jose'
|
||
|
||
const issuer = process.env.ZITADEL_ISSUER!
|
||
const clientId = process.env.ZITADEL_CLIENT_ID!
|
||
|
||
let jwksCache: ReturnType<typeof createRemoteJWKSet> | null = null
|
||
function getJWKS() {
|
||
if (!jwksCache) {
|
||
jwksCache = createRemoteJWKSet(new URL(`${issuer}/oauth/v2/keys`))
|
||
}
|
||
return jwksCache
|
||
}
|
||
|
||
export interface TokenSet {
|
||
access_token: string
|
||
id_token: string
|
||
refresh_token?: string
|
||
expires_in: number
|
||
token_type: string
|
||
}
|
||
|
||
export async function exchangeCode(args: {
|
||
code: string
|
||
codeVerifier: string
|
||
redirectUri: string
|
||
}): Promise<TokenSet> {
|
||
const body = new URLSearchParams({
|
||
grant_type: 'authorization_code',
|
||
client_id: clientId,
|
||
code: args.code,
|
||
code_verifier: args.codeVerifier,
|
||
redirect_uri: args.redirectUri,
|
||
})
|
||
const res = await fetch(`${issuer}/oauth/v2/token`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||
body,
|
||
})
|
||
if (!res.ok) {
|
||
throw new Error(`Zitadel token exchange failed: ${res.status} ${await res.text()}`)
|
||
}
|
||
return res.json()
|
||
}
|
||
|
||
export interface IdTokenClaims {
|
||
sub: string
|
||
email?: string
|
||
name?: string
|
||
iss: string
|
||
aud: string | string[]
|
||
exp: number
|
||
}
|
||
|
||
export async function verifyIdToken(idToken: string): Promise<IdTokenClaims> {
|
||
const { payload } = await jwtVerify(idToken, getJWKS(), {
|
||
issuer,
|
||
audience: clientId,
|
||
})
|
||
return payload as unknown as IdTokenClaims
|
||
}
|
||
|
||
export function buildAuthorizeUrl(args: {
|
||
state: string
|
||
codeChallenge: string
|
||
redirectUri: string
|
||
}): string {
|
||
const params = new URLSearchParams({
|
||
response_type: 'code',
|
||
client_id: clientId,
|
||
scope: 'openid email profile',
|
||
redirect_uri: args.redirectUri,
|
||
state: args.state,
|
||
code_challenge: args.codeChallenge,
|
||
code_challenge_method: 'S256',
|
||
})
|
||
return `${issuer}/oauth/v2/authorize?${params.toString()}`
|
||
}
|
||
|
||
export function buildEndSessionUrl(redirectTo: string): string {
|
||
const params = new URLSearchParams({ post_logout_redirect_uri: redirectTo })
|
||
return `${issuer}/oidc/v1/end_session?${params.toString()}`
|
||
}
|
||
|
||
export function isAllowedUser(sub: string): boolean {
|
||
const allowed = (process.env.ALLOWED_USER_IDS || '').split(',').map((s) => s.trim()).filter(Boolean)
|
||
return allowed.includes(sub)
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Create `src/app/api/auth/login/route.ts`**
|
||
|
||
```typescript
|
||
import { NextResponse } from 'next/server'
|
||
import { getSession } from '@/lib/auth/session'
|
||
import { generateVerifier, challengeFromVerifier, generateState } from '@/lib/auth/pkce'
|
||
import { buildAuthorizeUrl } from '@/lib/auth/zitadel'
|
||
|
||
export async function GET() {
|
||
const verifier = generateVerifier()
|
||
const challenge = challengeFromVerifier(verifier)
|
||
const state = generateState()
|
||
const redirectUri = `${process.env.NEXT_PUBLIC_BASE_URL}/api/auth/callback`
|
||
|
||
const session = await getSession()
|
||
session.loginInProgress = { state, codeVerifier: verifier }
|
||
await session.save()
|
||
|
||
const url = buildAuthorizeUrl({ state, codeChallenge: challenge, redirectUri })
|
||
return NextResponse.redirect(url)
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: Create `src/app/api/auth/callback/route.ts`**
|
||
|
||
```typescript
|
||
import { NextRequest, NextResponse } from 'next/server'
|
||
import { getSession } from '@/lib/auth/session'
|
||
import { exchangeCode, verifyIdToken, isAllowedUser } from '@/lib/auth/zitadel'
|
||
|
||
export async function GET(req: NextRequest) {
|
||
const url = new URL(req.url)
|
||
const code = url.searchParams.get('code')
|
||
const state = url.searchParams.get('state')
|
||
if (!code || !state) return NextResponse.json({ error: 'missing code/state' }, { status: 400 })
|
||
|
||
const session = await getSession()
|
||
const pending = session.loginInProgress
|
||
if (!pending || pending.state !== state) {
|
||
return NextResponse.json({ error: 'state mismatch' }, { status: 400 })
|
||
}
|
||
|
||
const redirectUri = `${process.env.NEXT_PUBLIC_BASE_URL}/api/auth/callback`
|
||
const tokens = await exchangeCode({ code, codeVerifier: pending.codeVerifier, redirectUri })
|
||
const claims = await verifyIdToken(tokens.id_token)
|
||
|
||
if (!isAllowedUser(claims.sub)) {
|
||
session.destroy()
|
||
return NextResponse.json({ error: 'user not allowed' }, { status: 403 })
|
||
}
|
||
|
||
session.userId = claims.sub
|
||
session.email = claims.email
|
||
session.name = claims.name
|
||
delete session.loginInProgress
|
||
await session.save()
|
||
|
||
return NextResponse.redirect(new URL('/', req.url))
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 6: Create `src/app/api/auth/logout/route.ts`**
|
||
|
||
```typescript
|
||
import { NextResponse } from 'next/server'
|
||
import { getSession } from '@/lib/auth/session'
|
||
import { buildEndSessionUrl } from '@/lib/auth/zitadel'
|
||
|
||
export async function GET() {
|
||
const session = await getSession()
|
||
session.destroy()
|
||
return NextResponse.redirect(buildEndSessionUrl(`${process.env.NEXT_PUBLIC_BASE_URL}/`))
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 7: Create `src/middleware.ts`**
|
||
|
||
```typescript
|
||
import { NextRequest, NextResponse } from 'next/server'
|
||
import { getIronSession } from 'iron-session'
|
||
import type { SessionData } from '@/lib/auth/session'
|
||
|
||
const PUBLIC_PREFIXES = ['/api/auth/', '/api/cron/', '/_next/', '/favicon']
|
||
|
||
export async function middleware(req: NextRequest) {
|
||
const { pathname } = req.nextUrl
|
||
if (PUBLIC_PREFIXES.some((p) => pathname.startsWith(p))) return NextResponse.next()
|
||
|
||
const res = NextResponse.next()
|
||
const session = await getIronSession<SessionData>(req.cookies, res.cookies, {
|
||
password: process.env.SESSION_PASSWORD || '',
|
||
cookieName: 'preis_tracker_session',
|
||
})
|
||
|
||
if (!session.userId) {
|
||
if (pathname.startsWith('/api/')) {
|
||
return NextResponse.json({ error: 'unauthenticated' }, { status: 401 })
|
||
}
|
||
return NextResponse.redirect(new URL('/api/auth/login', req.url))
|
||
}
|
||
return res
|
||
}
|
||
|
||
export const config = {
|
||
matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 8: Zitadel app setup (manual, one-time)**
|
||
|
||
In Zitadel UI (`https://auth.kuns.dev`):
|
||
1. Project → Applications → New → Web → Name `preis-tracker`
|
||
2. Auth Method: `PKCE`
|
||
3. Redirect URIs: `https://preis.kuns.dev/api/auth/callback`, `http://localhost:3000/api/auth/callback`
|
||
4. Post Logout URIs: `https://preis.kuns.dev/`, `http://localhost:3000/`
|
||
5. Copy `Client ID` → put into `.env` as `ZITADEL_CLIENT_ID`
|
||
6. Get your user-sub: User Profile → ID → put into `.env` as `ALLOWED_USER_IDS`
|
||
|
||
Generate session password:
|
||
|
||
```bash
|
||
openssl rand -base64 48 | head -c 48
|
||
```
|
||
|
||
Put into `.env` as `SESSION_PASSWORD`.
|
||
|
||
- [ ] **Step 9: Smoke test login flow locally**
|
||
|
||
```bash
|
||
bun run dev &
|
||
sleep 5
|
||
# Open browser: http://localhost:3000
|
||
# Expect redirect to Zitadel → login → back to /
|
||
# Verify cookie `preis_tracker_session` is set
|
||
kill %1
|
||
```
|
||
|
||
- [ ] **Step 10: Commit**
|
||
|
||
```bash
|
||
git add -A
|
||
git commit -m "feat: zitadel oidc auth with pkce, iron-session, middleware"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 12: Products API
|
||
|
||
**Files:**
|
||
- Create: `src/app/api/products/route.ts`
|
||
- Create: `src/app/api/products/[id]/route.ts`
|
||
- Create: `src/app/api/products/[id]/scrape/route.ts`
|
||
|
||
- [ ] **Step 1: Create `src/app/api/products/route.ts`**
|
||
|
||
```typescript
|
||
import { NextRequest, NextResponse } from 'next/server'
|
||
import { z } from 'zod'
|
||
import { eq, desc, sql } from 'drizzle-orm'
|
||
import { db, products, priceSnapshots } from '@/lib/db'
|
||
import { detectShop } from '@/lib/shops'
|
||
import { scrapeUrl } from '@/lib/scrapers'
|
||
import { ensureScrapersRegistered } from '@/lib/scrapers/register'
|
||
|
||
const AddSchema = z.object({ url: z.string().url() })
|
||
|
||
export async function GET() {
|
||
// List with latest snapshot
|
||
const rows = await db.execute<{
|
||
id: string; url: string; shop: string; name: string; image_url: string | null;
|
||
last_price: string | null; last_scraped_at: Date | null; min_price: string | null;
|
||
}>(sql`
|
||
select p.id, p.url, p.shop, p.name, p.image_url, p.last_scraped_at,
|
||
(select price from price_snapshots s where s.product_id = p.id and s.price is not null order by scraped_at desc limit 1) as last_price,
|
||
(select min(price) from price_snapshots s where s.product_id = p.id and s.price is not null) as min_price
|
||
from products p
|
||
where p.enabled = true
|
||
order by p.created_at desc
|
||
`)
|
||
return NextResponse.json(rows)
|
||
}
|
||
|
||
export async function POST(req: NextRequest) {
|
||
ensureScrapersRegistered()
|
||
const body = await req.json()
|
||
const parsed = AddSchema.safeParse(body)
|
||
if (!parsed.success) return NextResponse.json({ error: 'invalid url' }, { status: 400 })
|
||
|
||
const shop = detectShop(parsed.data.url)
|
||
if (!shop) return NextResponse.json({ error: 'unsupported shop' }, { status: 400 })
|
||
|
||
// Initial scrape (use minimal name/image to insert, then update)
|
||
const result = await scrapeUrl(parsed.data.url)
|
||
const name = result.name || parsed.data.url
|
||
|
||
const [inserted] = await db.insert(products).values({
|
||
url: parsed.data.url,
|
||
shop,
|
||
name,
|
||
imageUrl: result.imageUrl ?? null,
|
||
}).returning()
|
||
|
||
await db.insert(priceSnapshots).values({
|
||
productId: inserted.id,
|
||
price: result.price !== null ? String(result.price) : null,
|
||
currency: result.currency,
|
||
availability: result.availability,
|
||
error: result.error ?? null,
|
||
})
|
||
|
||
await db.update(products).set({
|
||
lastScrapedAt: new Date(),
|
||
consecutiveFailures: result.price === null ? 1 : 0,
|
||
}).where(eq(products.id, inserted.id))
|
||
|
||
return NextResponse.json({ id: inserted.id }, { status: 201 })
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Create `src/app/api/products/[id]/route.ts`**
|
||
|
||
```typescript
|
||
import { NextRequest, NextResponse } from 'next/server'
|
||
import { eq, desc } from 'drizzle-orm'
|
||
import { db, products, priceSnapshots, alerts } from '@/lib/db'
|
||
|
||
export async function GET(_req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||
const { id } = await params
|
||
const [product] = await db.select().from(products).where(eq(products.id, id))
|
||
if (!product) return NextResponse.json({ error: 'not found' }, { status: 404 })
|
||
|
||
const snapshots = await db.select().from(priceSnapshots)
|
||
.where(eq(priceSnapshots.productId, id))
|
||
.orderBy(desc(priceSnapshots.scrapedAt))
|
||
.limit(1000)
|
||
const productAlerts = await db.select().from(alerts).where(eq(alerts.productId, id))
|
||
|
||
return NextResponse.json({ product, snapshots, alerts: productAlerts })
|
||
}
|
||
|
||
export async function DELETE(_req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||
const { id } = await params
|
||
await db.delete(products).where(eq(products.id, id))
|
||
return NextResponse.json({ ok: true })
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Create `src/app/api/products/[id]/scrape/route.ts`**
|
||
|
||
```typescript
|
||
import { NextRequest, NextResponse } from 'next/server'
|
||
import { eq } from 'drizzle-orm'
|
||
import { db, products, priceSnapshots } from '@/lib/db'
|
||
import { scrapeUrl } from '@/lib/scrapers'
|
||
import { ensureScrapersRegistered } from '@/lib/scrapers/register'
|
||
|
||
export async function POST(_req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||
ensureScrapersRegistered()
|
||
const { id } = await params
|
||
const [product] = await db.select().from(products).where(eq(products.id, id))
|
||
if (!product) return NextResponse.json({ error: 'not found' }, { status: 404 })
|
||
|
||
const result = await scrapeUrl(product.url)
|
||
await db.insert(priceSnapshots).values({
|
||
productId: id,
|
||
price: result.price !== null ? String(result.price) : null,
|
||
currency: result.currency,
|
||
availability: result.availability,
|
||
error: result.error ?? null,
|
||
})
|
||
await db.update(products).set({
|
||
lastScrapedAt: new Date(),
|
||
consecutiveFailures: result.price === null ? product.consecutiveFailures + 1 : 0,
|
||
}).where(eq(products.id, id))
|
||
|
||
return NextResponse.json(result)
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Manual smoke test**
|
||
|
||
```bash
|
||
bun run dev &
|
||
sleep 5
|
||
# In another shell — get a session cookie first by visiting localhost:3000 in browser and copying it
|
||
COOKIE='preis_tracker_session=...'
|
||
curl -s -X POST -H "Cookie: $COOKIE" -H 'Content-Type: application/json' \
|
||
-d '{"url":"https://geizhals.de/sony-playstation-5-pro-a3163879.html"}' \
|
||
http://localhost:3000/api/products
|
||
curl -s -H "Cookie: $COOKIE" http://localhost:3000/api/products | head -c 500
|
||
kill %1
|
||
```
|
||
|
||
Expected: `{"id":"..."}` then list contains the entry.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add -A
|
||
git commit -m "feat: products api (list/add/detail/delete/manual-scrape)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 13: Alerts API
|
||
|
||
**Files:**
|
||
- Create: `src/app/api/alerts/route.ts`
|
||
- Create: `src/app/api/alerts/[id]/route.ts`
|
||
|
||
- [ ] **Step 1: Create `src/app/api/alerts/route.ts`**
|
||
|
||
```typescript
|
||
import { NextRequest, NextResponse } from 'next/server'
|
||
import { z } from 'zod'
|
||
import { db, alerts } from '@/lib/db'
|
||
|
||
const CreateSchema = z.object({
|
||
productId: z.string().uuid(),
|
||
type: z.enum(['target_price', 'all_time_low', 'percent_drop']),
|
||
config: z.record(z.string(), z.unknown()),
|
||
})
|
||
|
||
export async function POST(req: NextRequest) {
|
||
const body = await req.json()
|
||
const parsed = CreateSchema.safeParse(body)
|
||
if (!parsed.success) return NextResponse.json({ error: parsed.error.message }, { status: 400 })
|
||
|
||
// Per-type config validation
|
||
const cfg = parsed.data.config
|
||
switch (parsed.data.type) {
|
||
case 'target_price':
|
||
if (typeof cfg.threshold !== 'number' || cfg.threshold <= 0) {
|
||
return NextResponse.json({ error: 'threshold (number > 0) required' }, { status: 400 })
|
||
}
|
||
break
|
||
case 'percent_drop':
|
||
if (typeof cfg.lookback_days !== 'number' || typeof cfg.percent !== 'number') {
|
||
return NextResponse.json({ error: 'lookback_days + percent required' }, { status: 400 })
|
||
}
|
||
break
|
||
case 'all_time_low':
|
||
break
|
||
}
|
||
|
||
const [inserted] = await db.insert(alerts).values({
|
||
productId: parsed.data.productId,
|
||
type: parsed.data.type,
|
||
config: parsed.data.config,
|
||
}).returning()
|
||
|
||
return NextResponse.json(inserted, { status: 201 })
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Create `src/app/api/alerts/[id]/route.ts`**
|
||
|
||
```typescript
|
||
import { NextRequest, NextResponse } from 'next/server'
|
||
import { eq } from 'drizzle-orm'
|
||
import { db, alerts } from '@/lib/db'
|
||
|
||
export async function DELETE(_req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||
const { id } = await params
|
||
await db.delete(alerts).where(eq(alerts.id, id))
|
||
return NextResponse.json({ ok: true })
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add -A
|
||
git commit -m "feat: alerts api (create/delete)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 14: Cron Scrape Endpoint
|
||
|
||
**Files:**
|
||
- Create: `src/app/api/cron/scrape/route.ts`
|
||
|
||
- [ ] **Step 1: Create `src/app/api/cron/scrape/route.ts`**
|
||
|
||
```typescript
|
||
import { NextRequest, NextResponse } from 'next/server'
|
||
import { eq, and } from 'drizzle-orm'
|
||
import { db, products, priceSnapshots, alerts } from '@/lib/db'
|
||
import { scrapeUrl } from '@/lib/scrapers'
|
||
import { ensureScrapersRegistered } from '@/lib/scrapers/register'
|
||
import { evaluateAlert, type SnapshotInput } from '@/lib/alerts/evaluate'
|
||
import { sendPush } from '@/lib/pushover'
|
||
|
||
export const maxDuration = 300 // 5 min
|
||
|
||
const CONCURRENCY = 2
|
||
const FAIL_WARN_THRESHOLD = 3
|
||
|
||
export async function POST(req: NextRequest) {
|
||
const auth = req.headers.get('authorization')
|
||
if (auth !== `Bearer ${process.env.CRON_SECRET}`) {
|
||
return NextResponse.json({ error: 'unauthorized' }, { status: 401 })
|
||
}
|
||
ensureScrapersRegistered()
|
||
|
||
const productsToScrape = await db.select().from(products).where(eq(products.enabled, true))
|
||
console.log(`[cron] scraping ${productsToScrape.length} products`)
|
||
|
||
// Run with concurrency limit
|
||
const queue = [...productsToScrape]
|
||
const summary = { ok: 0, failed: 0, alertsTriggered: 0 }
|
||
|
||
async function worker() {
|
||
while (queue.length > 0) {
|
||
const p = queue.shift()
|
||
if (!p) break
|
||
try {
|
||
await processProduct(p, summary)
|
||
} catch (err) {
|
||
console.error(`[cron] product ${p.id} crashed`, err)
|
||
}
|
||
}
|
||
}
|
||
await Promise.all(Array.from({ length: CONCURRENCY }, () => worker()))
|
||
|
||
return NextResponse.json(summary)
|
||
}
|
||
|
||
async function processProduct(p: typeof products.$inferSelect, summary: { ok: number; failed: number; alertsTriggered: number }) {
|
||
const result = await scrapeUrl(p.url)
|
||
await db.insert(priceSnapshots).values({
|
||
productId: p.id,
|
||
price: result.price !== null ? String(result.price) : null,
|
||
currency: result.currency,
|
||
availability: result.availability,
|
||
error: result.error ?? null,
|
||
})
|
||
|
||
if (result.price === null) {
|
||
const failures = p.consecutiveFailures + 1
|
||
await db.update(products).set({
|
||
lastScrapedAt: new Date(),
|
||
consecutiveFailures: failures,
|
||
}).where(eq(products.id, p.id))
|
||
summary.failed++
|
||
if (failures === FAIL_WARN_THRESHOLD) {
|
||
await sendPush({
|
||
title: `⚠ Scrape-Fehler`,
|
||
message: `${p.name} ist ${failures}× hintereinander fehlgeschlagen: ${result.error ?? 'unknown'}`,
|
||
url: p.url,
|
||
}).catch((e) => console.error('pushover failed', e))
|
||
}
|
||
return
|
||
}
|
||
|
||
summary.ok++
|
||
await db.update(products).set({ lastScrapedAt: new Date(), consecutiveFailures: 0 }).where(eq(products.id, p.id))
|
||
|
||
// Load history (price snapshots only) and product alerts
|
||
const allSnapshots = await db.select().from(priceSnapshots)
|
||
.where(and(eq(priceSnapshots.productId, p.id)))
|
||
const history: SnapshotInput[] = allSnapshots
|
||
.filter((s) => s.price !== null)
|
||
.filter((s) => s.id !== Math.max(...allSnapshots.map((x) => x.id))) // exclude the just-inserted one
|
||
.map((s) => ({ price: Number(s.price), scrapedAt: s.scrapedAt }))
|
||
|
||
const productAlerts = await db.select().from(alerts)
|
||
.where(and(eq(alerts.productId, p.id), eq(alerts.enabled, true)))
|
||
|
||
for (const alert of productAlerts) {
|
||
const r = evaluateAlert({
|
||
alert: {
|
||
type: alert.type as 'target_price' | 'all_time_low' | 'percent_drop',
|
||
config: alert.config as Record<string, unknown>,
|
||
lastTriggeredAt: alert.lastTriggeredAt,
|
||
},
|
||
currentPrice: result.price,
|
||
history,
|
||
})
|
||
if (r.triggered) {
|
||
summary.alertsTriggered++
|
||
await sendPush({
|
||
title: alertTitle(alert.type as string, r.context, p.name),
|
||
message: alertMessage(alert.type as string, r.context, result.price),
|
||
url: p.url,
|
||
urlTitle: 'Zum Shop',
|
||
}).catch((e) => console.error('pushover failed', e))
|
||
await db.update(alerts).set({ lastTriggeredAt: new Date() }).where(eq(alerts.id, alert.id))
|
||
}
|
||
}
|
||
}
|
||
|
||
function alertTitle(type: string, ctx: Record<string, number | string>, name: string): string {
|
||
switch (type) {
|
||
case 'target_price': return `📉 ${name} unter ${ctx.threshold}€`
|
||
case 'all_time_low': return `🎯 Allzeit-Tief: ${name}`
|
||
case 'percent_drop': return `⬇️ ${name} −${ctx.percent}%`
|
||
default: return name
|
||
}
|
||
}
|
||
|
||
function alertMessage(type: string, ctx: Record<string, number | string>, price: number): string {
|
||
switch (type) {
|
||
case 'target_price': return `Jetzt ${price}€ (Ziel: ${ctx.threshold}€)`
|
||
case 'all_time_low': return `Jetzt ${price}€ (vorher min: ${ctx.prevMin}€)`
|
||
case 'percent_drop': return `Jetzt ${price}€ (vorher Ø ${ctx.avg}€ in ${ctx.percent ?? '?'}% Drop)`
|
||
default: return `Jetzt ${price}€`
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Manual smoke test**
|
||
|
||
```bash
|
||
bun run dev &
|
||
sleep 5
|
||
curl -s -X POST -H "Authorization: Bearer $(grep CRON_SECRET .env | cut -d= -f2)" \
|
||
http://localhost:3000/api/cron/scrape
|
||
kill %1
|
||
```
|
||
|
||
Expected: JSON `{ok, failed, alertsTriggered}`. New snapshots in DB.
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add -A
|
||
git commit -m "feat: daily cron scrape endpoint with concurrency + alert dispatch"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 15: UI — Dashboard
|
||
|
||
**Files:**
|
||
- Create: `src/components/Sparkline.tsx`
|
||
- Create: `src/components/ProductCard.tsx`
|
||
- Modify: `src/app/page.tsx`
|
||
|
||
- [ ] **Step 1: Create `src/components/Sparkline.tsx`**
|
||
|
||
```tsx
|
||
'use client'
|
||
import { LineChart, Line, ResponsiveContainer, YAxis } from 'recharts'
|
||
|
||
export function Sparkline({ data }: { data: Array<{ price: number; t: string }> }) {
|
||
if (data.length === 0) return <div className="h-10 text-xs text-zinc-500">keine Daten</div>
|
||
return (
|
||
<ResponsiveContainer width="100%" height={40}>
|
||
<LineChart data={data}>
|
||
<YAxis hide domain={['dataMin', 'dataMax']} />
|
||
<Line type="monotone" dataKey="price" stroke="#22c55e" strokeWidth={2} dot={false} isAnimationActive={false} />
|
||
</LineChart>
|
||
</ResponsiveContainer>
|
||
)
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Create `src/components/ProductCard.tsx`**
|
||
|
||
```tsx
|
||
import Link from 'next/link'
|
||
import { Sparkline } from './Sparkline'
|
||
|
||
interface Props {
|
||
id: string
|
||
name: string
|
||
shop: string
|
||
imageUrl: string | null
|
||
lastPrice: string | null
|
||
minPrice: string | null
|
||
sparkline: Array<{ price: number; t: string }>
|
||
}
|
||
|
||
export function ProductCard(p: Props) {
|
||
const last = p.lastPrice ? Number(p.lastPrice) : null
|
||
const min = p.minPrice ? Number(p.minPrice) : null
|
||
const deltaFromMin = last !== null && min !== null ? (last - min).toFixed(2) : null
|
||
|
||
return (
|
||
<Link href={`/products/${p.id}`} className="block rounded-lg border border-zinc-800 bg-zinc-900 p-4 hover:border-zinc-700 transition">
|
||
<div className="flex gap-3">
|
||
{p.imageUrl && <img src={p.imageUrl} alt="" className="h-16 w-16 object-contain rounded bg-white" />}
|
||
<div className="flex-1 min-w-0">
|
||
<div className="text-xs uppercase tracking-wide text-zinc-500">{p.shop}</div>
|
||
<div className="truncate text-sm font-medium">{p.name}</div>
|
||
<div className="mt-1 flex items-baseline gap-2">
|
||
<span className="text-lg font-semibold">{last !== null ? `${last.toFixed(2)} €` : '—'}</span>
|
||
{deltaFromMin !== null && (
|
||
<span className="text-xs text-zinc-400">+{deltaFromMin} € vom Tief</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div className="mt-2"><Sparkline data={p.sparkline} /></div>
|
||
</Link>
|
||
)
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Replace `src/app/page.tsx`**
|
||
|
||
```tsx
|
||
import Link from 'next/link'
|
||
import { desc, eq, sql } from 'drizzle-orm'
|
||
import { db, products, priceSnapshots } from '@/lib/db'
|
||
import { ProductCard } from '@/components/ProductCard'
|
||
|
||
export const dynamic = 'force-dynamic'
|
||
|
||
export default async function Home() {
|
||
const rows = await db.execute<{
|
||
id: string; url: string; shop: string; name: string; image_url: string | null;
|
||
last_price: string | null; min_price: string | null;
|
||
}>(sql`
|
||
select p.id, p.url, p.shop, p.name, p.image_url,
|
||
(select price from price_snapshots s where s.product_id = p.id and s.price is not null order by scraped_at desc limit 1) as last_price,
|
||
(select min(price) from price_snapshots s where s.product_id = p.id and s.price is not null) as min_price
|
||
from products p where p.enabled = true order by p.created_at desc
|
||
`)
|
||
|
||
const sparklines = new Map<string, Array<{ price: number; t: string }>>()
|
||
for (const r of rows) {
|
||
const snaps = await db.select({ price: priceSnapshots.price, scrapedAt: priceSnapshots.scrapedAt })
|
||
.from(priceSnapshots)
|
||
.where(eq(priceSnapshots.productId, r.id))
|
||
.orderBy(desc(priceSnapshots.scrapedAt))
|
||
.limit(30)
|
||
sparklines.set(r.id, snaps
|
||
.filter((s) => s.price !== null)
|
||
.reverse()
|
||
.map((s) => ({ price: Number(s.price), t: s.scrapedAt.toISOString() })))
|
||
}
|
||
|
||
return (
|
||
<main className="mx-auto max-w-5xl p-6">
|
||
<header className="mb-6 flex items-center justify-between">
|
||
<h1 className="text-2xl font-bold">Preis-Tracker</h1>
|
||
<div className="flex gap-2">
|
||
<Link href="/add" className="rounded bg-emerald-600 px-3 py-1.5 text-sm font-medium hover:bg-emerald-500">+ Produkt</Link>
|
||
<Link href="/api/auth/logout" className="rounded bg-zinc-800 px-3 py-1.5 text-sm hover:bg-zinc-700">Logout</Link>
|
||
</div>
|
||
</header>
|
||
{rows.length === 0 ? (
|
||
<div className="rounded border border-dashed border-zinc-700 p-12 text-center text-zinc-400">
|
||
Noch keine Produkte. <Link href="/add" className="text-emerald-400 underline">Erstes hinzufügen</Link>.
|
||
</div>
|
||
) : (
|
||
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 lg:grid-cols-3">
|
||
{rows.map((r) => (
|
||
<ProductCard
|
||
key={r.id}
|
||
id={r.id}
|
||
name={r.name}
|
||
shop={r.shop}
|
||
imageUrl={r.image_url}
|
||
lastPrice={r.last_price}
|
||
minPrice={r.min_price}
|
||
sparkline={sparklines.get(r.id) ?? []}
|
||
/>
|
||
))}
|
||
</div>
|
||
)}
|
||
</main>
|
||
)
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Smoke test in browser**
|
||
|
||
```bash
|
||
bun run dev
|
||
# Open http://localhost:3000, login, verify dashboard renders (empty state ok)
|
||
```
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add -A
|
||
git commit -m "feat: dashboard UI with product cards + sparklines"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 16: UI — Add Product
|
||
|
||
**Files:**
|
||
- Create: `src/app/add/page.tsx`
|
||
|
||
- [ ] **Step 1: Create `src/app/add/page.tsx`**
|
||
|
||
```tsx
|
||
'use client'
|
||
import { useState } from 'react'
|
||
import { useRouter } from 'next/navigation'
|
||
|
||
export default function AddPage() {
|
||
const [url, setUrl] = useState('')
|
||
const [submitting, setSubmitting] = useState(false)
|
||
const [error, setError] = useState<string | null>(null)
|
||
const router = useRouter()
|
||
|
||
async function submit(e: React.FormEvent) {
|
||
e.preventDefault()
|
||
setSubmitting(true)
|
||
setError(null)
|
||
try {
|
||
const res = await fetch('/api/products', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ url }),
|
||
})
|
||
if (!res.ok) {
|
||
const j = await res.json().catch(() => ({ error: 'unknown' }))
|
||
setError(j.error || `HTTP ${res.status}`)
|
||
return
|
||
}
|
||
const { id } = await res.json()
|
||
router.push(`/products/${id}`)
|
||
} finally {
|
||
setSubmitting(false)
|
||
}
|
||
}
|
||
|
||
return (
|
||
<main className="mx-auto max-w-xl p-6">
|
||
<h1 className="mb-4 text-2xl font-bold">Produkt hinzufügen</h1>
|
||
<form onSubmit={submit} className="space-y-3">
|
||
<input
|
||
type="url"
|
||
required
|
||
value={url}
|
||
onChange={(e) => setUrl(e.target.value)}
|
||
placeholder="https://www.amazon.de/dp/..."
|
||
className="w-full rounded border border-zinc-700 bg-zinc-900 px-3 py-2 text-sm"
|
||
/>
|
||
<p className="text-xs text-zinc-500">Unterstützt: Amazon, Idealo, Geizhals</p>
|
||
{error && <div className="rounded bg-red-950 px-3 py-2 text-sm text-red-300">{error}</div>}
|
||
<button
|
||
type="submit"
|
||
disabled={submitting}
|
||
className="rounded bg-emerald-600 px-4 py-2 text-sm font-medium hover:bg-emerald-500 disabled:opacity-50"
|
||
>
|
||
{submitting ? 'Wird abgerufen…' : 'Hinzufügen'}
|
||
</button>
|
||
</form>
|
||
</main>
|
||
)
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Smoke test — add a Geizhals URL**
|
||
|
||
```bash
|
||
bun run dev
|
||
# Browser: /add → paste Geizhals URL → submit → redirect to /products/<id>
|
||
```
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add -A
|
||
git commit -m "feat: add-product page"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 17: UI — Product Detail (Chart + Alerts)
|
||
|
||
**Files:**
|
||
- Create: `src/components/PriceChart.tsx`
|
||
- Create: `src/components/AlertList.tsx`
|
||
- Create: `src/components/AlertForm.tsx`
|
||
- Create: `src/app/products/[id]/page.tsx`
|
||
|
||
- [ ] **Step 1: Create `src/components/PriceChart.tsx`**
|
||
|
||
```tsx
|
||
'use client'
|
||
import { useState } from 'react'
|
||
import { LineChart, Line, ResponsiveContainer, XAxis, YAxis, Tooltip, CartesianGrid } from 'recharts'
|
||
|
||
interface Snap { price: number; t: string }
|
||
|
||
export function PriceChart({ data }: { data: Snap[] }) {
|
||
const [range, setRange] = useState<'30d' | '90d' | 'all'>('30d')
|
||
const cutoff = range === 'all' ? 0 : Date.now() - (range === '30d' ? 30 : 90) * 86400_000
|
||
const filtered = data.filter((d) => new Date(d.t).getTime() >= cutoff)
|
||
|
||
return (
|
||
<div>
|
||
<div className="mb-2 flex gap-2">
|
||
{(['30d', '90d', 'all'] as const).map((r) => (
|
||
<button key={r} onClick={() => setRange(r)}
|
||
className={`rounded px-2 py-1 text-xs ${range === r ? 'bg-emerald-600' : 'bg-zinc-800'}`}>
|
||
{r}
|
||
</button>
|
||
))}
|
||
</div>
|
||
<ResponsiveContainer width="100%" height={300}>
|
||
<LineChart data={filtered}>
|
||
<CartesianGrid stroke="#27272a" strokeDasharray="3 3" />
|
||
<XAxis dataKey="t" tick={{ fill: '#a1a1aa', fontSize: 11 }} tickFormatter={(v) => new Date(v).toLocaleDateString('de-DE')} />
|
||
<YAxis tick={{ fill: '#a1a1aa', fontSize: 11 }} tickFormatter={(v) => `${v}€`} domain={['dataMin', 'dataMax']} />
|
||
<Tooltip contentStyle={{ background: '#18181b', border: '1px solid #3f3f46' }}
|
||
labelFormatter={(v) => new Date(v).toLocaleString('de-DE')}
|
||
formatter={(v: number) => [`${v}€`, 'Preis']} />
|
||
<Line type="monotone" dataKey="price" stroke="#22c55e" strokeWidth={2} dot={false} />
|
||
</LineChart>
|
||
</ResponsiveContainer>
|
||
</div>
|
||
)
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Create `src/components/AlertList.tsx`**
|
||
|
||
```tsx
|
||
'use client'
|
||
import { useRouter } from 'next/navigation'
|
||
|
||
interface Alert { id: string; type: string; config: Record<string, unknown>; enabled: boolean }
|
||
|
||
export function AlertList({ alerts }: { alerts: Alert[] }) {
|
||
const router = useRouter()
|
||
async function del(id: string) {
|
||
await fetch(`/api/alerts/${id}`, { method: 'DELETE' })
|
||
router.refresh()
|
||
}
|
||
if (alerts.length === 0) return <p className="text-sm text-zinc-500">Keine Alerts.</p>
|
||
return (
|
||
<ul className="space-y-2">
|
||
{alerts.map((a) => (
|
||
<li key={a.id} className="flex items-center justify-between rounded border border-zinc-800 px-3 py-2 text-sm">
|
||
<span>{labelFor(a)}</span>
|
||
<button onClick={() => del(a.id)} className="text-xs text-red-400 hover:underline">Löschen</button>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
)
|
||
}
|
||
|
||
function labelFor(a: Alert): string {
|
||
switch (a.type) {
|
||
case 'target_price': return `Zielpreis ≤ ${a.config.threshold}€`
|
||
case 'all_time_low': return `Allzeit-Tief`
|
||
case 'percent_drop': return `−${a.config.percent}% in ${a.config.lookback_days} Tagen`
|
||
default: return a.type
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Create `src/components/AlertForm.tsx`**
|
||
|
||
```tsx
|
||
'use client'
|
||
import { useState } from 'react'
|
||
import { useRouter } from 'next/navigation'
|
||
|
||
export function AlertForm({ productId }: { productId: string }) {
|
||
const [type, setType] = useState<'target_price' | 'all_time_low' | 'percent_drop'>('target_price')
|
||
const [threshold, setThreshold] = useState('')
|
||
const [percent, setPercent] = useState('10')
|
||
const [lookback, setLookback] = useState('7')
|
||
const router = useRouter()
|
||
|
||
async function submit(e: React.FormEvent) {
|
||
e.preventDefault()
|
||
let config: Record<string, unknown> = {}
|
||
if (type === 'target_price') config = { threshold: Number(threshold) }
|
||
if (type === 'percent_drop') config = { percent: Number(percent), lookback_days: Number(lookback) }
|
||
const res = await fetch('/api/alerts', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ productId, type, config }),
|
||
})
|
||
if (res.ok) {
|
||
setThreshold('')
|
||
router.refresh()
|
||
}
|
||
}
|
||
|
||
return (
|
||
<form onSubmit={submit} className="space-y-2 rounded border border-zinc-800 p-3">
|
||
<div className="flex gap-2">
|
||
<select value={type} onChange={(e) => setType(e.target.value as typeof type)} className="rounded border border-zinc-700 bg-zinc-900 px-2 py-1 text-sm">
|
||
<option value="target_price">Zielpreis</option>
|
||
<option value="all_time_low">Allzeit-Tief</option>
|
||
<option value="percent_drop">% Drop</option>
|
||
</select>
|
||
{type === 'target_price' && (
|
||
<input type="number" step="0.01" required value={threshold} onChange={(e) => setThreshold(e.target.value)}
|
||
placeholder="€" className="w-24 rounded border border-zinc-700 bg-zinc-900 px-2 py-1 text-sm" />
|
||
)}
|
||
{type === 'percent_drop' && (
|
||
<>
|
||
<input type="number" required value={percent} onChange={(e) => setPercent(e.target.value)}
|
||
className="w-16 rounded border border-zinc-700 bg-zinc-900 px-2 py-1 text-sm" />
|
||
<span className="self-center text-xs">% in</span>
|
||
<input type="number" required value={lookback} onChange={(e) => setLookback(e.target.value)}
|
||
className="w-16 rounded border border-zinc-700 bg-zinc-900 px-2 py-1 text-sm" />
|
||
<span className="self-center text-xs">Tagen</span>
|
||
</>
|
||
)}
|
||
<button type="submit" className="rounded bg-emerald-600 px-3 py-1 text-sm hover:bg-emerald-500">+ Alert</button>
|
||
</div>
|
||
</form>
|
||
)
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Create `src/app/products/[id]/page.tsx`**
|
||
|
||
```tsx
|
||
import { notFound } from 'next/navigation'
|
||
import { eq, desc } from 'drizzle-orm'
|
||
import { db, products, priceSnapshots, alerts } from '@/lib/db'
|
||
import { PriceChart } from '@/components/PriceChart'
|
||
import { AlertList } from '@/components/AlertList'
|
||
import { AlertForm } from '@/components/AlertForm'
|
||
|
||
export const dynamic = 'force-dynamic'
|
||
|
||
export default async function ProductPage({ params }: { params: Promise<{ id: string }> }) {
|
||
const { id } = await params
|
||
const [product] = await db.select().from(products).where(eq(products.id, id))
|
||
if (!product) notFound()
|
||
|
||
const snaps = await db.select().from(priceSnapshots)
|
||
.where(eq(priceSnapshots.productId, id))
|
||
.orderBy(desc(priceSnapshots.scrapedAt))
|
||
.limit(500)
|
||
const chartData = snaps
|
||
.filter((s) => s.price !== null)
|
||
.reverse()
|
||
.map((s) => ({ price: Number(s.price), t: s.scrapedAt.toISOString() }))
|
||
|
||
const productAlerts = await db.select().from(alerts).where(eq(alerts.productId, id))
|
||
|
||
return (
|
||
<main className="mx-auto max-w-4xl p-6">
|
||
<a href="/" className="text-sm text-zinc-500 hover:underline">← Zurück</a>
|
||
<div className="mt-2 flex items-start gap-4">
|
||
{product.imageUrl && <img src={product.imageUrl} alt="" className="h-24 w-24 rounded bg-white object-contain" />}
|
||
<div className="flex-1">
|
||
<div className="text-xs uppercase text-zinc-500">{product.shop}</div>
|
||
<h1 className="text-xl font-bold">{product.name}</h1>
|
||
<a href={product.url} target="_blank" rel="noreferrer" className="text-sm text-emerald-400 hover:underline">Zum Shop ↗</a>
|
||
</div>
|
||
</div>
|
||
|
||
<section className="mt-6">
|
||
<h2 className="mb-2 text-sm font-semibold uppercase text-zinc-400">Preisverlauf</h2>
|
||
<PriceChart data={chartData} />
|
||
</section>
|
||
|
||
<section className="mt-6">
|
||
<h2 className="mb-2 text-sm font-semibold uppercase text-zinc-400">Alerts</h2>
|
||
<AlertList alerts={productAlerts.map((a) => ({ id: a.id, type: a.type, config: a.config as Record<string, unknown>, enabled: a.enabled }))} />
|
||
<div className="mt-3"><AlertForm productId={id} /></div>
|
||
</section>
|
||
|
||
<section className="mt-6 flex gap-2">
|
||
<form action={`/api/products/${id}/scrape`} method="post">
|
||
<button className="rounded bg-zinc-800 px-3 py-1.5 text-sm hover:bg-zinc-700">Jetzt aktualisieren</button>
|
||
</form>
|
||
<form action={`/api/products/${id}`} method="post" onSubmit={(e) => { e.preventDefault(); if (confirm('Wirklich löschen?')) fetch(`/api/products/${id}`, { method: 'DELETE' }).then(() => location.href = '/') }}>
|
||
<button className="rounded bg-red-900 px-3 py-1.5 text-sm hover:bg-red-800">Löschen</button>
|
||
</form>
|
||
</section>
|
||
</main>
|
||
)
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: Smoke test in browser**
|
||
|
||
```bash
|
||
bun run dev
|
||
# /products/<id> → verify chart, alerts list, form, refresh button
|
||
```
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add -A
|
||
git commit -m "feat: product detail page with chart, alert list, alert form"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 18: Dockerfile + .dockerignore
|
||
|
||
**Files:**
|
||
- Create: `Dockerfile`
|
||
- Create: `.dockerignore`
|
||
|
||
- [ ] **Step 1: Create `Dockerfile`**
|
||
|
||
```dockerfile
|
||
# syntax=docker/dockerfile:1.7
|
||
|
||
FROM mcr.microsoft.com/playwright:v1.50.0-jammy AS base
|
||
WORKDIR /app
|
||
ENV NODE_ENV=production
|
||
|
||
FROM base AS deps
|
||
ENV NODE_ENV=development
|
||
COPY package.json package-lock.json* bun.lockb* ./
|
||
RUN npm install --frozen-lockfile || npm install
|
||
|
||
FROM base AS build
|
||
ENV NODE_ENV=development
|
||
COPY --from=deps /app/node_modules ./node_modules
|
||
COPY . .
|
||
RUN npm run build
|
||
|
||
FROM base AS runner
|
||
ENV PORT=3000
|
||
COPY --from=build /app/.next/standalone ./
|
||
COPY --from=build /app/.next/static ./.next/static
|
||
COPY --from=build /app/public ./public 2>/dev/null || true
|
||
COPY --from=build /app/drizzle ./drizzle
|
||
COPY --from=build /app/drizzle.config.ts ./
|
||
COPY --from=build /app/src/lib/db/schema.ts ./schema.ts
|
||
EXPOSE 3000
|
||
CMD ["node", "server.js"]
|
||
```
|
||
|
||
- [ ] **Step 2: Create `.dockerignore`**
|
||
|
||
```
|
||
node_modules
|
||
.next
|
||
.git
|
||
.env
|
||
.env.local
|
||
*.log
|
||
tests
|
||
docs
|
||
README.md
|
||
```
|
||
|
||
- [ ] **Step 3: Local build test**
|
||
|
||
```bash
|
||
docker build -t preis-tracker:local .
|
||
docker run --rm -p 3001:3000 \
|
||
--env-file .env \
|
||
-e NEXT_PUBLIC_BASE_URL=http://localhost:3001 \
|
||
preis-tracker:local &
|
||
sleep 8
|
||
curl -sf -o /dev/null -w '%{http_code}\n' http://localhost:3001
|
||
docker stop $(docker ps -q --filter ancestor=preis-tracker:local)
|
||
```
|
||
|
||
Expected: `307` (redirect to /api/auth/login). Image builds without errors.
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add -A
|
||
git commit -m "build: dockerfile based on playwright image, standalone next.js"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 19: Coolify Deployment
|
||
|
||
**Files:**
|
||
- Create: `README.md`
|
||
|
||
- [ ] **Step 1: Push to Gitea**
|
||
|
||
```bash
|
||
cd ~/preis-tracker
|
||
git remote add origin ssh://git@git.kuns.dev:22222/kuns/preis-tracker.git
|
||
|
||
# In another shell — create the repo on Gitea first
|
||
source ~/.secrets/coolify-tokens.env
|
||
curl -s -X POST -H "Authorization: token $GITEA_TOKEN" \
|
||
-H 'Content-Type: application/json' \
|
||
-d '{"name":"preis-tracker","private":true,"default_branch":"main"}' \
|
||
https://git.kuns.dev/api/v1/user/repos
|
||
|
||
git branch -M main
|
||
git push -u origin main
|
||
```
|
||
|
||
- [ ] **Step 2: Create Coolify app via API**
|
||
|
||
Follow the playbook in `memory/coolify-deploy.md`. Roughly:
|
||
1. Coolify UI → New Resource → Application → Public Repository → `https://git.kuns.dev/kuns/preis-tracker`
|
||
2. Build Pack: Dockerfile
|
||
3. Domain: `https://preis.kuns.dev`
|
||
4. Ports: `3000:3000`
|
||
5. Env Vars (mark sensitive ones as Build/Runtime Secret):
|
||
- `DATABASE_URL=postgresql://preistracker:<pw>@l8kogcggsc80sgcgk8kswww4:5432/preistracker`
|
||
- `ZITADEL_ISSUER=https://auth.kuns.dev`
|
||
- `ZITADEL_CLIENT_ID=<from Zitadel UI>`
|
||
- `ALLOWED_USER_IDS=<your sub>`
|
||
- `SESSION_PASSWORD=<32+ chars>`
|
||
- `PUSHOVER_TOKEN=<your app token>`
|
||
- `PUSHOVER_USER=<your user key>`
|
||
- `CRON_SECRET=<random>`
|
||
- `NEXT_PUBLIC_BASE_URL=https://preis.kuns.dev`
|
||
6. Webhook → enable, copy URL into Gitea repo settings → Webhooks
|
||
7. Deploy
|
||
|
||
- [ ] **Step 3: Add Scheduled Task in Coolify**
|
||
|
||
In the app settings → Scheduled Tasks → New:
|
||
- Name: `daily-scrape`
|
||
- Command: `curl -fsS -X POST -H "Authorization: Bearer $CRON_SECRET" http://localhost:3000/api/cron/scrape`
|
||
- Schedule: `0 6 * * *`
|
||
|
||
- [ ] **Step 4: Update Zitadel app config**
|
||
|
||
In Zitadel UI: add redirect URI `https://preis.kuns.dev/api/auth/callback` and post-logout URI `https://preis.kuns.dev/`.
|
||
|
||
- [ ] **Step 5: Verify deployment**
|
||
|
||
```bash
|
||
sleep 60 # wait for deploy
|
||
curl -sI https://preis.kuns.dev | head -5
|
||
# Expect 307 redirect to /api/auth/login
|
||
|
||
# Trigger manual cron run to test
|
||
curl -s -X POST -H "Authorization: Bearer <CRON_SECRET>" \
|
||
https://preis.kuns.dev/api/cron/scrape
|
||
```
|
||
|
||
- [ ] **Step 6: Create `README.md`**
|
||
|
||
```markdown
|
||
# preis-tracker
|
||
|
||
Tracking von Produktpreisen bei Amazon, Idealo, Geizhals mit Pushover-Alerts.
|
||
|
||
## Stack
|
||
Next.js 16, TypeScript, Drizzle + PostgreSQL, Playwright + Cheerio, Zitadel OIDC, iron-session, Recharts.
|
||
|
||
## Dev
|
||
|
||
```bash
|
||
cp .env.example .env # fill in
|
||
bun install
|
||
bunx playwright install chromium
|
||
bun run db:push
|
||
bun run dev
|
||
```
|
||
|
||
## Deploy
|
||
Coolify-App `preis.kuns.dev`. Daily scrape via Scheduled Task `0 6 * * *`.
|
||
|
||
Spec: `docs/superpowers/specs/2026-05-25-preis-tracker-design.md`
|
||
```
|
||
|
||
- [ ] **Step 7: Commit**
|
||
|
||
```bash
|
||
git add README.md
|
||
git commit -m "docs: readme"
|
||
git push
|
||
```
|
||
|
||
- [ ] **Step 8: Update memory**
|
||
|
||
After successful deploy, write to `~/.claude/projects/-home-mika/memory/`:
|
||
- Append to `MEMORY.md`: line for `preis-tracker` project (Coolify UUID, repo, domain, DB)
|
||
- If learnings emerged (selector drift, Playwright in container quirks), add to `learnings.md`
|
||
|
||
---
|
||
|
||
## Self-Review Notes
|
||
|
||
**Spec coverage:** All 16 sections of the spec map to tasks 1-19. Auth (§8) → Task 11. Scrapers (§6) → Tasks 4-8. Alerts (§4, §9) → Tasks 10, 13. Cron flow (§7) → Task 14. UI (§10) → Tasks 15-17. Deployment (§12) → Task 19.
|
||
|
||
**Known fragility:** The HTML-fixture-based tests are only as good as the captured fixtures. If a fixture is from a captcha/cloudflare page, the scraper tests "pass" by extracting nothing. Step 1 of each scraper task instructs to verify fixture size — that's the guardrail.
|
||
|
||
**Concurrency in cron:** The simple `worker()` pattern in Task 14 is fine for single-digit product counts. If list grows >50, consider proper batching with backpressure.
|
||
|
||
**Type consistency:** `AlertType` defined in `evaluate.ts` (Task 10) is the authoritative source; API routes (Task 13) and Cron (Task 14) reference it indirectly via the literal union. Schema (Task 2) uses the same string values in the CHECK constraint.
|