import { NextRequest, NextResponse } from "next/server"; import { z } from "zod"; import { db, recipes, ratings, eq, and } from "@epicure/db"; import { requireSessionOrApiKey } from "@/lib/api-auth"; import { createNotification } from "@/lib/notifications"; import { isOwnedReviewPhotoKey } from "@/lib/storage"; import { checkTierLimitInTransaction, TierLimitError } from "@/lib/tiers"; const Schema = z.object({ score: z.number().int().min(1).max(5), reviewText: z.string().max(2000).optional(), photoKey: z.string().max(500).optional(), photoSizeMb: z.number().int().min(0).max(50).default(0), }); type Params = { params: Promise<{ id: string }> }; export async function POST(req: NextRequest, { params }: Params) { const { session, response } = await requireSessionOrApiKey(req); if (response) return response; const { id } = await params; const recipe = await db.query.recipes.findFirst({ where: eq(recipes.id, id) }); if (!recipe || (recipe.visibility === "private" && recipe.authorId !== session!.user.id)) { return NextResponse.json({ error: "Not found" }, { status: 404 }); } if (recipe.authorId === session!.user.id) { return NextResponse.json({ error: "Cannot rate your own recipe" }, { status: 400 }); } const body = await req.json() as unknown; const parsed = Schema.safeParse(body); if (!parsed.success) return NextResponse.json({ error: "Validation error" }, { status: 400 }); if (parsed.data.photoKey && !isOwnedReviewPhotoKey(parsed.data.photoKey, id, session!.user.id)) { return NextResponse.json({ error: "Validation error", issues: [{ path: ["photoKey"], message: "Photo key not issued for this review" }] }, { status: 400 }); } const existing = await db.query.ratings.findFirst({ where: and(eq(ratings.recipeId, id), eq(ratings.userId, session!.user.id)), }); const newPhotoSizeMb = parsed.data.photoKey ? parsed.data.photoSizeMb : 0; const photoSizeDeltaMb = newPhotoSizeMb - (existing?.photoSizeMb ?? 0); try { if (existing) { await db.transaction(async (tx) => { if (photoSizeDeltaMb > 0) { await checkTierLimitInTransaction(tx, session!.user.id, session!.user.tier as "free" | "pro" | "family", "storage", photoSizeDeltaMb); } await tx.update(ratings) .set({ score: parsed.data.score, reviewText: parsed.data.reviewText, photoKey: parsed.data.photoKey, photoSizeMb: newPhotoSizeMb, updatedAt: new Date(), }) .where(eq(ratings.id, existing.id)); }); return NextResponse.json({ updated: true }); } await db.transaction(async (tx) => { if (photoSizeDeltaMb > 0) { await checkTierLimitInTransaction(tx, session!.user.id, session!.user.tier as "free" | "pro" | "family", "storage", photoSizeDeltaMb); } await tx.insert(ratings).values({ id: crypto.randomUUID(), recipeId: id, userId: session!.user.id, score: parsed.data.score, reviewText: parsed.data.reviewText, photoKey: parsed.data.photoKey, photoSizeMb: newPhotoSizeMb, }); }); } catch (err) { if (err instanceof TierLimitError) { return NextResponse.json({ error: "Storage limit reached for your tier" }, { status: 403 }); } throw err; } void createNotification({ userId: recipe.authorId, type: "rating", actorId: session!.user.id, recipeId: id, score: parsed.data.score }); return NextResponse.json({ created: true }, { status: 201 }); }