c8f4b50ef3
All literal "team" tier-value references renamed to "family" across API routes, admin UI, OpenAPI schemas, and lib/tiers.ts. The DB enum value itself is renamed in place via ALTER TYPE ... RENAME VALUE (migration 0044) rather than drizzle-kit's auto-generated drop-and-recreate-the-enum migration, which would have failed against any existing row still holding 'team' — RENAME VALUE preserves existing data with no cast/backfill needed. Also adds STRIPE_PLAN.md — a full Stripe billing integration plan (Checkout+Portal, tier→Price mapping, admin billing dashboard, and a multi-user Family-group design since Family is meant to cover several accounts under one subscription, not one payer). Planning only, no Stripe code yet. v0.47.0
60 lines
2.5 KiB
TypeScript
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 });
|
|
}
|
|
|
|
try {
|
|
const sizeMb = Math.ceil(fileSize / (1024 * 1024));
|
|
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 });
|
|
}
|