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
212 lines
7.8 KiB
TypeScript
212 lines
7.8 KiB
TypeScript
import { NextRequest, NextResponse } from "next/server";
|
|
import { db, recipes, recipeIngredients, recipeSteps, recipePhotos, recipeBatchDishes } from "@epicure/db";
|
|
import { eq, desc, and } from "@epicure/db";
|
|
import { z } from "zod";
|
|
import { requireSessionOrApiKey } from "@/lib/api-auth";
|
|
import { isOwnedRecipePhotoKey } from "@/lib/storage";
|
|
import { checkAndIncrementTierLimit, TierLimitError } from "@/lib/tiers";
|
|
import { dispatchWebhook } from "@/lib/webhooks";
|
|
import { parseQuantity } from "@/lib/parse-quantity";
|
|
import { extractIngredientQuantity } from "@/lib/extract-ingredient-quantity";
|
|
|
|
const CreateRecipeSchema = z.object({
|
|
title: z.string().min(1).max(200),
|
|
description: z.string().max(2000).optional(),
|
|
baseServings: z.number().int().min(1).max(100).default(4),
|
|
recipeType: z.enum(["dish", "drink"]).default("dish"),
|
|
visibility: z.enum(["private", "unlisted", "public", "followers"]).default("private"),
|
|
difficulty: z.enum(["easy", "medium", "hard"]).optional(),
|
|
prepMins: z.number().int().min(0).max(1440).optional(),
|
|
cookMins: z.number().int().min(0).max(1440).optional(),
|
|
tags: z.array(z.string().min(1).max(50)).max(20).default([]),
|
|
aiGenerated: z.boolean().optional(),
|
|
language: z.string().max(10).optional(),
|
|
sourceUrl: z.string().url().max(2000).optional(),
|
|
dietaryTags: z.object({
|
|
vegan: z.boolean().optional(),
|
|
vegetarian: z.boolean().optional(),
|
|
glutenFree: z.boolean().optional(),
|
|
dairyFree: z.boolean().optional(),
|
|
nutFree: z.boolean().optional(),
|
|
halal: z.boolean().optional(),
|
|
kosher: z.boolean().optional(),
|
|
}).optional(),
|
|
ingredients: z.array(z.object({
|
|
rawName: z.string().min(1).max(200),
|
|
quantity: z.union([z.number(), z.string()]).optional().transform(parseQuantity),
|
|
unit: z.string().max(50).optional(),
|
|
note: z.string().max(500).optional(),
|
|
order: z.number().int().default(0),
|
|
}).transform((ing) => {
|
|
const { rawName, quantity, unit } = extractIngredientQuantity(ing.rawName, ing.quantity, ing.unit);
|
|
return { ...ing, rawName, quantity, unit };
|
|
})).max(100).default([]),
|
|
steps: z.array(z.object({
|
|
instruction: z.string().min(1).max(2000),
|
|
timerSeconds: z.number().int().min(0).max(86400).optional(),
|
|
order: z.number().int().optional(),
|
|
appliesTo: z.array(z.string().min(1).max(100)).max(20).default([]),
|
|
})).max(100).default([]),
|
|
photos: z.array(z.object({
|
|
key: z.string().min(1).max(500),
|
|
isCover: z.boolean().default(false),
|
|
})).max(20).default([]),
|
|
isBatchCook: z.boolean().default(false),
|
|
dishes: z.array(z.object({
|
|
name: z.string().min(1).max(100),
|
|
description: z.string().max(500).optional(),
|
|
fridgeDays: z.number().int().min(1).max(14),
|
|
freezerFriendly: z.boolean().default(false),
|
|
freezerNote: z.string().max(300).optional(),
|
|
dayOfInstructions: z.string().min(1).max(1000),
|
|
})).max(10).default([]),
|
|
// Manually-entered per-serving nutrition — takes precedence over (and
|
|
// disables) the AI-estimate action on the recipe page, since re-estimating
|
|
// would silently discard values the author entered on purpose.
|
|
nutrition: z.object({
|
|
calories: z.number().min(0).max(10000),
|
|
proteinG: z.number().min(0).max(1000),
|
|
carbsG: z.number().min(0).max(1000),
|
|
fatG: z.number().min(0).max(1000),
|
|
fiberG: z.number().min(0).max(1000),
|
|
sodiumMg: z.number().min(0).max(100000),
|
|
}).optional(),
|
|
});
|
|
|
|
export async function GET(req: NextRequest) {
|
|
const { session, response } = await requireSessionOrApiKey(req, { rateLimit: { limit: 60, windowSeconds: 60 } });
|
|
if (response) return response;
|
|
|
|
const { searchParams } = new URL(req.url);
|
|
const limit = Math.min(parseInt(searchParams.get("limit") ?? "20"), 100);
|
|
const offset = parseInt(searchParams.get("offset") ?? "0");
|
|
const visibility = searchParams.get("visibility") as "private" | "unlisted" | "public" | "followers" | null;
|
|
const recipeType = searchParams.get("recipeType") as "dish" | "drink" | null;
|
|
|
|
const conditions = [eq(recipes.authorId, session!.user.id)];
|
|
if (visibility) conditions.push(eq(recipes.visibility, visibility));
|
|
if (recipeType) conditions.push(eq(recipes.recipeType, recipeType));
|
|
|
|
const rows = await db
|
|
.select()
|
|
.from(recipes)
|
|
.where(and(...conditions))
|
|
.orderBy(desc(recipes.updatedAt))
|
|
.limit(limit)
|
|
.offset(offset);
|
|
|
|
return NextResponse.json({ data: rows, limit, offset });
|
|
}
|
|
|
|
export async function POST(req: NextRequest) {
|
|
const { session, response } = await requireSessionOrApiKey(req, { rateLimit: { limit: 60, windowSeconds: 60 } });
|
|
if (response) return response;
|
|
|
|
const body = await req.json() as unknown;
|
|
const parsed = CreateRecipeSchema.safeParse(body);
|
|
if (!parsed.success) {
|
|
return NextResponse.json({ error: "Validation error", issues: parsed.error.issues }, { status: 400 });
|
|
}
|
|
|
|
const id = crypto.randomUUID();
|
|
const now = new Date();
|
|
const data = parsed.data;
|
|
|
|
if (data.photos.some((p) => !isOwnedRecipePhotoKey(p.key, id, session!.user.id))) {
|
|
return NextResponse.json({ error: "Validation error", issues: [{ path: ["photos"], message: "Photo key not issued for this recipe" }] }, { status: 400 });
|
|
}
|
|
|
|
try {
|
|
await checkAndIncrementTierLimit(session!.user.id, session!.user.tier as "free" | "pro" | "family", "recipe");
|
|
} catch (err) {
|
|
if (err instanceof TierLimitError) {
|
|
return NextResponse.json({ error: "Recipe limit reached for your tier" }, { status: 403 });
|
|
}
|
|
throw err;
|
|
}
|
|
|
|
await db.transaction(async (tx) => {
|
|
await tx.insert(recipes).values({
|
|
id,
|
|
authorId: session!.user.id,
|
|
title: data.title,
|
|
description: data.description,
|
|
baseServings: data.baseServings,
|
|
recipeType: data.recipeType,
|
|
visibility: data.visibility,
|
|
difficulty: data.difficulty,
|
|
prepMins: data.prepMins,
|
|
cookMins: data.cookMins,
|
|
tags: data.tags,
|
|
dietaryTags: data.dietaryTags ?? {},
|
|
aiGenerated: data.aiGenerated ?? false,
|
|
language: data.language,
|
|
sourceUrl: data.sourceUrl,
|
|
isBatchCook: data.isBatchCook,
|
|
nutritionData: data.nutrition ? { perServing: data.nutrition } : undefined,
|
|
nutritionManual: !!data.nutrition,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
});
|
|
|
|
if (data.ingredients.length > 0) {
|
|
await tx.insert(recipeIngredients).values(
|
|
data.ingredients.map((ing, i) => ({
|
|
id: crypto.randomUUID(),
|
|
recipeId: id,
|
|
rawName: ing.rawName,
|
|
quantity: ing.quantity,
|
|
unit: ing.unit,
|
|
note: ing.note,
|
|
order: ing.order ?? i,
|
|
}))
|
|
);
|
|
}
|
|
|
|
if (data.steps.length > 0) {
|
|
await tx.insert(recipeSteps).values(
|
|
data.steps.map((step, i) => ({
|
|
id: crypto.randomUUID(),
|
|
recipeId: id,
|
|
instruction: step.instruction,
|
|
timerSeconds: step.timerSeconds,
|
|
order: step.order ?? i,
|
|
appliesTo: step.appliesTo,
|
|
}))
|
|
);
|
|
}
|
|
|
|
if (data.photos.length > 0) {
|
|
await tx.insert(recipePhotos).values(
|
|
data.photos.map((photo, i) => ({
|
|
id: crypto.randomUUID(),
|
|
recipeId: id,
|
|
storageKey: photo.key,
|
|
order: i,
|
|
isCover: photo.isCover,
|
|
}))
|
|
);
|
|
}
|
|
|
|
if (data.dishes.length > 0) {
|
|
await tx.insert(recipeBatchDishes).values(
|
|
data.dishes.map((dish, i) => ({
|
|
id: crypto.randomUUID(),
|
|
recipeId: id,
|
|
name: dish.name,
|
|
description: dish.description,
|
|
order: i,
|
|
fridgeDays: dish.fridgeDays,
|
|
freezerFriendly: dish.freezerFriendly,
|
|
freezerNote: dish.freezerNote,
|
|
dayOfInstructions: dish.dayOfInstructions,
|
|
}))
|
|
);
|
|
}
|
|
});
|
|
|
|
const recipe = await db.query.recipes.findFirst({ where: eq(recipes.id, id) });
|
|
void dispatchWebhook(session!.user.id, "recipe.created", { id, title: data.title });
|
|
return NextResponse.json(recipe, { status: 201 });
|
|
}
|