feat(recipes): full recipe CRUD with photos, print, version history
List, detail, new, edit pages. Server-side pagination, dietary tags, difficulty. Photo upload to S3-compatible storage. Version history. Multi-select grid with bulk delete/visibility. Print view. Delete confirmation dialog.
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
import { Nav } from "@/components/layout/nav";
|
||||
|
||||
export default function AppLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<>
|
||||
<Nav />
|
||||
<main className="flex-1 container mx-auto px-4 py-8">{children}</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { headers } from "next/headers";
|
||||
import { auth } from "@/lib/auth/server";
|
||||
import { db, recipes, eq, and } from "@epicure/db";
|
||||
import { CookingMode } from "@/components/cooking-mode/cooking-mode";
|
||||
|
||||
type Params = { params: Promise<{ id: string }> };
|
||||
|
||||
export async function generateMetadata({ params }: Params) {
|
||||
const { id } = await params;
|
||||
const recipe = await db.query.recipes.findFirst({ where: eq(recipes.id, id) });
|
||||
return { title: recipe ? `Cooking: ${recipe.title}` : "Cooking Mode" };
|
||||
}
|
||||
|
||||
export default async function CookPage({ params }: Params) {
|
||||
const { id } = await params;
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
if (!session) return null;
|
||||
|
||||
const recipe = await db.query.recipes.findFirst({
|
||||
where: and(eq(recipes.id, id), eq(recipes.authorId, session.user.id)),
|
||||
with: {
|
||||
steps: { orderBy: (t, { asc }) => asc(t.order) },
|
||||
ingredients: { orderBy: (t, { asc }) => asc(t.order) },
|
||||
},
|
||||
});
|
||||
|
||||
if (!recipe || recipe.steps.length === 0) notFound();
|
||||
|
||||
return (
|
||||
<CookingMode
|
||||
recipeId={id}
|
||||
recipeTitle={recipe.title}
|
||||
steps={recipe.steps.map((s) => ({
|
||||
id: s.id,
|
||||
instruction: s.instruction,
|
||||
timerSeconds: s.timerSeconds,
|
||||
}))}
|
||||
ingredients={recipe.ingredients.map((i) => ({
|
||||
rawName: i.rawName,
|
||||
quantity: i.quantity,
|
||||
unit: i.unit,
|
||||
}))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { headers } from "next/headers";
|
||||
import { auth } from "@/lib/auth/server";
|
||||
import { db, recipes } from "@epicure/db";
|
||||
import { and, eq } from "@epicure/db";
|
||||
import { RecipeForm } from "@/components/recipe/recipe-form";
|
||||
import { getPublicUrl } from "@/lib/storage";
|
||||
|
||||
type Params = { params: Promise<{ id: string }> };
|
||||
|
||||
export const metadata: Metadata = { title: "Edit Recipe" };
|
||||
|
||||
export default async function EditRecipePage({ params }: Params) {
|
||||
const { id } = await params;
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
if (!session) return null;
|
||||
|
||||
const recipe = await db.query.recipes.findFirst({
|
||||
where: and(eq(recipes.id, id), eq(recipes.authorId, session.user.id)),
|
||||
with: {
|
||||
ingredients: { orderBy: (t, { asc }) => asc(t.order) },
|
||||
steps: { orderBy: (t, { asc }) => asc(t.order) },
|
||||
photos: { orderBy: (t, { asc }) => asc(t.order) },
|
||||
},
|
||||
});
|
||||
|
||||
if (!recipe) notFound();
|
||||
|
||||
const defaultValues = {
|
||||
title: recipe.title,
|
||||
description: recipe.description ?? undefined,
|
||||
baseServings: recipe.baseServings,
|
||||
visibility: recipe.visibility,
|
||||
difficulty: recipe.difficulty,
|
||||
prepMins: recipe.prepMins,
|
||||
cookMins: recipe.cookMins,
|
||||
dietaryTags: (recipe.dietaryTags as Record<string, boolean | undefined>) ?? {},
|
||||
ingredients: recipe.ingredients.map((ing) => ({
|
||||
id: ing.id,
|
||||
rawName: ing.rawName,
|
||||
quantity: ing.quantity ?? "",
|
||||
unit: ing.unit ?? "",
|
||||
note: ing.note ?? "",
|
||||
})),
|
||||
steps: recipe.steps.map((step) => ({
|
||||
id: step.id,
|
||||
instruction: step.instruction,
|
||||
timerSeconds: step.timerSeconds ? String(step.timerSeconds) : "",
|
||||
})),
|
||||
photos: recipe.photos.map((photo) => ({
|
||||
key: photo.storageKey,
|
||||
isCover: photo.isCover,
|
||||
preview: getPublicUrl(photo.storageKey),
|
||||
})),
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Edit recipe</h1>
|
||||
<p className="text-muted-foreground mt-1">{recipe.title}</p>
|
||||
</div>
|
||||
<RecipeForm recipeId={id} defaultValues={defaultValues} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { headers } from "next/headers";
|
||||
import Link from "next/link";
|
||||
import { Clock, Users, Globe, Lock, Link2, Pencil, ChefHat, ExternalLink, Play } from "lucide-react";
|
||||
import { VariationsButton } from "@/components/recipe/variations-button";
|
||||
import { TranslateButton } from "@/components/recipe/translate-button";
|
||||
import { AddToShoppingListButton } from "@/components/recipe/add-to-shopping-list-button";
|
||||
import { MealPairingButton } from "@/components/recipe/meal-pairing-button";
|
||||
import { DrinkPairingButton } from "@/components/recipe/drink-pairing-button";
|
||||
import { AdaptRecipeButton } from "@/components/recipe/adapt-recipe-button";
|
||||
import { PrintButton } from "@/components/recipe/print-button";
|
||||
import { VersionHistoryButton } from "@/components/recipe/version-history-button";
|
||||
import { DeleteRecipeButton } from "@/components/recipe/delete-recipe-button";
|
||||
import { NutritionPanel } from "@/components/recipe/nutrition-panel";
|
||||
import { auth } from "@/lib/auth/server";
|
||||
import { db, recipes, ratings, favorites, avg } from "@epicure/db";
|
||||
import { and, eq, count } from "@epicure/db";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { ServingScaler } from "@/components/recipe/serving-scaler";
|
||||
import { FavoriteButton } from "@/components/social/favorite-button";
|
||||
import { RatingStars } from "@/components/social/rating-stars";
|
||||
import { CommentsSection } from "@/components/social/comments-section";
|
||||
import { getPublicUrl } from "@/lib/storage";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type Params = { params: Promise<{ id: string }> };
|
||||
|
||||
export async function generateMetadata({ params }: Params): Promise<Metadata> {
|
||||
const { id } = await params;
|
||||
const recipe = await db.query.recipes.findFirst({ where: eq(recipes.id, id) });
|
||||
return { title: recipe?.title ?? "Recipe" };
|
||||
}
|
||||
|
||||
const VISIBILITY_LABEL = { private: "Private", unlisted: "Unlisted", public: "Public" };
|
||||
const VISIBILITY_ICON = { private: Lock, unlisted: Link2, public: Globe };
|
||||
const DIFFICULTY_COLOR = { easy: "default", medium: "secondary", hard: "destructive" } as const;
|
||||
|
||||
const DIETARY_LABELS: Record<string, string> = {
|
||||
vegan: "Vegan",
|
||||
vegetarian: "Vegetarian",
|
||||
glutenFree: "Gluten-free",
|
||||
dairyFree: "Dairy-free",
|
||||
nutFree: "Nut-free",
|
||||
halal: "Halal",
|
||||
kosher: "Kosher",
|
||||
};
|
||||
|
||||
export default async function RecipePage({ params }: Params) {
|
||||
const { id } = await params;
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
if (!session) return null;
|
||||
|
||||
const [recipe, ratingData, favoriteData] = await Promise.all([
|
||||
db.query.recipes.findFirst({
|
||||
where: and(eq(recipes.id, id), eq(recipes.authorId, session.user.id)),
|
||||
with: {
|
||||
ingredients: { orderBy: (t, { asc }) => asc(t.order) },
|
||||
steps: { orderBy: (t, { asc }) => asc(t.order) },
|
||||
photos: { orderBy: (t, { asc }) => asc(t.order) },
|
||||
},
|
||||
}),
|
||||
db.select({ avgScore: avg(ratings.score), total: count() }).from(ratings).where(eq(ratings.recipeId, id)),
|
||||
db.query.favorites.findFirst({ where: and(eq(favorites.userId, session.user.id), eq(favorites.recipeId, id)) }),
|
||||
]);
|
||||
|
||||
if (!recipe) notFound();
|
||||
|
||||
const avgScore = ratingData[0]?.avgScore ? parseFloat(ratingData[0].avgScore) : null;
|
||||
const ratingCount = ratingData[0]?.total ?? 0;
|
||||
const isFavorited = !!favoriteData;
|
||||
|
||||
const cover = recipe.photos.find((p) => p.isCover) ?? recipe.photos[0];
|
||||
const totalMins = (recipe.prepMins ?? 0) + (recipe.cookMins ?? 0);
|
||||
const activeDietaryTags = Object.entries(recipe.dietaryTags ?? {})
|
||||
.filter(([, v]) => v)
|
||||
.map(([k]) => DIETARY_LABELS[k])
|
||||
.filter(Boolean);
|
||||
|
||||
const VisibilityIcon = VISIBILITY_ICON[recipe.visibility];
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto space-y-8">
|
||||
{/* Header */}
|
||||
<div className="space-y-4">
|
||||
<h1 className="text-3xl font-bold tracking-tight">{recipe.title}</h1>
|
||||
<div className="flex items-center gap-2 overflow-x-auto pb-0.5 [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden">
|
||||
<FavoriteButton recipeId={id} initialFavorited={isFavorited} />
|
||||
<MealPairingButton recipeId={id} />
|
||||
<DrinkPairingButton recipeId={id} />
|
||||
{recipe.visibility === "public" && (
|
||||
<Link href={`/r/${id}`} target="_blank" className={cn(buttonVariants({ variant: "ghost", size: "sm" }))}>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</Link>
|
||||
)}
|
||||
{recipe.ingredients.length > 0 && (
|
||||
<AddToShoppingListButton
|
||||
recipeId={id}
|
||||
recipeTitle={recipe.title}
|
||||
baseServings={recipe.baseServings}
|
||||
ingredients={recipe.ingredients.map((ing) => ({
|
||||
rawName: ing.rawName,
|
||||
quantity: ing.quantity,
|
||||
unit: ing.unit,
|
||||
}))}
|
||||
/>
|
||||
)}
|
||||
<TranslateButton recipeId={id} />
|
||||
{recipe.ingredients.length > 0 && (
|
||||
<AdaptRecipeButton
|
||||
recipeId={id}
|
||||
ingredients={recipe.ingredients.map((ing) => ({ rawName: ing.rawName }))}
|
||||
/>
|
||||
)}
|
||||
<VariationsButton
|
||||
recipeId={id}
|
||||
baseServings={recipe.baseServings}
|
||||
difficulty={recipe.difficulty}
|
||||
prepMins={recipe.prepMins}
|
||||
cookMins={recipe.cookMins}
|
||||
ingredients={recipe.ingredients.map((ing) => ({
|
||||
rawName: ing.rawName,
|
||||
quantity: ing.quantity,
|
||||
unit: ing.unit,
|
||||
note: ing.note,
|
||||
order: ing.order,
|
||||
}))}
|
||||
steps={recipe.steps.map((s) => ({
|
||||
instruction: s.instruction,
|
||||
timerSeconds: s.timerSeconds,
|
||||
order: s.order,
|
||||
}))}
|
||||
/>
|
||||
{recipe.steps.length > 0 && (
|
||||
<Link href={`/recipes/${id}/cook`} className={cn(buttonVariants({ variant: "default", size: "sm" }))}>
|
||||
<Play className="h-4 w-4" />
|
||||
Cook
|
||||
</Link>
|
||||
)}
|
||||
<PrintButton recipeId={id} />
|
||||
<VersionHistoryButton recipeId={id} />
|
||||
<Link href={`/recipes/${id}/edit`} className={cn(buttonVariants({ variant: "outline", size: "sm" }))}>
|
||||
<Pencil className="h-4 w-4" />
|
||||
Edit
|
||||
</Link>
|
||||
<DeleteRecipeButton recipeId={id} />
|
||||
</div>
|
||||
|
||||
{avgScore !== null && (
|
||||
<div className="flex items-center gap-2">
|
||||
<RatingStars recipeId={id} initialScore={Math.round(avgScore)} readonly size="sm" />
|
||||
<span className="text-sm text-muted-foreground">{avgScore.toFixed(1)} ({ratingCount})</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{recipe.description && (
|
||||
<p className="text-muted-foreground leading-relaxed">{recipe.description}</p>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3 text-sm">
|
||||
{recipe.difficulty && (
|
||||
<Badge variant={DIFFICULTY_COLOR[recipe.difficulty]}>{recipe.difficulty}</Badge>
|
||||
)}
|
||||
<span className="flex items-center gap-1 text-muted-foreground">
|
||||
<Users className="h-4 w-4" />
|
||||
{recipe.baseServings} servings
|
||||
</span>
|
||||
{recipe.prepMins && (
|
||||
<span className="flex items-center gap-1 text-muted-foreground">
|
||||
<Clock className="h-4 w-4" />
|
||||
{recipe.prepMins}m prep
|
||||
</span>
|
||||
)}
|
||||
{recipe.cookMins && (
|
||||
<span className="flex items-center gap-1 text-muted-foreground">
|
||||
<ChefHat className="h-4 w-4" />
|
||||
{recipe.cookMins}m cook
|
||||
</span>
|
||||
)}
|
||||
{totalMins > 0 && recipe.prepMins && recipe.cookMins && (
|
||||
<span className="text-muted-foreground">({totalMins}m total)</span>
|
||||
)}
|
||||
<span className="flex items-center gap-1 text-muted-foreground ml-auto">
|
||||
<VisibilityIcon className="h-4 w-4" />
|
||||
{VISIBILITY_LABEL[recipe.visibility]}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{activeDietaryTags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{activeDietaryTags.map((tag) => (
|
||||
<Badge key={tag} variant="secondary" className="text-xs">{tag}</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Cover photo */}
|
||||
{cover && (
|
||||
<div className="aspect-video overflow-hidden rounded-xl bg-muted">
|
||||
<img
|
||||
src={getPublicUrl(cover.storageKey)}
|
||||
alt={recipe.title}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Serving scaler + ingredients */}
|
||||
{recipe.ingredients.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-xl font-semibold">Ingredients</h2>
|
||||
<ServingScaler
|
||||
baseServings={recipe.baseServings}
|
||||
recipeTitle={recipe.title}
|
||||
ingredients={recipe.ingredients.map((ing) => ({
|
||||
id: ing.id,
|
||||
rawName: ing.rawName,
|
||||
quantity: ing.quantity,
|
||||
unit: ing.unit,
|
||||
note: ing.note,
|
||||
order: ing.order,
|
||||
}))}
|
||||
/>
|
||||
<NutritionPanel recipeId={id} initialData={recipe.nutritionData} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{recipe.steps.length > 0 && (
|
||||
<>
|
||||
<Separator />
|
||||
<div className="space-y-6">
|
||||
<h2 className="text-xl font-semibold">Instructions</h2>
|
||||
<ol className="space-y-6">
|
||||
{recipe.steps.map((step, i) => (
|
||||
<li key={step.id} className="flex gap-4">
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-primary text-primary-foreground text-sm font-bold">
|
||||
{i + 1}
|
||||
</div>
|
||||
<div className="flex-1 space-y-1 pt-1">
|
||||
<p className="leading-relaxed">{step.instruction}</p>
|
||||
{step.timerSeconds && (
|
||||
<p className="text-xs text-muted-foreground flex items-center gap-1">
|
||||
<Clock className="h-3 w-3" />
|
||||
{step.timerSeconds >= 60
|
||||
? `${Math.floor(step.timerSeconds / 60)}m ${step.timerSeconds % 60 > 0 ? `${step.timerSeconds % 60}s` : ""}`
|
||||
: `${step.timerSeconds}s`}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{recipe.photos.length > 1 && (
|
||||
<>
|
||||
<Separator />
|
||||
<div className="space-y-3">
|
||||
<h2 className="text-xl font-semibold">Photos</h2>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{recipe.photos.map((photo) => (
|
||||
<div key={photo.id} className="aspect-square rounded-lg overflow-hidden bg-muted">
|
||||
<img src={getPublicUrl(photo.storageKey)} alt="" className="w-full h-full object-cover" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{recipe.visibility !== "private" && (
|
||||
<>
|
||||
<Separator />
|
||||
<div className="space-y-4">
|
||||
<RatingStars recipeId={id} initialScore={0} />
|
||||
</div>
|
||||
<Separator />
|
||||
<CommentsSection recipeId={id} currentUserId={session.user.id} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { headers } from "next/headers";
|
||||
import { auth } from "@/lib/auth/server";
|
||||
import { db, recipes, eq, and } from "@epicure/db";
|
||||
import { PrintTrigger } from "@/components/recipe/print-trigger";
|
||||
|
||||
type Params = { params: Promise<{ id: string }> };
|
||||
|
||||
export default async function RecipePrintPage({ params }: Params) {
|
||||
const { id } = await params;
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
if (!session) return null;
|
||||
|
||||
const recipe = await db.query.recipes.findFirst({
|
||||
where: and(eq(recipes.id, id), eq(recipes.authorId, session.user.id)),
|
||||
with: {
|
||||
ingredients: { orderBy: (t, { asc }) => asc(t.order) },
|
||||
steps: { orderBy: (t, { asc }) => asc(t.order) },
|
||||
},
|
||||
});
|
||||
|
||||
if (!recipe) notFound();
|
||||
|
||||
const totalMins = (recipe.prepMins ?? 0) + (recipe.cookMins ?? 0);
|
||||
|
||||
const DIETARY_LABELS: Record<string, string> = {
|
||||
vegan: "Vegan",
|
||||
vegetarian: "Vegetarian",
|
||||
glutenFree: "Gluten-free",
|
||||
dairyFree: "Dairy-free",
|
||||
nutFree: "Nut-free",
|
||||
halal: "Halal",
|
||||
kosher: "Kosher",
|
||||
};
|
||||
|
||||
const activeTags = Object.entries(recipe.dietaryTags ?? {})
|
||||
.filter(([, v]) => v)
|
||||
.map(([k]) => DIETARY_LABELS[k])
|
||||
.filter(Boolean);
|
||||
|
||||
return (
|
||||
<>
|
||||
<style>{`
|
||||
@media print {
|
||||
body { margin: 0; }
|
||||
.no-print { display: none !important; }
|
||||
}
|
||||
body {
|
||||
font-family: Georgia, 'Times New Roman', serif;
|
||||
color: #1a1a1a;
|
||||
max-width: 680px;
|
||||
margin: 40px auto;
|
||||
padding: 0 24px;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
h1 { font-size: 2em; margin: 0 0 8px; line-height: 1.2; }
|
||||
h2 { font-size: 1.1em; text-transform: uppercase; letter-spacing: 0.08em; border-bottom: 1px solid #ccc; padding-bottom: 4px; margin: 24px 0 12px; }
|
||||
.meta { display: flex; gap: 24px; font-size: 0.85em; color: #555; margin: 12px 0 16px; flex-wrap: wrap; }
|
||||
.meta span { display: flex; align-items: center; gap: 4px; }
|
||||
.tags { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 16px; }
|
||||
.tag { border: 1px solid #ccc; border-radius: 4px; padding: 1px 8px; font-size: 0.8em; font-family: system-ui, sans-serif; }
|
||||
.description { color: #444; font-style: italic; margin-bottom: 16px; }
|
||||
ul.ingredients { list-style: none; padding: 0; margin: 0; display: grid; grid-template-columns: 1fr 1fr; gap: 4px 24px; }
|
||||
ul.ingredients li { padding: 4px 0; border-bottom: 1px dotted #e0e0e0; font-family: system-ui, sans-serif; font-size: 0.9em; }
|
||||
ul.ingredients li .qty { color: #666; min-width: 60px; display: inline-block; }
|
||||
ol.steps { padding-left: 20px; margin: 0; }
|
||||
ol.steps li { padding: 6px 0 6px 4px; border-bottom: 1px dotted #e0e0e0; font-size: 0.95em; }
|
||||
ol.steps li:last-child { border-bottom: none; }
|
||||
.timer { font-size: 0.85em; color: #666; font-family: system-ui, sans-serif; margin-left: 8px; }
|
||||
footer { margin-top: 40px; font-size: 0.75em; color: #aaa; text-align: center; font-family: system-ui, sans-serif; }
|
||||
.print-btn {
|
||||
position: fixed; top: 16px; right: 16px; padding: 8px 16px;
|
||||
background: #18181b; color: white; border: none; border-radius: 6px;
|
||||
cursor: pointer; font-size: 13px; font-family: system-ui, sans-serif;
|
||||
}
|
||||
.print-btn:hover { background: #3f3f46; }
|
||||
`}</style>
|
||||
|
||||
<PrintTrigger />
|
||||
|
||||
<article>
|
||||
<h1>{recipe.title}</h1>
|
||||
|
||||
{recipe.description && (
|
||||
<p className="description">{recipe.description}</p>
|
||||
)}
|
||||
|
||||
<div className="meta">
|
||||
{recipe.baseServings && <span>Serves {recipe.baseServings}</span>}
|
||||
{recipe.prepMins && <span>Prep {recipe.prepMins} min</span>}
|
||||
{recipe.cookMins && <span>Cook {recipe.cookMins} min</span>}
|
||||
{totalMins > 0 && <span>Total {totalMins} min</span>}
|
||||
{recipe.difficulty && <span>{recipe.difficulty.charAt(0).toUpperCase() + recipe.difficulty.slice(1)}</span>}
|
||||
</div>
|
||||
|
||||
{activeTags.length > 0 && (
|
||||
<div className="tags">
|
||||
{activeTags.map((tag) => (
|
||||
<span key={tag} className="tag">{tag}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{recipe.ingredients.length > 0 && (
|
||||
<>
|
||||
<h2>Ingredients</h2>
|
||||
<ul className="ingredients">
|
||||
{recipe.ingredients.map((ing) => (
|
||||
<li key={ing.id}>
|
||||
<span className="qty">
|
||||
{[ing.quantity, ing.unit].filter(Boolean).join(" ")}
|
||||
</span>
|
||||
{ing.rawName}
|
||||
{ing.note && <span style={{ color: "#888" }}> ({ing.note})</span>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
|
||||
{recipe.steps.length > 0 && (
|
||||
<>
|
||||
<h2>Instructions</h2>
|
||||
<ol className="steps">
|
||||
{recipe.steps.map((step) => (
|
||||
<li key={step.id}>
|
||||
{step.instruction}
|
||||
{step.timerSeconds && (
|
||||
<span className="timer">⏱ {Math.floor(step.timerSeconds / 60)} min</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</>
|
||||
)}
|
||||
</article>
|
||||
|
||||
<footer>Printed from Epicure</footer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { Metadata } from "next";
|
||||
import { headers } from "next/headers";
|
||||
import { auth } from "@/lib/auth/server";
|
||||
import { db, recipes, pantryItems } from "@epicure/db";
|
||||
import { eq } from "@epicure/db";
|
||||
import { getPublicUrl } from "@/lib/storage";
|
||||
import { CanCookContent } from "@/components/recipe/can-cook-content";
|
||||
|
||||
export const metadata: Metadata = { title: "What can I cook?" };
|
||||
|
||||
export default async function CanCookPage() {
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
if (!session) return null;
|
||||
|
||||
const [userRecipes, pantry] = await Promise.all([
|
||||
db.query.recipes.findMany({
|
||||
where: eq(recipes.authorId, session.user.id),
|
||||
with: {
|
||||
ingredients: true,
|
||||
photos: true,
|
||||
},
|
||||
}),
|
||||
db.query.pantryItems.findMany({
|
||||
where: eq(pantryItems.userId, session.user.id),
|
||||
}),
|
||||
]);
|
||||
|
||||
const pantryKeys = new Set(pantry.map((p) => p.rawName.toLowerCase()));
|
||||
|
||||
const scored = userRecipes
|
||||
.filter((r) => r.ingredients.length > 0)
|
||||
.map((recipe) => {
|
||||
const matched = recipe.ingredients.filter((ing) =>
|
||||
pantryKeys.has(ing.rawName.toLowerCase())
|
||||
).length;
|
||||
const missing = recipe.ingredients
|
||||
.filter((ing) => !pantryKeys.has(ing.rawName.toLowerCase()))
|
||||
.map((ing) => ing.rawName)
|
||||
.slice(0, 5);
|
||||
const total = recipe.ingredients.length;
|
||||
const cover = recipe.photos?.find((p) => p.isCover) ?? recipe.photos?.[0];
|
||||
return {
|
||||
recipe: {
|
||||
id: recipe.id,
|
||||
title: recipe.title,
|
||||
description: recipe.description,
|
||||
photos: cover ? [{ url: getPublicUrl(cover.storageKey) }] : [],
|
||||
},
|
||||
matched,
|
||||
total,
|
||||
pct: Math.round((matched / total) * 100),
|
||||
missing,
|
||||
};
|
||||
})
|
||||
.sort((a, b) => b.pct - a.pct);
|
||||
|
||||
return <CanCookContent pantryCount={pantry.length} scored={scored} />;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { Metadata } from "next";
|
||||
import { RecipeForm } from "@/components/recipe/recipe-form";
|
||||
import { NewRecipeHeader } from "@/components/recipe/new-recipe-header";
|
||||
import { PhotoImportButton } from "@/components/recipe/photo-import-button";
|
||||
|
||||
export const metadata: Metadata = { title: "New Recipe" };
|
||||
|
||||
export default function NewRecipePage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<NewRecipeHeader />
|
||||
<PhotoImportButton />
|
||||
</div>
|
||||
<RecipeForm />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { Metadata } from "next";
|
||||
import { headers } from "next/headers";
|
||||
import { auth } from "@/lib/auth/server";
|
||||
import { db, recipes, recipeIngredients } from "@epicure/db";
|
||||
import { eq, desc, and, ilike, or, sql } from "@epicure/db";
|
||||
import { RecipesHeader } from "@/components/recipe/recipes-header";
|
||||
import { RecipesEmptyState } from "@/components/recipe/recipes-empty-state";
|
||||
import { RecipesGrid } from "@/components/recipe/recipes-grid";
|
||||
|
||||
export const metadata: Metadata = { title: "Recipes" };
|
||||
|
||||
type SearchParams = Promise<{ q?: string }>;
|
||||
|
||||
export default async function RecipesPage({ searchParams }: { searchParams: SearchParams }) {
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
if (!session) return null;
|
||||
|
||||
const { q } = await searchParams;
|
||||
const query = q?.trim() ?? "";
|
||||
|
||||
const whereClause = query
|
||||
? and(
|
||||
eq(recipes.authorId, session.user.id),
|
||||
or(
|
||||
ilike(recipes.title, `%${query}%`),
|
||||
ilike(recipes.description, `%${query}%`)
|
||||
)
|
||||
)
|
||||
: eq(recipes.authorId, session.user.id);
|
||||
|
||||
const userRecipes = await db.query.recipes.findMany({
|
||||
where: whereClause,
|
||||
orderBy: desc(recipes.updatedAt),
|
||||
with: { photos: true },
|
||||
});
|
||||
|
||||
const totalCount = query ? undefined : userRecipes.length;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<RecipesHeader count={totalCount ?? userRecipes.length} initialQuery={query} />
|
||||
|
||||
<RecipesEmptyState query={query} count={userRecipes.length} />
|
||||
|
||||
<RecipesGrid recipes={userRecipes} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { db, comments, commentReactions, eq, and, count } from "@epicure/db";
|
||||
import { requireSession } from "@/lib/api-auth";
|
||||
|
||||
const ReactionSchema = z.object({
|
||||
type: z.enum(["like", "love", "laugh", "wow", "sad", "fire"]),
|
||||
});
|
||||
|
||||
type Params = { params: Promise<{ id: string; commentId: string }> };
|
||||
|
||||
export async function GET(req: NextRequest, { params }: Params) {
|
||||
const { commentId } = await params;
|
||||
|
||||
// Verify comment exists
|
||||
const comment = await db.query.comments.findFirst({ where: eq(comments.id, commentId) });
|
||||
if (!comment) {
|
||||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
// Get reaction counts grouped by type
|
||||
const rows = await db
|
||||
.select({ type: commentReactions.type, cnt: count() })
|
||||
.from(commentReactions)
|
||||
.where(eq(commentReactions.commentId, commentId))
|
||||
.groupBy(commentReactions.type);
|
||||
|
||||
const counts: Record<string, number> = {};
|
||||
for (const row of rows) {
|
||||
counts[row.type] = row.cnt;
|
||||
}
|
||||
|
||||
// Check for session to return user's reactions
|
||||
const { session } = await requireSession();
|
||||
let userReactions: string[] = [];
|
||||
|
||||
if (session) {
|
||||
const userRows = await db
|
||||
.select({ type: commentReactions.type })
|
||||
.from(commentReactions)
|
||||
.where(and(eq(commentReactions.commentId, commentId), eq(commentReactions.userId, session.user.id)));
|
||||
userReactions = userRows.map((r) => r.type);
|
||||
}
|
||||
|
||||
return NextResponse.json({ counts, userReactions });
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest, { params }: Params) {
|
||||
const { session, response } = await requireSession();
|
||||
if (response) return response;
|
||||
const { commentId } = await params;
|
||||
|
||||
// Verify comment exists
|
||||
const comment = await db.query.comments.findFirst({ where: eq(comments.id, commentId) });
|
||||
if (!comment) {
|
||||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const body = await req.json() as unknown;
|
||||
const parsed = ReactionSchema.safeParse(body);
|
||||
if (!parsed.success) return NextResponse.json({ error: "Validation error" }, { status: 400 });
|
||||
|
||||
const { type } = parsed.data;
|
||||
const userId = session!.user.id;
|
||||
|
||||
// Toggle: check if reaction exists
|
||||
const existing = await db.query.commentReactions.findFirst({
|
||||
where: and(
|
||||
eq(commentReactions.commentId, commentId),
|
||||
eq(commentReactions.userId, userId),
|
||||
eq(commentReactions.type, type),
|
||||
),
|
||||
});
|
||||
|
||||
let added: boolean;
|
||||
if (existing) {
|
||||
await db.delete(commentReactions).where(eq(commentReactions.id, existing.id));
|
||||
added = false;
|
||||
} else {
|
||||
await db.insert(commentReactions).values({
|
||||
id: crypto.randomUUID(),
|
||||
commentId,
|
||||
userId,
|
||||
type,
|
||||
});
|
||||
added = true;
|
||||
}
|
||||
|
||||
// Return updated counts
|
||||
const rows = await db
|
||||
.select({ type: commentReactions.type, cnt: count() })
|
||||
.from(commentReactions)
|
||||
.where(eq(commentReactions.commentId, commentId))
|
||||
.groupBy(commentReactions.type);
|
||||
|
||||
const counts: Record<string, number> = {};
|
||||
for (const row of rows) {
|
||||
counts[row.type] = row.cnt;
|
||||
}
|
||||
|
||||
return NextResponse.json({ counts, added });
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { db, recipes, comments, users, eq, and } from "@epicure/db";
|
||||
import { requireSession } from "@/lib/api-auth";
|
||||
import { dispatchWebhook } from "@/lib/webhooks";
|
||||
import { sendPushNotification } from "@/lib/push";
|
||||
|
||||
const Schema = z.object({
|
||||
content: z.string().min(1).max(5000),
|
||||
parentId: z.string().optional(),
|
||||
});
|
||||
|
||||
type Params = { params: Promise<{ id: string }> };
|
||||
|
||||
export async function GET(_req: NextRequest, { params }: Params) {
|
||||
const { id } = await params;
|
||||
const recipe = await db.query.recipes.findFirst({ where: eq(recipes.id, id) });
|
||||
if (!recipe || recipe.visibility === "private") {
|
||||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
id: comments.id,
|
||||
content: comments.content,
|
||||
parentId: comments.parentId,
|
||||
createdAt: comments.createdAt,
|
||||
updatedAt: comments.updatedAt,
|
||||
userId: comments.userId,
|
||||
userName: users.name,
|
||||
userUsername: users.username,
|
||||
userAvatarUrl: users.avatarUrl,
|
||||
})
|
||||
.from(comments)
|
||||
.innerJoin(users, eq(comments.userId, users.id))
|
||||
.where(eq(comments.recipeId, id))
|
||||
.orderBy(comments.createdAt);
|
||||
|
||||
return NextResponse.json(rows);
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest, { params }: Params) {
|
||||
const { session, response } = await requireSession();
|
||||
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 });
|
||||
}
|
||||
|
||||
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.parentId) {
|
||||
const parent = await db.query.comments.findFirst({
|
||||
where: and(eq(comments.id, parsed.data.parentId), eq(comments.recipeId, id)),
|
||||
});
|
||||
if (!parent) return NextResponse.json({ error: "Parent comment not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const commentId = crypto.randomUUID();
|
||||
await db.insert(comments).values({
|
||||
id: commentId,
|
||||
recipeId: id,
|
||||
userId: session!.user.id,
|
||||
parentId: parsed.data.parentId,
|
||||
content: parsed.data.content,
|
||||
});
|
||||
|
||||
// Dispatch to recipe author's webhooks (not to the commenter themselves)
|
||||
if (recipe.authorId !== session!.user.id) {
|
||||
void dispatchWebhook(recipe.authorId, "comment.added", { commentId, recipeId: id, recipeTitle: recipe.title });
|
||||
void sendPushNotification(recipe.authorId, {
|
||||
title: "New comment on your recipe",
|
||||
body: `${session!.user.name} commented on "${recipe.title}"`,
|
||||
url: `/recipes/${id}`,
|
||||
});
|
||||
}
|
||||
return NextResponse.json({ id: commentId }, { status: 201 });
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { db, recipes, cookingHistory, pantryItems, recipeIngredients, eq, and } from "@epicure/db";
|
||||
import { requireSession } from "@/lib/api-auth";
|
||||
|
||||
type Params = { params: Promise<{ id: string }> };
|
||||
|
||||
const Schema = z.object({
|
||||
servings: z.number().int().min(1).optional(),
|
||||
notes: z.string().max(2000).optional(),
|
||||
deductFromPantry: z.boolean().default(true),
|
||||
});
|
||||
|
||||
export async function POST(req: NextRequest, { params }: Params) {
|
||||
const { session, response } = await requireSession();
|
||||
if (response) return response;
|
||||
const { id } = await params;
|
||||
const userId = session!.user.id;
|
||||
|
||||
const recipe = await db.query.recipes.findFirst({ where: eq(recipes.id, id) });
|
||||
if (!recipe || (recipe.visibility === "private" && recipe.authorId !== userId)) {
|
||||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const body = await req.json().catch(() => ({})) as unknown;
|
||||
const parsed = Schema.safeParse(body);
|
||||
const data = parsed.success ? parsed.data : { deductFromPantry: true };
|
||||
|
||||
await db.insert(cookingHistory).values({
|
||||
id: crypto.randomUUID(),
|
||||
userId,
|
||||
recipeId: id,
|
||||
servings: data.servings,
|
||||
notes: data.notes,
|
||||
cookedAt: new Date(),
|
||||
});
|
||||
|
||||
if (data.deductFromPantry) {
|
||||
const ings = await db.query.recipeIngredients.findMany({
|
||||
where: eq(recipeIngredients.recipeId, id),
|
||||
});
|
||||
const scale = data.servings ? data.servings / recipe.baseServings : 1;
|
||||
const userPantry = await db.query.pantryItems.findMany({
|
||||
where: eq(pantryItems.userId, userId),
|
||||
});
|
||||
|
||||
for (const ing of ings) {
|
||||
const key = ing.rawName.toLowerCase();
|
||||
const pantryItem = userPantry.find(
|
||||
(p) => p.rawName.toLowerCase() === key && (p.unit ?? "") === (ing.unit ?? "")
|
||||
);
|
||||
if (!pantryItem) continue;
|
||||
|
||||
const pantryQty = pantryItem.quantity ? parseFloat(pantryItem.quantity) : null;
|
||||
const ingQty = ing.quantity ? parseFloat(ing.quantity) * scale : null;
|
||||
|
||||
if (pantryQty !== null && ingQty !== null && !isNaN(pantryQty) && !isNaN(ingQty)) {
|
||||
const remaining = pantryQty - ingQty;
|
||||
if (remaining <= 0) {
|
||||
await db.delete(pantryItems).where(eq(pantryItems.id, pantryItem.id));
|
||||
} else {
|
||||
await db.update(pantryItems)
|
||||
.set({ quantity: String(Math.round(remaining * 10000) / 10000) })
|
||||
.where(eq(pantryItems.id, pantryItem.id));
|
||||
}
|
||||
} else {
|
||||
// no numeric quantity to deduct — remove the item entirely
|
||||
await db.delete(pantryItems).where(eq(pantryItems.id, pantryItem.id));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ logged: true }, { status: 201 });
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { db, recipes, favorites, eq, and } from "@epicure/db";
|
||||
import { requireSession } from "@/lib/api-auth";
|
||||
|
||||
type Params = { params: Promise<{ id: string }> };
|
||||
|
||||
export async function POST(_req: NextRequest, { params }: Params) {
|
||||
const { session, response } = await requireSession();
|
||||
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 });
|
||||
}
|
||||
|
||||
await db.insert(favorites).values({ userId: session!.user.id, recipeId: id }).onConflictDoNothing();
|
||||
return NextResponse.json({ favorited: true });
|
||||
}
|
||||
|
||||
export async function DELETE(_req: NextRequest, { params }: Params) {
|
||||
const { session, response } = await requireSession();
|
||||
if (response) return response;
|
||||
const { id } = await params;
|
||||
|
||||
await db.delete(favorites).where(and(eq(favorites.userId, session!.user.id), eq(favorites.recipeId, id)));
|
||||
return NextResponse.json({ favorited: false });
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { db, recipes, recipeIngredients } from "@epicure/db";
|
||||
import { eq, and, or, ne } from "@epicure/db";
|
||||
import { requireSession } from "@/lib/api-auth";
|
||||
import { estimateNutrition } from "@/lib/ai/features/estimate-nutrition";
|
||||
|
||||
type Params = { params: Promise<{ id: string }> };
|
||||
|
||||
export async function GET(_req: NextRequest, { params }: Params) {
|
||||
const { session, response } = await requireSession();
|
||||
if (response) return response;
|
||||
const { id } = await params;
|
||||
|
||||
const recipe = await db.query.recipes.findFirst({
|
||||
where: and(
|
||||
eq(recipes.id, id),
|
||||
or(
|
||||
eq(recipes.authorId, session!.user.id),
|
||||
ne(recipes.visibility, "private")
|
||||
)
|
||||
),
|
||||
});
|
||||
|
||||
if (!recipe) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
|
||||
return NextResponse.json({ nutrition: recipe.nutritionData ?? null });
|
||||
}
|
||||
|
||||
export async function POST(_req: NextRequest, { params }: Params) {
|
||||
const { session, response } = await requireSession();
|
||||
if (response) return response;
|
||||
const { id } = await params;
|
||||
|
||||
const recipe = await db.query.recipes.findFirst({
|
||||
where: and(
|
||||
eq(recipes.id, id),
|
||||
or(
|
||||
eq(recipes.authorId, session!.user.id),
|
||||
ne(recipes.visibility, "private")
|
||||
)
|
||||
),
|
||||
with: {
|
||||
ingredients: { orderBy: (t, { asc }) => asc(t.order) },
|
||||
},
|
||||
});
|
||||
|
||||
if (!recipe) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
|
||||
const result = await estimateNutrition({
|
||||
title: recipe.title,
|
||||
baseServings: recipe.baseServings,
|
||||
ingredients: recipe.ingredients.map((i) => ({
|
||||
rawName: i.rawName,
|
||||
quantity: i.quantity,
|
||||
unit: i.unit,
|
||||
})),
|
||||
});
|
||||
|
||||
await db.update(recipes).set({ nutritionData: result }).where(eq(recipes.id, id));
|
||||
|
||||
return NextResponse.json({ nutrition: result });
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { db, recipes, ratings, eq, and } from "@epicure/db";
|
||||
import { requireSession } from "@/lib/api-auth";
|
||||
|
||||
const Schema = z.object({
|
||||
score: z.number().int().min(1).max(5),
|
||||
reviewText: z.string().max(2000).optional(),
|
||||
});
|
||||
|
||||
type Params = { params: Promise<{ id: string }> };
|
||||
|
||||
export async function POST(req: NextRequest, { params }: Params) {
|
||||
const { session, response } = await requireSession();
|
||||
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 });
|
||||
|
||||
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, 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,
|
||||
});
|
||||
return NextResponse.json({ created: true }, { status: 201 });
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { db, recipes, recipeIngredients, recipeSteps, recipeSnapshots } from "@epicure/db";
|
||||
import { eq, and, max } from "@epicure/db";
|
||||
import { z } from "zod";
|
||||
import { requireSessionOrApiKey } from "@/lib/api-auth";
|
||||
import { dispatchWebhook } from "@/lib/webhooks";
|
||||
|
||||
const UpdateRecipeSchema = z.object({
|
||||
title: z.string().min(1).max(200).optional(),
|
||||
description: z.string().optional(),
|
||||
baseServings: z.number().int().min(1).max(100).optional(),
|
||||
visibility: z.enum(["private", "unlisted", "public"]).optional(),
|
||||
difficulty: z.enum(["easy", "medium", "hard"]).nullable().optional(),
|
||||
prepMins: z.number().int().min(0).nullable().optional(),
|
||||
cookMins: z.number().int().min(0).nullable().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),
|
||||
quantity: z.string().optional(),
|
||||
unit: z.string().optional(),
|
||||
note: z.string().optional(),
|
||||
order: z.number().int().default(0),
|
||||
})).optional(),
|
||||
steps: z.array(z.object({
|
||||
instruction: z.string().min(1),
|
||||
timerSeconds: z.number().int().optional(),
|
||||
order: z.number().int(),
|
||||
})).optional(),
|
||||
});
|
||||
|
||||
type Params = { params: Promise<{ id: string }> };
|
||||
|
||||
async function getOwnedRecipe(recipeId: string, userId: string) {
|
||||
return db.query.recipes.findFirst({
|
||||
where: and(eq(recipes.id, recipeId), eq(recipes.authorId, userId)),
|
||||
with: { ingredients: { orderBy: (t, { asc }) => asc(t.order) }, steps: { orderBy: (t, { asc }) => asc(t.order) }, photos: true },
|
||||
});
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest, { params }: Params) {
|
||||
const { session, response } = await requireSessionOrApiKey(req, { rateLimit: { limit: 60, windowSeconds: 60 } });
|
||||
if (response) return response;
|
||||
const { id } = await params;
|
||||
|
||||
const recipe = await getOwnedRecipe(id, session!.user.id);
|
||||
if (!recipe) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
|
||||
return NextResponse.json(recipe);
|
||||
}
|
||||
|
||||
export async function PUT(req: NextRequest, { params }: Params) {
|
||||
const { session, response } = await requireSessionOrApiKey(req, { rateLimit: { limit: 60, windowSeconds: 60 } });
|
||||
if (response) return response;
|
||||
const { id } = await params;
|
||||
|
||||
const existing = await db.query.recipes.findFirst({
|
||||
where: and(eq(recipes.id, id), eq(recipes.authorId, session!.user.id)),
|
||||
with: { ingredients: { orderBy: (t, { asc }) => asc(t.order) }, steps: { orderBy: (t, { asc }) => asc(t.order) } },
|
||||
});
|
||||
if (!existing) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
|
||||
// Create a snapshot of the current state before updating
|
||||
const [maxVersionRow] = await db
|
||||
.select({ v: max(recipeSnapshots.version) })
|
||||
.from(recipeSnapshots)
|
||||
.where(eq(recipeSnapshots.recipeId, id));
|
||||
const nextVersion = (maxVersionRow?.v ?? 0) + 1;
|
||||
await db.insert(recipeSnapshots).values({
|
||||
id: crypto.randomUUID(),
|
||||
recipeId: id,
|
||||
authorId: session!.user.id,
|
||||
version: nextVersion,
|
||||
title: existing.title,
|
||||
snapshotData: {
|
||||
title: existing.title,
|
||||
description: existing.description,
|
||||
baseServings: existing.baseServings,
|
||||
difficulty: existing.difficulty,
|
||||
prepMins: existing.prepMins,
|
||||
cookMins: existing.cookMins,
|
||||
dietaryTags: existing.dietaryTags ?? {},
|
||||
ingredients: existing.ingredients.map((i) => ({
|
||||
rawName: i.rawName,
|
||||
quantity: i.quantity,
|
||||
unit: i.unit,
|
||||
note: i.note,
|
||||
order: i.order,
|
||||
})),
|
||||
steps: existing.steps.map((s) => ({
|
||||
instruction: s.instruction,
|
||||
timerSeconds: s.timerSeconds,
|
||||
order: s.order,
|
||||
})),
|
||||
},
|
||||
});
|
||||
|
||||
const body = await req.json() as unknown;
|
||||
const parsed = UpdateRecipeSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: "Validation error", issues: parsed.error.issues }, { status: 400 });
|
||||
}
|
||||
|
||||
const data = parsed.data;
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
const updates: Partial<typeof recipes.$inferInsert> = { updatedAt: new Date() };
|
||||
if (data.title !== undefined) updates.title = data.title;
|
||||
if (data.description !== undefined) updates.description = data.description;
|
||||
if (data.baseServings !== undefined) updates.baseServings = data.baseServings;
|
||||
if (data.visibility !== undefined) updates.visibility = data.visibility;
|
||||
if (data.difficulty !== undefined) updates.difficulty = data.difficulty ?? undefined;
|
||||
if (data.prepMins !== undefined) updates.prepMins = data.prepMins ?? undefined;
|
||||
if (data.cookMins !== undefined) updates.cookMins = data.cookMins ?? undefined;
|
||||
if (data.dietaryTags !== undefined) updates.dietaryTags = data.dietaryTags;
|
||||
|
||||
await tx.update(recipes).set(updates).where(eq(recipes.id, id));
|
||||
|
||||
if (data.ingredients !== undefined) {
|
||||
await tx.delete(recipeIngredients).where(eq(recipeIngredients.recipeId, id));
|
||||
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 !== undefined) {
|
||||
await tx.delete(recipeSteps).where(eq(recipeSteps.recipeId, id));
|
||||
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,
|
||||
}))
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const updated = await getOwnedRecipe(id, session!.user.id);
|
||||
void dispatchWebhook(session!.user.id, "recipe.updated", { id, title: updated?.title });
|
||||
return NextResponse.json(updated);
|
||||
}
|
||||
|
||||
export async function DELETE(req: NextRequest, { params }: Params) {
|
||||
const { session, response } = await requireSessionOrApiKey(req, { rateLimit: { limit: 60, windowSeconds: 60 } });
|
||||
if (response) return response;
|
||||
const { id } = await params;
|
||||
|
||||
const existing = await db.query.recipes.findFirst({
|
||||
where: and(eq(recipes.id, id), eq(recipes.authorId, session!.user.id)),
|
||||
});
|
||||
if (!existing) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
|
||||
await db.delete(recipes).where(eq(recipes.id, id));
|
||||
void dispatchWebhook(session!.user.id, "recipe.deleted", { id });
|
||||
return new NextResponse(null, { status: 204 });
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { db, recipes, recipeIngredients, recipeSteps, recipeSnapshots } from "@epicure/db";
|
||||
import { eq, and, max, desc } from "@epicure/db";
|
||||
import { requireSessionOrApiKey } from "@/lib/api-auth";
|
||||
|
||||
type Params = { params: Promise<{ id: string; versionId: string }> };
|
||||
|
||||
export async function GET(req: NextRequest, { params }: Params) {
|
||||
const { session, response } = await requireSessionOrApiKey(req, { rateLimit: { limit: 60, windowSeconds: 60 } });
|
||||
if (response) return response;
|
||||
const { id, versionId } = await params;
|
||||
|
||||
// Verify ownership
|
||||
const recipe = await db.query.recipes.findFirst({
|
||||
where: and(eq(recipes.id, id), eq(recipes.authorId, session!.user.id)),
|
||||
});
|
||||
if (!recipe) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
|
||||
const snapshot = await db.query.recipeSnapshots.findFirst({
|
||||
where: and(
|
||||
eq(recipeSnapshots.id, versionId),
|
||||
eq(recipeSnapshots.recipeId, id),
|
||||
eq(recipeSnapshots.authorId, session!.user.id)
|
||||
),
|
||||
});
|
||||
if (!snapshot) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
|
||||
return NextResponse.json(snapshot);
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest, { params }: Params) {
|
||||
const { session, response } = await requireSessionOrApiKey(req, { rateLimit: { limit: 60, windowSeconds: 60 } });
|
||||
if (response) return response;
|
||||
const { id, versionId } = await params;
|
||||
|
||||
const body = await req.json() as { action?: string };
|
||||
if (body.action !== "restore") {
|
||||
return NextResponse.json({ error: "Invalid action" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Verify ownership and get current recipe with relations
|
||||
const currentRecipe = await db.query.recipes.findFirst({
|
||||
where: and(eq(recipes.id, id), eq(recipes.authorId, session!.user.id)),
|
||||
with: { ingredients: { orderBy: (t, { asc }) => asc(t.order) }, steps: { orderBy: (t, { asc }) => asc(t.order) } },
|
||||
});
|
||||
if (!currentRecipe) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
|
||||
// Get the snapshot to restore
|
||||
const snapshot = await db.query.recipeSnapshots.findFirst({
|
||||
where: and(
|
||||
eq(recipeSnapshots.id, versionId),
|
||||
eq(recipeSnapshots.recipeId, id),
|
||||
eq(recipeSnapshots.authorId, session!.user.id)
|
||||
),
|
||||
});
|
||||
if (!snapshot) return NextResponse.json({ error: "Snapshot not found" }, { status: 404 });
|
||||
|
||||
// Create a snapshot of the current state before restoring
|
||||
const [maxVersionRow] = await db
|
||||
.select({ v: max(recipeSnapshots.version) })
|
||||
.from(recipeSnapshots)
|
||||
.where(eq(recipeSnapshots.recipeId, id));
|
||||
const nextVersion = (maxVersionRow?.v ?? 0) + 1;
|
||||
await db.insert(recipeSnapshots).values({
|
||||
id: crypto.randomUUID(),
|
||||
recipeId: id,
|
||||
authorId: session!.user.id,
|
||||
version: nextVersion,
|
||||
title: currentRecipe.title,
|
||||
snapshotData: {
|
||||
title: currentRecipe.title,
|
||||
description: currentRecipe.description,
|
||||
baseServings: currentRecipe.baseServings,
|
||||
difficulty: currentRecipe.difficulty,
|
||||
prepMins: currentRecipe.prepMins,
|
||||
cookMins: currentRecipe.cookMins,
|
||||
dietaryTags: currentRecipe.dietaryTags ?? {},
|
||||
ingredients: currentRecipe.ingredients.map((i) => ({
|
||||
rawName: i.rawName,
|
||||
quantity: i.quantity,
|
||||
unit: i.unit,
|
||||
note: i.note,
|
||||
order: i.order,
|
||||
})),
|
||||
steps: currentRecipe.steps.map((s) => ({
|
||||
instruction: s.instruction,
|
||||
timerSeconds: s.timerSeconds,
|
||||
order: s.order,
|
||||
})),
|
||||
},
|
||||
});
|
||||
|
||||
// Restore the recipe from the snapshot
|
||||
const data = snapshot.snapshotData as {
|
||||
title: string;
|
||||
description?: string | null;
|
||||
baseServings: number;
|
||||
difficulty?: string | null;
|
||||
prepMins?: number | null;
|
||||
cookMins?: number | null;
|
||||
dietaryTags?: Record<string, boolean>;
|
||||
ingredients: Array<{ rawName: string; quantity?: string | null; unit?: string | null; note?: string | null; order: number }>;
|
||||
steps: Array<{ instruction: string; timerSeconds?: number | null; order: number }>;
|
||||
};
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
await tx.update(recipes).set({
|
||||
title: data.title,
|
||||
description: data.description ?? null,
|
||||
baseServings: data.baseServings,
|
||||
difficulty: (data.difficulty as "easy" | "medium" | "hard" | null) ?? null,
|
||||
prepMins: data.prepMins ?? null,
|
||||
cookMins: data.cookMins ?? null,
|
||||
dietaryTags: data.dietaryTags ?? {},
|
||||
updatedAt: new Date(),
|
||||
}).where(eq(recipes.id, id));
|
||||
|
||||
await tx.delete(recipeIngredients).where(eq(recipeIngredients.recipeId, id));
|
||||
if (data.ingredients.length > 0) {
|
||||
await tx.insert(recipeIngredients).values(
|
||||
data.ingredients.map((ing) => ({
|
||||
id: crypto.randomUUID(),
|
||||
recipeId: id,
|
||||
rawName: ing.rawName,
|
||||
quantity: ing.quantity ?? undefined,
|
||||
unit: ing.unit ?? undefined,
|
||||
note: ing.note ?? undefined,
|
||||
order: ing.order,
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
await tx.delete(recipeSteps).where(eq(recipeSteps.recipeId, id));
|
||||
if (data.steps.length > 0) {
|
||||
await tx.insert(recipeSteps).values(
|
||||
data.steps.map((step) => ({
|
||||
id: crypto.randomUUID(),
|
||||
recipeId: id,
|
||||
instruction: step.instruction,
|
||||
timerSeconds: step.timerSeconds ?? undefined,
|
||||
order: step.order,
|
||||
}))
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { db, recipes, recipeSnapshots } from "@epicure/db";
|
||||
import { eq, and, desc } from "@epicure/db";
|
||||
import { requireSessionOrApiKey } from "@/lib/api-auth";
|
||||
|
||||
type Params = { params: Promise<{ id: string }> };
|
||||
|
||||
export async function GET(req: NextRequest, { params }: Params) {
|
||||
const { session, response } = await requireSessionOrApiKey(req, { rateLimit: { limit: 60, windowSeconds: 60 } });
|
||||
if (response) return response;
|
||||
const { id } = await params;
|
||||
|
||||
// Verify ownership
|
||||
const recipe = await db.query.recipes.findFirst({
|
||||
where: and(eq(recipes.id, id), eq(recipes.authorId, session!.user.id)),
|
||||
});
|
||||
if (!recipe) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
|
||||
const versions = await db
|
||||
.select({
|
||||
id: recipeSnapshots.id,
|
||||
version: recipeSnapshots.version,
|
||||
title: recipeSnapshots.title,
|
||||
createdAt: recipeSnapshots.createdAt,
|
||||
})
|
||||
.from(recipeSnapshots)
|
||||
.where(and(eq(recipeSnapshots.recipeId, id), eq(recipeSnapshots.authorId, session!.user.id)))
|
||||
.orderBy(desc(recipeSnapshots.version))
|
||||
.limit(20);
|
||||
|
||||
return NextResponse.json(versions);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { requireSession } from "@/lib/api-auth";
|
||||
import { db, recipes, eq, and, inArray } from "@epicure/db";
|
||||
|
||||
const BulkDeleteSchema = z.object({
|
||||
ids: z.array(z.string()).min(1).max(100),
|
||||
});
|
||||
|
||||
const BulkUpdateSchema = z.object({
|
||||
ids: z.array(z.string()).min(1).max(100),
|
||||
visibility: z.enum(["private", "unlisted", "public"]).optional(),
|
||||
});
|
||||
|
||||
export async function DELETE(req: NextRequest) {
|
||||
const session = await requireSession();
|
||||
if (session instanceof NextResponse) return session;
|
||||
|
||||
const body = BulkDeleteSchema.safeParse(await req.json());
|
||||
if (!body.success) return NextResponse.json({ error: "Invalid request" }, { status: 400 });
|
||||
|
||||
await db
|
||||
.delete(recipes)
|
||||
.where(and(inArray(recipes.id, body.data.ids), eq(recipes.authorId, session.user.id)));
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
||||
export async function PATCH(req: NextRequest) {
|
||||
const session = await requireSession();
|
||||
if (session instanceof NextResponse) return session;
|
||||
|
||||
const body = BulkUpdateSchema.safeParse(await req.json());
|
||||
if (!body.success) return NextResponse.json({ error: "Invalid request" }, { status: 400 });
|
||||
|
||||
const { ids, visibility } = body.data;
|
||||
if (!visibility) return NextResponse.json({ error: "Nothing to update" }, { status: 400 });
|
||||
|
||||
await db
|
||||
.update(recipes)
|
||||
.set({ visibility, updatedAt: new Date() })
|
||||
.where(and(inArray(recipes.id, ids), eq(recipes.authorId, session.user.id)));
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { db, recipes, recipeIngredients, recipeSteps } from "@epicure/db";
|
||||
import { eq, desc, and } from "@epicure/db";
|
||||
import { z } from "zod";
|
||||
import { requireSessionOrApiKey } from "@/lib/api-auth";
|
||||
import { checkTierLimit, incrementUsage } from "@/lib/tiers";
|
||||
import { dispatchWebhook } from "@/lib/webhooks";
|
||||
|
||||
const UNICODE_FRACTIONS: Record<string, number> = {
|
||||
"½": 0.5, "⅓": 1/3, "⅔": 2/3, "¼": 0.25, "¾": 0.75,
|
||||
"⅕": 0.2, "⅖": 0.4, "⅗": 0.6, "⅘": 0.8,
|
||||
"⅙": 1/6, "⅚": 5/6, "⅛": 0.125, "⅜": 0.375, "⅝": 0.625, "⅞": 0.875,
|
||||
};
|
||||
|
||||
function parseQuantity(v: string | number | undefined): string | undefined {
|
||||
if (v === undefined || v === "") return undefined;
|
||||
const s = String(v).trim();
|
||||
if (!s) return undefined;
|
||||
|
||||
if (UNICODE_FRACTIONS[s] !== undefined) return String(UNICODE_FRACTIONS[s]);
|
||||
|
||||
for (const [frac, val] of Object.entries(UNICODE_FRACTIONS)) {
|
||||
if (s.endsWith(frac)) {
|
||||
const whole = s.slice(0, -frac.length).trim();
|
||||
if (!whole) return String(val);
|
||||
const w = parseFloat(whole);
|
||||
if (!isNaN(w)) return String(w + val);
|
||||
}
|
||||
}
|
||||
|
||||
const mixed = s.match(/^(\d+)\s+(\d+)\s*\/\s*(\d+)$/);
|
||||
if (mixed) {
|
||||
const den = parseInt(mixed[3]!);
|
||||
if (den !== 0) return String(parseInt(mixed[1]!) + parseInt(mixed[2]!) / den);
|
||||
}
|
||||
|
||||
const slash = s.match(/^(\d+)\s*\/\s*(\d+)$/);
|
||||
if (slash) {
|
||||
const den = parseInt(slash[2]!);
|
||||
if (den !== 0) return String(parseInt(slash[1]!) / den);
|
||||
}
|
||||
|
||||
const n = parseFloat(s);
|
||||
return isNaN(n) ? undefined : String(n);
|
||||
}
|
||||
|
||||
const CreateRecipeSchema = z.object({
|
||||
title: z.string().min(1).max(200),
|
||||
description: z.string().optional(),
|
||||
baseServings: z.number().int().min(1).max(100).default(4),
|
||||
visibility: z.enum(["private", "unlisted", "public"]).default("private"),
|
||||
difficulty: z.enum(["easy", "medium", "hard"]).optional(),
|
||||
prepMins: z.number().int().min(0).optional(),
|
||||
cookMins: z.number().int().min(0).optional(),
|
||||
aiGenerated: z.boolean().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),
|
||||
quantity: z.union([z.number(), z.string()]).optional().transform(parseQuantity),
|
||||
unit: z.string().optional(),
|
||||
note: z.string().optional(),
|
||||
order: z.number().int().default(0),
|
||||
})).default([]),
|
||||
steps: z.array(z.object({
|
||||
instruction: z.string().min(1),
|
||||
timerSeconds: z.number().int().optional(),
|
||||
order: z.number().int().optional(),
|
||||
})).default([]),
|
||||
});
|
||||
|
||||
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" | null;
|
||||
|
||||
const conditions = [eq(recipes.authorId, session!.user.id)];
|
||||
if (visibility) conditions.push(eq(recipes.visibility, visibility));
|
||||
|
||||
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 });
|
||||
}
|
||||
|
||||
await checkTierLimit(session!.user.id, session!.user.tier as "free" | "pro", "recipe");
|
||||
|
||||
const id = crypto.randomUUID();
|
||||
const now = new Date();
|
||||
const data = parsed.data;
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
await tx.insert(recipes).values({
|
||||
id,
|
||||
authorId: session!.user.id,
|
||||
title: data.title,
|
||||
description: data.description,
|
||||
baseServings: data.baseServings,
|
||||
visibility: data.visibility,
|
||||
difficulty: data.difficulty,
|
||||
prepMins: data.prepMins,
|
||||
cookMins: data.cookMins,
|
||||
dietaryTags: data.dietaryTags ?? {},
|
||||
aiGenerated: data.aiGenerated ?? false,
|
||||
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,
|
||||
}))
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
await incrementUsage(session!.user.id, "recipe");
|
||||
|
||||
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 });
|
||||
}
|
||||
Reference in New Issue
Block a user