feat: products api (list/add/detail/delete/manual-scrape)
This commit is contained in:
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 })
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user