From 8a284edcb1a62d6f8c1d0d2a8e5f84bc1ed1dbda Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 25 May 2026 14:25:26 +0000 Subject: [PATCH] feat: products api (list/add/detail/delete/manual-scrape) --- src/app/api/products/[id]/route.ts | 23 +++++++++ src/app/api/products/[id]/scrape/route.ts | 27 +++++++++++ src/app/api/products/route.ts | 59 +++++++++++++++++++++++ 3 files changed, 109 insertions(+) create mode 100644 src/app/api/products/[id]/route.ts create mode 100644 src/app/api/products/[id]/scrape/route.ts create mode 100644 src/app/api/products/route.ts diff --git a/src/app/api/products/[id]/route.ts b/src/app/api/products/[id]/route.ts new file mode 100644 index 0000000..bc94433 --- /dev/null +++ b/src/app/api/products/[id]/route.ts @@ -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 }) +} diff --git a/src/app/api/products/[id]/scrape/route.ts b/src/app/api/products/[id]/scrape/route.ts new file mode 100644 index 0000000..502f6d1 --- /dev/null +++ b/src/app/api/products/[id]/scrape/route.ts @@ -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) +} diff --git a/src/app/api/products/route.ts b/src/app/api/products/route.ts new file mode 100644 index 0000000..8a1700c --- /dev/null +++ b/src/app/api/products/route.ts @@ -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 }) +}