Files
Epicure/apps/web/app/api/v1/upload/presign/route.ts
T
Arnaud f6975e98a9 fix: recipe/storage tier limits are lifetime totals, not monthly (v0.58.0)
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>
2026-07-20 20:32:31 +02:00

60 lines
2.5 KiB
TypeScript

import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { requireSession } from "@/lib/api-auth";
import { createPresignedUploadPost } from "@/lib/storage";
import { db, recipes, eq, and } from "@epicure/db";
import { checkAndIncrementTierLimit, TierLimitError } from "@/lib/tiers";
const ALLOWED_TYPES = ["image/jpeg", "image/png", "image/webp", "image/avif"] as const;
type AllowedType = (typeof ALLOWED_TYPES)[number];
const MAX_FILE_SIZE = 10 * 1024 * 1024;
const Schema = z.object({
contentType: z.string().refine((t): t is AllowedType => (ALLOWED_TYPES as readonly string[]).includes(t), {
message: "Content type must be jpeg, png, webp, or avif",
}),
recipeId: z.string().uuid(),
purpose: z.enum(["recipe", "review"]).default("recipe"),
fileSize: z.number().int().positive().max(MAX_FILE_SIZE, "File exceeds 10MB limit"),
});
export async function POST(req: NextRequest) {
const { session, response } = await requireSession();
if (response) return response;
const body = await req.json() as unknown;
const parsed = Schema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: "Validation error", issues: parsed.error.issues }, { status: 400 });
}
const { recipeId, contentType, purpose, fileSize } = parsed.data;
const recipe = await db.query.recipes.findFirst({
where: purpose === "recipe"
? and(eq(recipes.id, recipeId), eq(recipes.authorId, session!.user.id))
: eq(recipes.id, recipeId),
columns: { id: true, visibility: true, authorId: true },
});
if (!recipe) return NextResponse.json({ error: "Not found" }, { status: 404 });
if (purpose === "review" && recipe.visibility === "private" && recipe.authorId !== session!.user.id) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
const sizeMb = Math.ceil(fileSize / (1024 * 1024));
try {
await checkAndIncrementTierLimit(session!.user.id, session!.user.tier as "free" | "pro" | "family", "storage", sizeMb);
} catch (err) {
if (err instanceof TierLimitError) {
return NextResponse.json({ error: "Storage limit reached for your tier" }, { status: 403 });
}
throw err;
}
const ext = contentType.split("/")[1] ?? "jpg";
const folder = purpose === "review" ? "reviews" : "photos";
const key = `recipes/${recipeId}/${folder}/${session!.user.id}-${crypto.randomUUID()}.${ext}`;
const { url, fields } = await createPresignedUploadPost(key, contentType, MAX_FILE_SIZE);
return NextResponse.json({ url, fields, key, sizeMb });
}