f6975e98a9
Recipe count and storage usage shared the monthly user_usage bucket with AI calls, so both incorrectly reset every month even though nothing was deleted. Only AI calls should be monthly. Recipe count and storage are now derived live from real data (recipes, recipe/review photos, avatar) instead of a counter — deleting a photo or recipe is itself the "decrement", no extra wiring needed. Storage size is tracked per-row (recipePhotos.sizeMb, ratings.photoSizeMb, users.avatarSizeMb) and threaded through presign -> upload -> save. Also fixes avatar removal silently no-oping (client sent a field the PATCH schema didn't recognize). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
67 lines
2.6 KiB
TypeScript
67 lines
2.6 KiB
TypeScript
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";
|
|
|
|
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)),
|
|
});
|
|
|
|
if (existing) {
|
|
await db.update(ratings)
|
|
.set({
|
|
score: parsed.data.score,
|
|
reviewText: parsed.data.reviewText,
|
|
photoKey: parsed.data.photoKey,
|
|
photoSizeMb: parsed.data.photoKey ? parsed.data.photoSizeMb : 0,
|
|
updatedAt: new Date(),
|
|
})
|
|
.where(eq(ratings.id, existing.id));
|
|
return NextResponse.json({ updated: true });
|
|
}
|
|
|
|
await db.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: parsed.data.photoKey ? parsed.data.photoSizeMb : 0,
|
|
});
|
|
void createNotification({ userId: recipe.authorId, type: "rating", actorId: session!.user.id, recipeId: id, score: parsed.data.score });
|
|
return NextResponse.json({ created: true }, { status: 201 });
|
|
}
|