Compare commits

...

10 Commits

28 changed files with 907 additions and 9 deletions

9
.dockerignore Normal file
View File

@@ -0,0 +1,9 @@
node_modules
.next
.git
.env
.env.local
*.log
tests
docs
README.md

2
.gitignore vendored
View File

@@ -9,3 +9,5 @@ build/
.turbo/
coverage/
test-results/
tsconfig.tsbuildinfo
next-env.d.ts

31
Dockerfile Normal file
View File

@@ -0,0 +1,31 @@
# syntax=docker/dockerfile:1.7
# --- deps + build use the Bun image so we honor bun.lock ---
FROM oven/bun:1.3 AS deps
WORKDIR /app
COPY package.json bun.lock* ./
RUN bun install --frozen-lockfile
FROM oven/bun:1.3 AS build
WORKDIR /app
# Dummy DATABASE_URL so Next.js can collect page data at build time.
# The real value is injected at runtime via env vars.
ENV DATABASE_URL=postgres://build:build@localhost:5432/build
ENV NEXT_TELEMETRY_DISABLED=1
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN bun run build
# --- runner: Playwright base image so the scraper has browsers available ---
FROM mcr.microsoft.com/playwright:v1.50.0-jammy AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV PORT=3000
COPY --from=build /app/.next/standalone ./
COPY --from=build /app/.next/static ./.next/static
COPY --from=build /app/public ./public
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"]

6
next-env.d.ts vendored
View File

@@ -1,6 +0,0 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

0
public/.gitkeep Normal file
View File

57
src/app/add/page.tsx Normal file
View File

@@ -0,0 +1,57 @@
'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>
)
}

View File

@@ -0,0 +1,9 @@
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 })
}

View File

@@ -0,0 +1,39 @@
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 })
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 })
}

View File

@@ -0,0 +1,38 @@
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 })
}
// Consume the pending login atomically before doing the token exchange
// to prevent state replay on concurrent callbacks.
const { codeVerifier } = pending
delete session.loginInProgress
await session.save()
const redirectUri = `${process.env.NEXT_PUBLIC_BASE_URL}/api/auth/callback`
const tokens = await exchangeCode({ code, 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
await session.save()
return NextResponse.redirect(new URL('/', req.url))
}

View File

@@ -0,0 +1,18 @@
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)
}

View File

@@ -0,0 +1,9 @@
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}/`))
}

View File

@@ -0,0 +1,123 @@
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
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`)
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))
const allSnapshots = await db.select().from(priceSnapshots)
.where(and(eq(priceSnapshots.productId, p.id)))
const maxId = Math.max(...allSnapshots.map((x) => x.id))
const history: SnapshotInput[] = allSnapshots
.filter((s) => s.price !== null)
.filter((s) => s.id !== maxId)
.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}`
}
}

View File

@@ -0,0 +1,23 @@
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 })
}

View File

@@ -0,0 +1,27 @@
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)
}

View File

@@ -0,0 +1,59 @@
import { NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { eq, 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() {
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 })
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 })
}

View File

@@ -1,3 +1,63 @@
export default function Home() {
return <main className="p-8"><h1 className="text-2xl">Preis-Tracker</h1></main>
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>
)
}

View File

@@ -0,0 +1,55 @@
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'
import { ProductActions } from '@/components/ProductActions'
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">
<ProductActions productId={id} />
</section>
</main>
)
}

View File

@@ -0,0 +1,54 @@
'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>
)
}

View File

@@ -0,0 +1,32 @@
'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
}
}

View File

@@ -0,0 +1,35 @@
'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) => [`${v}`, 'Preis']} />
<Line type="monotone" dataKey="price" stroke="#22c55e" strokeWidth={2} dot={false} />
</LineChart>
</ResponsiveContainer>
</div>
)
}

View File

@@ -0,0 +1,19 @@
'use client'
export function ProductActions({ productId }: { productId: string }) {
async function refresh() {
const res = await fetch(`/api/products/${productId}/scrape`, { method: 'POST' })
if (res.ok) location.reload()
}
async function del() {
if (!confirm('Wirklich löschen?')) return
const res = await fetch(`/api/products/${productId}`, { method: 'DELETE' })
if (res.ok) location.href = '/'
}
return (
<div className="flex gap-2">
<button onClick={refresh} className="rounded bg-zinc-800 px-3 py-1.5 text-sm hover:bg-zinc-700">Jetzt aktualisieren</button>
<button onClick={del} className="rounded bg-red-900 px-3 py-1.5 text-sm hover:bg-red-800">Löschen</button>
</div>
)
}

View File

@@ -0,0 +1,37 @@
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>
)
}

View File

@@ -0,0 +1,14 @@
'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>
)
}

13
src/lib/auth/pkce.ts Normal file
View File

@@ -0,0 +1,13 @@
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')
}

29
src/lib/auth/session.ts Normal file
View File

@@ -0,0 +1,29 @@
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 }
}
export const sessionOptions: 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 (!sessionOptions.password || (typeof sessionOptions.password === 'string' && sessionOptions.password.length < 32)) {
throw new Error('SESSION_PASSWORD must be set to a 32+ char value')
}
const c = await cookies()
return getIronSession<SessionData>(c, sessionOptions)
}

87
src/lib/auth/zitadel.ts Normal file
View File

@@ -0,0 +1,87 @@
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)
}

View File

@@ -49,7 +49,7 @@ export const idealoScraper: PriceScraper = {
},
}
function extractJsonLdPrice($: cheerio.CheerioAPI): string | null {
function extractJsonLdPrice($: cheerio.Root): string | null {
const scripts = $('script[type="application/ld+json"]')
for (let i = 0; i < scripts.length; i++) {
const raw = $(scripts[i]).contents().text()

25
src/middleware.ts Normal file
View File

@@ -0,0 +1,25 @@
import { NextRequest, NextResponse } from 'next/server'
import { getIronSession } from 'iron-session'
import { sessionOptions, 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, res, sessionOptions)
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).*)'],
}