Compare commits
10 Commits
835f3bb2bb
...
3a8bcb6623
| Author | SHA1 | Date | |
|---|---|---|---|
| 3a8bcb6623 | |||
| 44293deb9b | |||
| 99d1d85293 | |||
| 04c014d48b | |||
| f59e6c8582 | |||
| e323ed0a9c | |||
| 8dcae4d60f | |||
| 8a284edcb1 | |||
| fabf6a5c38 | |||
| 1b31c4da71 |
9
.dockerignore
Normal file
9
.dockerignore
Normal file
@@ -0,0 +1,9 @@
|
||||
node_modules
|
||||
.next
|
||||
.git
|
||||
.env
|
||||
.env.local
|
||||
*.log
|
||||
tests
|
||||
docs
|
||||
README.md
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -9,3 +9,5 @@ build/
|
||||
.turbo/
|
||||
coverage/
|
||||
test-results/
|
||||
tsconfig.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
31
Dockerfile
Normal file
31
Dockerfile
Normal 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
6
next-env.d.ts
vendored
@@ -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
0
public/.gitkeep
Normal file
57
src/app/add/page.tsx
Normal file
57
src/app/add/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
9
src/app/api/alerts/[id]/route.ts
Normal file
9
src/app/api/alerts/[id]/route.ts
Normal 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 })
|
||||
}
|
||||
39
src/app/api/alerts/route.ts
Normal file
39
src/app/api/alerts/route.ts
Normal 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 })
|
||||
}
|
||||
38
src/app/api/auth/callback/route.ts
Normal file
38
src/app/api/auth/callback/route.ts
Normal 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))
|
||||
}
|
||||
18
src/app/api/auth/login/route.ts
Normal file
18
src/app/api/auth/login/route.ts
Normal 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)
|
||||
}
|
||||
9
src/app/api/auth/logout/route.ts
Normal file
9
src/app/api/auth/logout/route.ts
Normal 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}/`))
|
||||
}
|
||||
123
src/app/api/cron/scrape/route.ts
Normal file
123
src/app/api/cron/scrape/route.ts
Normal 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}€`
|
||||
}
|
||||
}
|
||||
23
src/app/api/products/[id]/route.ts
Normal file
23
src/app/api/products/[id]/route.ts
Normal 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 })
|
||||
}
|
||||
27
src/app/api/products/[id]/scrape/route.ts
Normal file
27
src/app/api/products/[id]/scrape/route.ts
Normal 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)
|
||||
}
|
||||
59
src/app/api/products/route.ts
Normal file
59
src/app/api/products/route.ts
Normal 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 })
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
55
src/app/products/[id]/page.tsx
Normal file
55
src/app/products/[id]/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
54
src/components/AlertForm.tsx
Normal file
54
src/components/AlertForm.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
32
src/components/AlertList.tsx
Normal file
32
src/components/AlertList.tsx
Normal 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
|
||||
}
|
||||
}
|
||||
35
src/components/PriceChart.tsx
Normal file
35
src/components/PriceChart.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
19
src/components/ProductActions.tsx
Normal file
19
src/components/ProductActions.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
37
src/components/ProductCard.tsx
Normal file
37
src/components/ProductCard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
14
src/components/Sparkline.tsx
Normal file
14
src/components/Sparkline.tsx
Normal 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
13
src/lib/auth/pkce.ts
Normal 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
29
src/lib/auth/session.ts
Normal 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
87
src/lib/auth/zitadel.ts
Normal 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)
|
||||
}
|
||||
@@ -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
25
src/middleware.ts
Normal 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).*)'],
|
||||
}
|
||||
Reference in New Issue
Block a user