Update features and dependencies

This commit is contained in:
Arnaud
2026-07-01 11:10:37 +02:00
parent 9d9dfb46c6
commit 8b57a3fd87
107 changed files with 14654 additions and 458 deletions
+7 -2
View File
@@ -24,7 +24,12 @@ export type RecipeResult = {
cookMins: number | null;
};
export default async function ExplorePage() {
export default async function ExplorePage({
searchParams,
}: {
searchParams: Promise<{ q?: string }>;
}) {
const { q } = await searchParams;
const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
// Trending: public recipes ordered by favorite count in last 7 days
@@ -71,5 +76,5 @@ export default async function ExplorePage() {
const trending: RecipeResult[] = trendingRows.map(({ favoriteCount: _fc, ...r }) => r);
const recent: RecipeResult[] = recentRows;
return <ExplorePageContent trending={trending} recent={recent} />;
return <ExplorePageContent trending={trending} recent={recent} initialQuery={q ?? ""} />;
}
+5 -1
View File
@@ -1,7 +1,7 @@
import type { Metadata } from "next";
import { headers } from "next/headers";
import Link from "next/link";
import { ChevronLeft, ChevronRight, ShoppingCart } from "lucide-react";
import { ChevronLeft, ChevronRight, ShoppingCart, Printer } from "lucide-react";
import { auth } from "@/lib/auth/server";
import { db, mealPlans, recipes, eq, and, desc } from "@epicure/db";
import { buttonVariants } from "@/components/ui/button";
@@ -86,6 +86,10 @@ export default async function MealPlanPage({
<ShoppingCart className="h-4 w-4" />
Shopping lists
</Link>
<Link href={`/print/meal-plan?week=${weekStart}`} target="_blank" className={cn(buttonVariants({ variant: "outline", size: "sm" }))}>
<Printer className="h-4 w-4" />
Print
</Link>
<Link href={`/meal-plan?week=${prevWeek}`} className={cn(buttonVariants({ variant: "outline", size: "sm" }))}>
<ChevronLeft className="h-4 w-4" />
</Link>
@@ -35,6 +35,7 @@ export default async function EditRecipePage({ params }: Params) {
difficulty: recipe.difficulty,
prepMins: recipe.prepMins,
cookMins: recipe.cookMins,
tags: recipe.tags ?? [],
dietaryTags: (recipe.dietaryTags as Record<string, boolean | undefined>) ?? {},
ingredients: recipe.ingredients.map((ing) => ({
id: ing.id,
+54 -14
View File
@@ -13,18 +13,21 @@ 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 { GenerateContentButton } from "@/components/recipe/generate-content-button";
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 { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
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";
import { RecipeChatPanel } from "@/components/recipe/recipe-chat-panel";
type Params = { params: Promise<{ id: string }> };
@@ -86,14 +89,30 @@ export default async function RecipePage({ params }: Params) {
{/* Header */}
<div className="space-y-4">
<h1 className="text-3xl font-bold tracking-tight">{recipe.title}</h1>
<TooltipProvider>
<div className="flex items-center gap-2 overflow-x-auto pb-0.5 [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden">
{recipe.steps.length > 0 && (
<Tooltip>
<TooltipTrigger render={
<Link href={`/recipes/${id}/cook`} className={cn(buttonVariants({ variant: "ghost", size: "icon" }))}>
<Play className="h-4 w-4" />
</Link>
} />
<TooltipContent>Cook</TooltipContent>
</Tooltip>
)}
<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>
<Tooltip>
<TooltipTrigger render={
<Link href={`/r/${id}`} target="_blank" className={cn(buttonVariants({ variant: "ghost", size: "icon" }))}>
<ExternalLink className="h-4 w-4" />
</Link>
} />
<TooltipContent>View publicly</TooltipContent>
</Tooltip>
)}
{recipe.ingredients.length > 0 && (
<AddToShoppingListButton
@@ -107,7 +126,9 @@ export default async function RecipePage({ params }: Params) {
}))}
/>
)}
<TranslateButton recipeId={id} />
{(!recipe.language || recipe.language !== (session.user as { locale?: string }).locale) && (
<TranslateButton recipeId={id} />
)}
{recipe.ingredients.length > 0 && (
<AdaptRecipeButton
recipeId={id}
@@ -133,20 +154,19 @@ export default async function RecipePage({ params }: Params) {
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>
<Tooltip>
<TooltipTrigger render={
<Link href={`/recipes/${id}/edit`} className={cn(buttonVariants({ variant: "ghost", size: "icon" }))}>
<Pencil className="h-4 w-4" />
</Link>
} />
<TooltipContent>Edit</TooltipContent>
</Tooltip>
<DeleteRecipeButton recipeId={id} />
</div>
</TooltipProvider>
{avgScore !== null && (
<div className="flex items-center gap-2">
@@ -210,6 +230,24 @@ export default async function RecipePage({ params }: Params) {
<Separator />
{/* Empty state */}
{recipe.ingredients.length === 0 && recipe.steps.length === 0 && (
<div className="flex flex-col items-center gap-4 py-12 text-center">
<p className="text-muted-foreground">No ingredients or steps yet.</p>
<div className="flex items-center gap-2">
<GenerateContentButton
recipeId={id}
title={recipe.title}
description={recipe.description}
/>
<Link href={`/recipes/${id}/edit`} className={cn(buttonVariants({ variant: "outline", size: "sm" }))}>
<Pencil className="h-4 w-4" />
Edit manually
</Link>
</div>
</div>
)}
{/* Serving scaler + ingredients */}
{recipe.ingredients.length > 0 && (
<div className="space-y-4">
@@ -285,6 +323,8 @@ export default async function RecipePage({ params }: Params) {
<CommentsSection recipeId={id} currentUserId={session.user.id} />
</>
)}
<RecipeChatPanel recipeId={id} recipeTitle={recipe.title} />
</div>
);
}
+49 -22
View File
@@ -1,48 +1,75 @@
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 { db, recipes, sql } from "@epicure/db";
import { eq, desc, asc, and, ilike, or } 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 }>;
type SearchParams = Promise<{
q?: string;
sort?: string;
visibility?: string;
difficulty?: string;
tag?: string;
}>;
const SORT_MAP = {
updated_desc: desc(recipes.updatedAt),
updated_asc: asc(recipes.updatedAt),
created_desc: desc(recipes.createdAt),
created_asc: asc(recipes.createdAt),
title_asc: asc(recipes.title),
title_desc: desc(recipes.title),
} as const;
type SortKey = keyof typeof SORT_MAP;
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 { q, sort, visibility, difficulty, tag } = await searchParams;
const query = (q ?? "").trim().slice(0, 200);
const sortKey: SortKey = (sort && sort in SORT_MAP ? sort : "updated_desc") as SortKey;
const tagFilter = tag?.trim().slice(0, 50) || undefined;
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 visibilityFilter = visibility && ["private", "unlisted", "public"].includes(visibility)
? (visibility as "private" | "unlisted" | "public")
: undefined;
const difficultyFilter = difficulty && ["easy", "medium", "hard"].includes(difficulty)
? (difficulty as "easy" | "medium" | "hard")
: undefined;
const userRecipes = await db.query.recipes.findMany({
where: whereClause,
orderBy: desc(recipes.updatedAt),
where: and(
eq(recipes.authorId, session.user.id),
query
? or(ilike(recipes.title, `%${query}%`), ilike(recipes.description, `%${query}%`))
: undefined,
visibilityFilter ? eq(recipes.visibility, visibilityFilter) : undefined,
difficultyFilter ? eq(recipes.difficulty, difficultyFilter) : undefined,
tagFilter ? sql`${recipes.tags} @> ARRAY[${tagFilter}]::text[]` : undefined,
),
orderBy: SORT_MAP[sortKey],
with: { photos: true },
});
const totalCount = query ? undefined : userRecipes.length;
return (
<div className="space-y-6">
<RecipesHeader count={totalCount ?? userRecipes.length} initialQuery={query} />
<RecipesHeader
count={userRecipes.length}
initialQuery={query}
initialSort={sortKey}
initialVisibility={visibilityFilter ?? ""}
initialDifficulty={difficultyFilter ?? ""}
initialTag={tagFilter ?? ""}
/>
<RecipesEmptyState query={query} count={userRecipes.length} />
<RecipesGrid recipes={userRecipes} />
<RecipesGrid key={`${query}-${sortKey}-${visibilityFilter}-${difficultyFilter}-${tagFilter}`} recipes={userRecipes} />
</div>
);
}
+8
View File
@@ -1,6 +1,7 @@
import type { Metadata } from "next";
import { headers } from "next/headers";
import { auth } from "@/lib/auth/server";
import { db, users, eq } from "@epicure/db";
import { SettingsForm } from "@/components/settings/settings-form";
export const metadata: Metadata = { title: "Profile Settings" };
@@ -9,6 +10,11 @@ export default async function SettingsPage() {
const session = await auth.api.getSession({ headers: await headers() });
if (!session) return null;
const dbUser = await db.query.users.findFirst({
where: eq(users.id, session.user.id),
columns: { bio: true, privateBio: true },
});
return (
<SettingsForm
user={{
@@ -16,6 +22,8 @@ export default async function SettingsPage() {
email: session.user.email,
image: session.user.image ?? null,
locale: (session.user as { locale?: string }).locale ?? "en",
bio: dbUser?.bio ?? null,
privateBio: dbUser?.privateBio ?? null,
}}
/>
);
@@ -1,9 +1,13 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { headers } from "next/headers";
import Link from "next/link";
import { Printer } from "lucide-react";
import { auth } from "@/lib/auth/server";
import { db, shoppingLists, eq, and } from "@epicure/db";
import { ShoppingListView } from "@/components/meal-plan/shopping-list-view";
import { buttonVariants } from "@/components/ui/button";
import { cn } from "@/lib/utils";
type Params = { params: Promise<{ id: string }> };
@@ -23,11 +27,17 @@ export default async function ShoppingListPage({ params }: Params) {
return (
<div className="space-y-6 max-w-lg">
<div>
<h1 className="text-3xl font-bold tracking-tight">{list.name}</h1>
<p className="text-muted-foreground mt-1">
{list.items.length} items{list.generatedAt ? " · Generated from meal plan" : ""}
</p>
<div className="flex items-start justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight">{list.name}</h1>
<p className="text-muted-foreground mt-1">
{list.items.length} items{list.generatedAt ? " · Generated from meal plan" : ""}
</p>
</div>
<Link href={`/print/shopping-list/${id}`} target="_blank" className={cn(buttonVariants({ variant: "outline", size: "sm" }))}>
<Printer className="h-4 w-4" />
Print
</Link>
</div>
<ShoppingListView
listId={id}
+65 -7
View File
@@ -53,10 +53,6 @@ export default function LoginPage() {
}
}
async function handleGoogle() {
await authClient.signIn.social({ provider: "google", callbackURL: "/recipes" });
}
return (
<Card>
<CardHeader className="space-y-1">
@@ -65,9 +61,15 @@ export default function LoginPage() {
</CardHeader>
<form onSubmit={handleSubmit}>
<CardContent className="space-y-4">
<Button variant="outline" className="w-full" type="button" onClick={handleGoogle}>
{t("continueWithGoogle")}
</Button>
<div className="space-y-2">
<Button variant="outline" className="w-full gap-2" type="button" onClick={() => authClient.signIn.social({ provider: "google", callbackURL: "/recipes" })}>
<GoogleIcon />
{t("continueWithGoogle")}
</Button>
<SocialButton provider="github" label={t("continueWithGithub")} icon={<GithubIcon />} />
<SocialButton provider="discord" label={t("continueWithDiscord")} icon={<DiscordIcon />} />
<AuthentikButton label={t("continueWithAuthentik")} />
</div>
<div className="flex items-center gap-2">
<Separator className="flex-1" />
<span className="text-xs text-muted-foreground">{t("or")}</span>
@@ -118,3 +120,59 @@ export default function LoginPage() {
</Card>
);
}
function SocialButton({ provider, label, icon }: { provider: "github" | "discord"; label: string; icon: React.ReactNode }) {
const envKey = provider === "github" ? process.env["NEXT_PUBLIC_GITHUB_ENABLED"] : process.env["NEXT_PUBLIC_DISCORD_ENABLED"];
if (!envKey) return null;
return (
<Button variant="outline" className="w-full gap-2" type="button" onClick={() => authClient.signIn.social({ provider, callbackURL: "/recipes" })}>
{icon}
{label}
</Button>
);
}
function AuthentikButton({ label }: { label: string }) {
if (!process.env["NEXT_PUBLIC_AUTHENTIK_ENABLED"]) return null;
return (
<Button variant="outline" className="w-full gap-2" type="button" onClick={() => (authClient as { signIn: { genericOAuth: (opts: { providerId: string; callbackURL: string }) => Promise<void> } }).signIn.genericOAuth({ providerId: "authentik", callbackURL: "/recipes" })}>
<AuthentikIcon />
{label}
</Button>
);
}
function GoogleIcon() {
return (
<svg viewBox="0 0 24 24" className="h-4 w-4" aria-hidden="true">
<path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" fill="#4285F4"/>
<path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853"/>
<path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" fill="#FBBC05"/>
<path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335"/>
</svg>
);
}
function GithubIcon() {
return (
<svg viewBox="0 0 24 24" className="h-4 w-4 fill-current" aria-hidden="true">
<path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0 0 24 12c0-6.63-5.37-12-12-12z" />
</svg>
);
}
function DiscordIcon() {
return (
<svg viewBox="0 0 24 24" className="h-4 w-4 fill-current" aria-hidden="true">
<path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057.1 18.08.11 18.1.128 18.11a19.9 19.9 0 0 0 5.993 3.03.077.077 0 0 0 .084-.028 14.09 14.09 0 0 0 1.226-1.994.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03z" />
</svg>
);
}
function AuthentikIcon() {
return (
<svg viewBox="0 0 24 24" className="h-4 w-4" stroke="currentColor" strokeWidth="2" fill="none" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5" />
</svg>
);
}
@@ -15,11 +15,7 @@ export async function POST(req: NextRequest) {
subject: "Epicure — test email",
html: verifyEmailHtml(`${process.env["BETTER_AUTH_URL"] ?? "http://localhost:3001"}/verify-email?token=test`),
});
return NextResponse.json({
ok: true,
smtp_host: process.env["SMTP_HOST"] ?? null,
smtp_user: process.env["SMTP_USER"] ?? null,
});
return NextResponse.json({ ok: true });
} catch (err) {
return NextResponse.json({ error: String(err) }, { status: 500 });
}
+8 -6
View File
@@ -3,9 +3,10 @@ import { z } from "zod";
import { and, eq } from "@epicure/db";
import { db, recipes, recipeIngredients, recipeSteps } from "@epicure/db";
import { requireSession } from "@/lib/api-auth";
import { checkTierLimit, incrementUsage } from "@/lib/tiers";
import { checkAndIncrementTierLimit } from "@/lib/tiers";
import { adaptRecipe } from "@/lib/ai/features/adapt-recipe";
import { withUserKey } from "@/lib/ai/resolve-user-key";
import { getUserPrivateBio } from "@/lib/ai/user-bio";
const Schema = z.object({
excludeIngredients: z.array(z.string()).default([]),
@@ -42,9 +43,12 @@ export async function POST(req: NextRequest, { params }: Params) {
return NextResponse.json({ error: "Provide at least one constraint" }, { status: 400 });
}
await checkTierLimit(userId, session!.user.tier as "free" | "pro", "aiCall");
await checkAndIncrementTierLimit(userId, session!.user.tier as "free" | "pro", "aiCall");
const aiConfig = await withUserKey(userId, { provider: parsed.data.provider, model: parsed.data.model });
const [aiConfig, privateBio] = await Promise.all([
withUserKey(userId, { provider: parsed.data.provider, model: parsed.data.model }),
getUserPrivateBio(userId),
]);
const adapted = await adaptRecipe(
{
title: recipe.title,
@@ -57,12 +61,10 @@ export async function POST(req: NextRequest, { params }: Params) {
excludeIngredients: parsed.data.excludeIngredients,
extraConstraints: parsed.data.extraConstraints,
},
aiConfig,
{ ...aiConfig, userContext: privateBio ?? undefined },
(session!.user as { locale?: string }).locale ?? "en"
);
await incrementUsage(userId, "aiCall");
const newId = crypto.randomUUID();
const now = new Date();
+8 -6
View File
@@ -3,9 +3,10 @@ import { z } from "zod";
import { and, eq } from "@epicure/db";
import { db, recipes } from "@epicure/db";
import { requireSession } from "@/lib/api-auth";
import { checkTierLimit, incrementUsage } from "@/lib/tiers";
import { checkAndIncrementTierLimit } from "@/lib/tiers";
import { suggestDrinks } from "@/lib/ai/features/suggest-drinks";
import { withUserKey } from "@/lib/ai/resolve-user-key";
import { getUserPrivateBio } from "@/lib/ai/user-bio";
const Schema = z.object({
count: z.number().int().min(1).max(6).default(4),
@@ -33,9 +34,12 @@ export async function POST(req: NextRequest, { params }: Params) {
const parsed = Schema.safeParse(body ?? {});
if (!parsed.success) return NextResponse.json({ error: "Validation error" }, { status: 400 });
await checkTierLimit(session!.user.id, session!.user.tier as "free" | "pro", "aiCall");
await checkAndIncrementTierLimit(session!.user.id, session!.user.tier as "free" | "pro", "aiCall");
const aiConfig = await withUserKey(session!.user.id, { provider: parsed.data.provider, model: parsed.data.model });
const [aiConfig, privateBio] = await Promise.all([
withUserKey(session!.user.id, { provider: parsed.data.provider, model: parsed.data.model }),
getUserPrivateBio(session!.user.id),
]);
const drinks = await suggestDrinks(
{
title: recipe.title,
@@ -45,11 +49,9 @@ export async function POST(req: NextRequest, { params }: Params) {
ingredients: recipe.ingredients,
},
parsed.data.count,
aiConfig,
{ ...aiConfig, userContext: privateBio ?? undefined },
(session!.user as { locale?: string }).locale ?? "en"
);
await incrementUsage(session!.user.id, "aiCall");
return NextResponse.json({ drinks });
}
@@ -0,0 +1,85 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { requireSession } from "@/lib/api-auth";
import { applyRateLimit } from "@/lib/rate-limit";
import { checkAndIncrementTierLimit } from "@/lib/tiers";
import { generateRecipe } from "@/lib/ai/features/generate-recipe";
import { getUserPrivateBio } from "@/lib/ai/user-bio";
import { db, recipes, recipeIngredients, recipeSteps } from "@epicure/db";
import { parseQuantity } from "@/lib/parse-quantity";
const Schema = z.object({
title: z.string().min(1).max(200),
provider: z.enum(["openai", "anthropic", "openrouter", "ollama"]).optional(),
model: z.string().optional(),
});
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" }, { status: 400 });
}
const limited = await applyRateLimit(`rl:ai:${session!.user.id}`, 10, 60);
if (limited) return limited;
await checkAndIncrementTierLimit(session!.user.id, session!.user.tier as "free" | "pro", "aiCall");
const privateBio = await getUserPrivateBio(session!.user.id);
const recipe = await generateRecipe(parsed.data.title, {
provider: parsed.data.provider,
model: parsed.data.model,
userContext: privateBio ?? undefined,
});
const recipeId = crypto.randomUUID();
await db.insert(recipes).values({
id: recipeId,
authorId: session!.user.id,
title: recipe.title,
description: recipe.description ?? null,
baseServings: recipe.baseServings,
prepMins: recipe.prepMins ?? null,
cookMins: recipe.cookMins ?? null,
difficulty: recipe.difficulty ?? null,
visibility: "private",
aiGenerated: true,
language: "en",
dietaryTags: recipe.dietaryTags ?? {},
tags: [],
});
if (recipe.ingredients?.length) {
await db.insert(recipeIngredients).values(
recipe.ingredients.map((ing, i) => ({
id: crypto.randomUUID(),
recipeId,
rawName: ing.rawName,
quantity: parseQuantity(ing.quantity) ?? null,
unit: ing.unit ?? null,
note: ing.note ?? null,
order: i,
}))
);
}
if (recipe.steps?.length) {
await db.insert(recipeSteps).values(
recipe.steps.map((step, i) => ({
id: crypto.randomUUID(),
recipeId,
instruction: step.instruction,
timerSeconds: step.timerSeconds ?? null,
order: i,
}))
);
}
return NextResponse.json({ id: recipeId });
}
+8 -4
View File
@@ -2,12 +2,14 @@ import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { requireSession } from "@/lib/api-auth";
import { applyRateLimit } from "@/lib/rate-limit";
import { checkTierLimit, incrementUsage } from "@/lib/tiers";
import { checkAndIncrementTierLimit } from "@/lib/tiers";
import { generateRecipe } from "@/lib/ai/features/generate-recipe";
import { getUserPrivateBio } from "@/lib/ai/user-bio";
const Schema = z.object({
prompt: z.string().min(3).max(500),
language: z.string().max(10).default("en"),
difficulty: z.enum(["easy", "medium", "hard"]).optional(),
provider: z.enum(["openai", "anthropic", "openrouter", "ollama"]).optional(),
model: z.string().optional(),
});
@@ -25,15 +27,17 @@ export async function POST(req: NextRequest) {
const limited = await applyRateLimit(`rl:ai:${session!.user.id}`, 10, 60);
if (limited) return limited;
await checkTierLimit(session!.user.id, session!.user.tier as "free" | "pro", "aiCall");
await checkAndIncrementTierLimit(session!.user.id, session!.user.tier as "free" | "pro", "aiCall");
const privateBio = await getUserPrivateBio(session!.user.id);
const recipe = await generateRecipe(parsed.data.prompt, {
provider: parsed.data.provider,
model: parsed.data.model,
language: parsed.data.language,
difficulty: parsed.data.difficulty,
userContext: privateBio ?? undefined,
});
await incrementUsage(session!.user.id, "aiCall");
return NextResponse.json(recipe);
}
+10 -10
View File
@@ -3,7 +3,7 @@ import { z } from "zod";
import { db, recipes, recipeIngredients, recipeSteps } from "@epicure/db";
import { requireSession } from "@/lib/api-auth";
import { applyRateLimit } from "@/lib/rate-limit";
import { checkTierLimit, incrementUsage } from "@/lib/tiers";
import { checkAndIncrementTierLimit, TierLimitError } from "@/lib/tiers";
import { importFromPhoto } from "@/lib/ai/features/import-photo";
import { getModelConfigForUseCase } from "@/lib/ai/resolve-user-key";
@@ -25,16 +25,18 @@ export async function POST(req: NextRequest) {
const limited = await applyRateLimit(`rl:ai:${session!.user.id}`, 5, 60);
if (limited) return limited;
try {
await checkTierLimit(session!.user.id, session!.user.tier as "free" | "pro", "aiCall");
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : "Tier limit reached";
return NextResponse.json({ error: msg }, { status: 403 });
}
const userId = session!.user.id;
const locale = (session!.user as { locale?: string }).locale ?? "en";
try {
await checkAndIncrementTierLimit(userId, session!.user.tier as "free" | "pro", "aiCall");
} catch (err: unknown) {
if (err instanceof TierLimitError) {
return NextResponse.json({ error: "AI call limit reached for your tier" }, { status: 403 });
}
throw err;
}
const aiConfig = await getModelConfigForUseCase(userId, "vision");
// Fall back to vision-capable defaults if no explicit model configured
@@ -51,8 +53,6 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: msg }, { status: 502 });
}
await incrementUsage(userId, "aiCall");
const newRecipeId = crypto.randomUUID();
const now = new Date();
+8 -4
View File
@@ -2,8 +2,9 @@ import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { requireSession } from "@/lib/api-auth";
import { applyRateLimit } from "@/lib/rate-limit";
import { checkTierLimit, incrementUsage } from "@/lib/tiers";
import { checkAndIncrementTierLimit } from "@/lib/tiers";
import { importFromUrl } from "@/lib/ai/features/import-url";
import { validateWebhookUrl } from "@/lib/validate-webhook-url";
const Schema = z.object({
url: z.string().url(),
@@ -21,17 +22,20 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: "Validation error", issues: parsed.error.issues }, { status: 400 });
}
const ssrfError = await validateWebhookUrl(parsed.data.url);
if (ssrfError) {
return NextResponse.json({ error: ssrfError }, { status: 400 });
}
const limited = await applyRateLimit(`rl:ai:${session!.user.id}`, 10, 60);
if (limited) return limited;
await checkTierLimit(session!.user.id, session!.user.tier as "free" | "pro", "aiCall");
await checkAndIncrementTierLimit(session!.user.id, session!.user.tier as "free" | "pro", "aiCall");
const recipe = await importFromUrl(parsed.data.url, {
provider: parsed.data.provider,
model: parsed.data.model,
});
await incrementUsage(session!.user.id, "aiCall");
return NextResponse.json(recipe);
}
@@ -5,6 +5,7 @@ import { requireSession } from "@/lib/api-auth";
import { applyRateLimit } from "@/lib/rate-limit";
import { getDefaultProviderWithKey } from "@/lib/ai/resolve-user-key";
import { generateMealPlan } from "@/lib/ai/features/generate-meal-plan";
import { getUserPrivateBio } from "@/lib/ai/user-bio";
const DAYS = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"] as const;
@@ -15,6 +16,7 @@ const Schema = z.object({
days: z.array(z.enum(DAYS)).min(1).max(7).default([...DAYS]),
usePantry: z.boolean().default(false),
pantryMode: z.boolean().default(false),
difficulty: z.enum(["easy", "medium", "hard"]).optional(),
});
export async function POST(req: NextRequest) {
@@ -32,7 +34,10 @@ export async function POST(req: NextRequest) {
const userId = session!.user.id;
const locale = (session!.user as { locale?: string }).locale ?? "en";
const config = await getDefaultProviderWithKey(userId);
const [config, privateBio] = await Promise.all([
getDefaultProviderWithKey(userId),
getUserPrivateBio(userId),
]);
// pantryMode forces usePantry on so pantry items are always fetched when maximizing pantry use
const effectiveUsePantry = parsed.data.usePantry || parsed.data.pantryMode;
@@ -54,8 +59,9 @@ export async function POST(req: NextRequest) {
pantryItems: pantryItemNames,
days: parsed.data.days,
pantryMode: parsed.data.pantryMode,
difficulty: parsed.data.difficulty,
},
config,
{ ...config, userContext: privateBio ?? undefined },
locale
);
@@ -94,7 +100,7 @@ export async function POST(req: NextRequest) {
id: crypto.randomUUID(),
recipeId,
rawName: ing.rawName,
quantity: ing.quantity ?? null,
quantity: ing.quantity != null ? String(ing.quantity) : null,
unit: ing.unit ?? null,
order: i,
}))
@@ -3,9 +3,10 @@ import { z } from "zod";
import { and, eq } from "@epicure/db";
import { db, recipes } from "@epicure/db";
import { requireSession } from "@/lib/api-auth";
import { checkTierLimit, incrementUsage } from "@/lib/tiers";
import { checkAndIncrementTierLimit } from "@/lib/tiers";
import { suggestPairings } from "@/lib/ai/features/suggest-pairings";
import { withUserKey } from "@/lib/ai/resolve-user-key";
import { getUserPrivateBio } from "@/lib/ai/user-bio";
const Schema = z.object({
count: z.number().int().min(1).max(6).default(4),
@@ -34,9 +35,12 @@ export async function POST(req: NextRequest, { params }: Params) {
const parsed = Schema.safeParse(body ?? {});
if (!parsed.success) return NextResponse.json({ error: "Validation error" }, { status: 400 });
await checkTierLimit(session!.user.id, session!.user.tier as "free" | "pro", "aiCall");
await checkAndIncrementTierLimit(session!.user.id, session!.user.tier as "free" | "pro", "aiCall");
const aiConfig = await withUserKey(session!.user.id, { provider: parsed.data.provider, model: parsed.data.model });
const [aiConfig, privateBio] = await Promise.all([
withUserKey(session!.user.id, { provider: parsed.data.provider, model: parsed.data.model }),
getUserPrivateBio(session!.user.id),
]);
const pairings = await suggestPairings(
{
title: recipe.title,
@@ -46,11 +50,9 @@ export async function POST(req: NextRequest, { params }: Params) {
ingredients: recipe.ingredients,
},
parsed.data.count,
aiConfig,
{ ...aiConfig, userContext: privateBio ?? undefined },
(session!.user as { locale?: string }).locale ?? "en"
);
await incrementUsage(session!.user.id, "aiCall");
return NextResponse.json({ pairings });
}
@@ -0,0 +1,78 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { generateText } from "ai";
import { requireSession } from "@/lib/api-auth";
import { applyRateLimit } from "@/lib/rate-limit";
import { getModelConfigForUseCase } from "@/lib/ai/resolve-user-key";
import { resolveModel } from "@/lib/ai/factory";
import { db, recipes, eq, and } from "@epicure/db";
import { getUserPrivateBio, buildUserBioContext } from "@/lib/ai/user-bio";
const Schema = z.object({
recipeId: z.string().uuid(),
question: z.string().min(1).max(500),
});
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" }, { status: 400 });
}
const limited = await applyRateLimit(`rl:ai:${session!.user.id}`, 30, 60);
if (limited) return limited;
const recipe = await db.query.recipes.findFirst({
where: and(eq(recipes.id, parsed.data.recipeId), eq(recipes.authorId, session!.user.id)),
with: {
ingredients: { orderBy: (t, { asc }) => asc(t.order) },
steps: { orderBy: (t, { asc }) => asc(t.order) },
},
});
if (!recipe) {
return NextResponse.json({ error: "Recipe not found" }, { status: 404 });
}
const ingredientList = recipe.ingredients
.map((ing) => [ing.quantity, ing.unit, ing.rawName, ing.note ? `(${ing.note})` : ""].filter(Boolean).join(" "))
.join("\n");
const stepList = recipe.steps
.map((s, i) => `${i + 1}. ${s.instruction}`)
.join("\n");
const recipeContext = `
Recipe: ${recipe.title}
${recipe.description ? `Description: ${recipe.description}` : ""}
Difficulty: ${recipe.difficulty ?? "unknown"}
Prep: ${recipe.prepMins ?? 0}min | Cook: ${recipe.cookMins ?? 0}min | Servings: ${recipe.baseServings}
INGREDIENTS:
${ingredientList || "None listed"}
STEPS:
${stepList || "None listed"}
`.trim();
const [config, privateBio] = await Promise.all([
getModelConfigForUseCase(session!.user.id, "text"),
getUserPrivateBio(session!.user.id),
]);
const model = resolveModel(config);
const bioContext = buildUserBioContext(privateBio);
const { text } = await generateText({
model,
system: `You are a helpful culinary assistant. Answer questions about the following recipe concisely and accurately. If a question is not related to the recipe or cooking, politely redirect. Keep answers under 200 words.
${recipeContext}${bioContext}`,
prompt: parsed.data.question,
});
return NextResponse.json({ answer: text });
}
@@ -0,0 +1,59 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { generateObject } from "ai";
import { requireSession } from "@/lib/api-auth";
import { applyRateLimit } from "@/lib/rate-limit";
import { getModelConfigForUseCase } from "@/lib/ai/resolve-user-key";
import { resolveModel } from "@/lib/ai/factory";
import { getUserPrivateBio, buildUserBioContext } from "@/lib/ai/user-bio";
const InputSchema = z.object({
prompt: z.string().max(300).optional(),
});
const IdeasSchema = z.object({
ideas: z.array(z.object({
title: z.string(),
description: z.string(),
tags: z.array(z.string()).max(4),
difficulty: z.enum(["easy", "medium", "hard"]),
totalMins: z.number().int().optional(),
})).length(6),
});
export async function POST(req: NextRequest) {
const { session, response } = await requireSession();
if (response) return response;
const body = await req.json() as unknown;
const parsed = InputSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: "Validation error" }, { status: 400 });
}
const limited = await applyRateLimit(`rl:ai:${session!.user.id}`, 10, 60);
if (limited) return limited;
const [config, privateBio] = await Promise.all([
getModelConfigForUseCase(session!.user.id, "text"),
getUserPrivateBio(session!.user.id),
]);
const model = resolveModel(config);
const bioContext = buildUserBioContext(privateBio);
const userContext = bioContext
? `Consider the following user preferences when suggesting ideas:${bioContext}\n\n`
: "";
const prompt = parsed.data.prompt?.trim()
? `${userContext}Generate 6 diverse recipe ideas based on: "${parsed.data.prompt}". Include a mix of difficulty levels.`
: `${userContext}Generate 6 diverse, creative recipe ideas. Include different cuisines, difficulty levels, and meal types.`;
const { object } = await generateObject({
model,
schema: IdeasSchema,
prompt,
});
return NextResponse.json(object.ideas);
}
+2 -4
View File
@@ -4,7 +4,7 @@ import { db, recipes, eq, and, or } from "@epicure/db";
import { requireSession } from "@/lib/api-auth";
import { applyRateLimit } from "@/lib/rate-limit";
import { getDefaultProviderWithKey } from "@/lib/ai/resolve-user-key";
import { checkTierLimit, incrementUsage } from "@/lib/tiers";
import { checkAndIncrementTierLimit } from "@/lib/tiers";
import { scaleRecipe } from "@/lib/ai/features/scale-recipe";
const Schema = z.object({
@@ -40,7 +40,7 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
await checkTierLimit(session!.user.id, session!.user.tier as "free" | "pro", "aiCall");
await checkAndIncrementTierLimit(session!.user.id, session!.user.tier as "free" | "pro", "aiCall");
const aiConfig = await getDefaultProviderWithKey(session!.user.id);
@@ -56,7 +56,5 @@ export async function POST(req: NextRequest) {
(session!.user as { locale?: string }).locale ?? "en"
);
await incrementUsage(session!.user.id, "aiCall");
return NextResponse.json({ ingredients: scaledIngredients });
}
+13 -3
View File
@@ -1,7 +1,8 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { requireSession } from "@/lib/api-auth";
import { checkTierLimit, incrementUsage } from "@/lib/tiers";
import { checkAndIncrementTierLimit, TierLimitError } from "@/lib/tiers";
import { applyRateLimit } from "@/lib/rate-limit";
import { substituteIngredient } from "@/lib/ai/features/substitute-ingredient";
const Schema = z.object({
@@ -19,7 +20,17 @@ export async function POST(req: NextRequest) {
const parsed = Schema.safeParse(body);
if (!parsed.success) return NextResponse.json({ error: "Validation error" }, { status: 400 });
await checkTierLimit(session!.user.id, session!.user.tier as "free" | "pro", "aiCall");
const rateLimitRes = await applyRateLimit(`ai:substitute:${session!.user.id}`, 10, 60);
if (rateLimitRes) return rateLimitRes;
try {
await checkAndIncrementTierLimit(session!.user.id, session!.user.tier as "free" | "pro", "aiCall");
} catch (err) {
if (err instanceof TierLimitError) {
return NextResponse.json({ error: "AI call limit reached for your tier" }, { status: 403 });
}
throw err;
}
const context = parsed.data.recipeTitle
? `recipe "${parsed.data.recipeTitle}"`
@@ -31,6 +42,5 @@ export async function POST(req: NextRequest) {
{ provider: parsed.data.provider, model: parsed.data.model }
);
await incrementUsage(session!.user.id, "aiCall");
return NextResponse.json({ substitutions });
}
@@ -3,7 +3,7 @@ import { z } from "zod";
import { and, eq } from "@epicure/db";
import { db, recipes, recipeIngredients, recipeSteps } from "@epicure/db";
import { requireSession } from "@/lib/api-auth";
import { checkTierLimit, incrementUsage } from "@/lib/tiers";
import { checkAndIncrementTierLimit } from "@/lib/tiers";
import { translateRecipe } from "@/lib/ai/features/translate-recipe";
const Schema = z.object({
@@ -35,7 +35,7 @@ export async function POST(req: NextRequest, { params }: Params) {
return NextResponse.json({ error: "Validation error", issues: parsed.error.issues }, { status: 400 });
}
await checkTierLimit(session!.user.id, session!.user.tier as "free" | "pro", "aiCall");
await checkAndIncrementTierLimit(session!.user.id, session!.user.tier as "free" | "pro", "aiCall");
const translation = await translateRecipe(
{
@@ -48,8 +48,6 @@ export async function POST(req: NextRequest, { params }: Params) {
{ provider: parsed.data.provider, model: parsed.data.model }
);
await incrementUsage(session!.user.id, "aiCall");
// Save as new draft recipe
const newId = crypto.randomUUID();
const now = new Date();
@@ -3,9 +3,10 @@ import { z } from "zod";
import { and, eq } from "@epicure/db";
import { db, recipes } from "@epicure/db";
import { requireSession } from "@/lib/api-auth";
import { checkTierLimit, incrementUsage } from "@/lib/tiers";
import { checkAndIncrementTierLimit } from "@/lib/tiers";
import { suggestVariations } from "@/lib/ai/features/suggest-variations";
import { withUserKey } from "@/lib/ai/resolve-user-key";
import { getUserPrivateBio } from "@/lib/ai/user-bio";
const Schema = z.object({
count: z.number().int().min(1).max(5).default(3),
@@ -37,9 +38,12 @@ export async function POST(req: NextRequest, { params }: Params) {
return NextResponse.json({ error: "Validation error", issues: parsed.error.issues }, { status: 400 });
}
await checkTierLimit(session!.user.id, session!.user.tier as "free" | "pro", "aiCall");
await checkAndIncrementTierLimit(session!.user.id, session!.user.tier as "free" | "pro", "aiCall");
const aiConfig = await withUserKey(session!.user.id, { provider: parsed.data.provider, model: parsed.data.model });
const [aiConfig, privateBio] = await Promise.all([
withUserKey(session!.user.id, { provider: parsed.data.provider, model: parsed.data.model }),
getUserPrivateBio(session!.user.id),
]);
const variations = await suggestVariations(
{
title: recipe.title,
@@ -48,12 +52,10 @@ export async function POST(req: NextRequest, { params }: Params) {
steps: recipe.steps,
},
parsed.data.count,
aiConfig,
{ ...aiConfig, userContext: privateBio ?? undefined },
parsed.data.directions,
(session!.user as { locale?: string }).locale ?? "en"
);
await incrementUsage(session!.user.id, "aiCall");
return NextResponse.json({ variations });
}
+9 -1
View File
@@ -1,7 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
import crypto from "node:crypto";
import { z } from "zod";
import { db, apiKeys, eq } from "@epicure/db";
import { db, apiKeys, eq, sql } from "@epicure/db";
import { requireSession } from "@/lib/api-auth";
const CreateApiKeyBody = z.object({
@@ -38,6 +38,14 @@ export async function POST(req: NextRequest) {
);
}
const [row] = await db
.select({ count: sql<number>`count(*)::int` })
.from(apiKeys)
.where(eq(apiKeys.userId, session!.user.id));
if ((row?.count ?? 0) >= 10) {
return NextResponse.json({ error: "API key limit reached (max 10)" }, { status: 403 });
}
const rawKey = "ek_" + crypto.randomBytes(32).toString("hex");
const keyHash = crypto.createHash("sha256").update(rawKey).digest("hex");
const id = crypto.randomUUID();
@@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { db, collections, collectionRecipes, recipes, eq, and } from "@epicure/db";
import { db, collections, collectionRecipes, recipes, eq, and, or, ne } from "@epicure/db";
import { requireSession } from "@/lib/api-auth";
type Params = { params: Promise<{ id: string }> };
@@ -50,7 +50,12 @@ export async function PUT(req: NextRequest, { params }: Params) {
}
if (data.addRecipeId) {
const recipe = await db.query.recipes.findFirst({ where: eq(recipes.id, data.addRecipeId) });
const recipe = await db.query.recipes.findFirst({
where: and(
eq(recipes.id, data.addRecipeId),
or(eq(recipes.authorId, session!.user.id), ne(recipes.visibility, "private"))
),
});
if (recipe) {
await db.insert(collectionRecipes).values({ collectionId: id, recipeId: data.addRecipeId }).onConflictDoNothing();
}
+2 -2
View File
@@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from "next/server";
import { db, recipes, users, userFollows, eq } from "@epicure/db";
import { db, recipes, users, userFollows, eq, and, ne } from "@epicure/db";
import { requireSession } from "@/lib/api-auth";
import { desc, inArray } from "@epicure/db";
@@ -42,7 +42,7 @@ export async function GET(req: NextRequest) {
})
.from(recipes)
.innerJoin(users, eq(recipes.authorId, users.id))
.where((t) => inArray(t.authorId, followedIds))
.where((t) => and(inArray(t.authorId, followedIds), ne(recipes.visibility, "private")))
.orderBy(desc(recipes.createdAt))
.limit(limit)
.offset(offset);
+2 -2
View File
@@ -7,8 +7,8 @@ const Schema = z.object({
items: z.array(z.object({
rawName: z.string().min(1).max(200),
quantity: z.string().optional(),
unit: z.string().optional(),
})).min(1),
unit: z.string().max(50).optional(),
})).min(1).max(100),
});
export async function POST(req: NextRequest) {
+1 -1
View File
@@ -39,10 +39,10 @@ export async function POST(request: Request) {
.onConflictDoUpdate({
target: pushSubscriptions.endpoint,
set: {
userId: session!.user.id,
p256dh: body.keys.p256dh,
auth: body.keys.auth,
},
setWhere: eq(pushSubscriptions.userId, session!.user.id),
});
return NextResponse.json({ ok: true });
@@ -30,7 +30,8 @@ export async function GET(req: NextRequest, { params }: Params) {
counts[row.type] = row.cnt;
}
// Check for session to return user's reactions
// Optional auth — GET reactions is public; session present means also return user's own reactions
// response intentionally ignored: unauthenticated callers get counts only with empty userReactions
const { session } = await requireSession();
let userReactions: string[] = [];
@@ -6,7 +6,7 @@ import { requireSession } from "@/lib/api-auth";
type Params = { params: Promise<{ id: string }> };
const Schema = z.object({
servings: z.number().int().min(1).optional(),
servings: z.number().int().min(1).max(1000).optional(),
notes: z.string().max(2000).optional(),
deductFromPantry: z.boolean().default(true),
});
@@ -56,7 +56,7 @@ export async function POST(_req: NextRequest, { params }: Params) {
})),
});
await db.update(recipes).set({ nutritionData: result }).where(eq(recipes.id, id));
await db.update(recipes).set({ nutritionData: result }).where(and(eq(recipes.id, id), eq(recipes.authorId, session!.user.id)));
return NextResponse.json({ nutrition: result });
}
+14 -11
View File
@@ -4,15 +4,17 @@ import { eq, and, max } from "@epicure/db";
import { z } from "zod";
import { requireSessionOrApiKey } from "@/lib/api-auth";
import { dispatchWebhook } from "@/lib/webhooks";
import { parseQuantity } from "@/lib/parse-quantity";
const UpdateRecipeSchema = z.object({
title: z.string().min(1).max(200).optional(),
description: z.string().optional(),
description: z.string().max(2000).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(),
prepMins: z.number().int().min(0).max(1440).nullable().optional(),
cookMins: z.number().int().min(0).max(1440).nullable().optional(),
tags: z.array(z.string().min(1).max(50)).max(20).optional(),
dietaryTags: z.object({
vegan: z.boolean().optional(),
vegetarian: z.boolean().optional(),
@@ -23,17 +25,17 @@ const UpdateRecipeSchema = z.object({
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(),
rawName: z.string().min(1).max(200),
quantity: z.union([z.number(), z.string().max(50)]).optional().transform(parseQuantity),
unit: z.string().max(50).optional(),
note: z.string().max(500).optional(),
order: z.number().int().default(0),
})).optional(),
})).max(100).optional(),
steps: z.array(z.object({
instruction: z.string().min(1),
timerSeconds: z.number().int().optional(),
instruction: z.string().min(1).max(2000),
timerSeconds: z.number().int().min(0).max(86400).optional(),
order: z.number().int(),
})).optional(),
})).max(100).optional(),
});
type Params = { params: Promise<{ id: string }> };
@@ -119,6 +121,7 @@ export async function PUT(req: NextRequest, { params }: Params) {
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.tags !== undefined) updates.tags = data.tags;
if (data.dietaryTags !== undefined) updates.dietaryTags = data.dietaryTags;
await tx.update(recipes).set(updates).where(eq(recipes.id, id));
@@ -0,0 +1,154 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
// --- Mock dependencies ---
const mockSession = {
user: { id: "user-1", name: "Test User", email: "test@test.com", tier: "free", role: "user" },
};
vi.mock("@/lib/api-auth", () => ({
requireSessionOrApiKey: vi.fn(),
requireSession: vi.fn(),
}));
vi.mock("@/lib/tiers", () => ({
checkTierLimit: vi.fn(),
incrementUsage: vi.fn(),
TierLimitError: class TierLimitError extends Error {},
}));
vi.mock("@/lib/webhooks", () => ({
dispatchWebhook: vi.fn(),
}));
vi.mock("@/lib/rate-limit", () => ({
applyRateLimit: vi.fn().mockResolvedValue(null),
}));
const { mockTransaction, mockInsertValues, mockSelectChain } = vi.hoisted(() => {
const mockInsertValues = vi.fn().mockResolvedValue(undefined);
const mockSelectChain = {
from: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
orderBy: vi.fn().mockReturnThis(),
limit: vi.fn().mockReturnThis(),
offset: vi.fn().mockResolvedValue([]),
};
return { mockTransaction: vi.fn(), mockInsertValues, mockSelectChain };
});
vi.mock("@epicure/db", () => ({
db: {
select: vi.fn(() => mockSelectChain),
insert: vi.fn(() => ({ values: mockInsertValues })),
transaction: mockTransaction,
query: {
recipes: { findFirst: vi.fn().mockResolvedValue({ id: "new-id", title: "Test Recipe" }) },
},
},
recipes: { id: "id", authorId: "author_id", title: "title", visibility: "visibility" },
recipeIngredients: {},
recipeSteps: {},
eq: vi.fn((a, b) => ({ a, b, op: "eq" })),
desc: vi.fn((a) => ({ a, op: "desc" })),
and: vi.fn((...args) => ({ args, op: "and" })),
count: vi.fn(() => ({ op: "count" })),
ilike: vi.fn((a, b) => ({ a, b, op: "ilike" })),
or: vi.fn((...args) => ({ args, op: "or" })),
sql: vi.fn(),
}));
const { requireSessionOrApiKey } = await import("@/lib/api-auth");
import { GET, POST } from "../route";
function makeRequest(method: string, body?: unknown, search = "") {
const url = `http://localhost/api/v1/recipes${search}`;
return new NextRequest(url, {
method,
headers: { "Content-Type": "application/json" },
body: body ? JSON.stringify(body) : undefined,
});
}
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(requireSessionOrApiKey).mockResolvedValue({ session: mockSession as never, response: null });
mockTransaction.mockImplementation(async (fn: (tx: unknown) => Promise<unknown>) => {
const tx = {
insert: vi.fn(() => ({ values: vi.fn().mockResolvedValue(undefined) })),
};
return fn(tx);
});
});
describe("GET /api/v1/recipes", () => {
it("returns 200 with empty list", async () => {
mockSelectChain.offset.mockResolvedValue([]);
const res = await GET(makeRequest("GET"));
expect(res.status).toBe(200);
const body = await res.json() as { data: unknown[] };
expect(Array.isArray(body.data)).toBe(true);
});
it("returns 401 when not authenticated", async () => {
vi.mocked(requireSessionOrApiKey).mockResolvedValue({
session: null,
response: new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401 }),
} as never);
const res = await GET(makeRequest("GET"));
expect(res.status).toBe(401);
});
});
describe("POST /api/v1/recipes", () => {
const validRecipe = {
title: "Test Recipe",
baseServings: 4,
visibility: "private",
ingredients: [{ rawName: "flour", quantity: "2", unit: "cups" }],
steps: [{ instruction: "Mix everything" }],
};
it("returns 201 on valid recipe creation", async () => {
const res = await POST(makeRequest("POST", validRecipe));
expect(res.status).toBe(201);
const body = await res.json() as { id: string };
expect(body.id).toBeTruthy();
});
it("returns 400 on invalid body (missing title)", async () => {
const res = await POST(makeRequest("POST", { baseServings: 4 }));
expect(res.status).toBe(400);
});
it("returns 400 when title is empty", async () => {
const res = await POST(makeRequest("POST", { ...validRecipe, title: "" }));
expect(res.status).toBe(400);
});
it("returns 400 when title is too long", async () => {
const res = await POST(makeRequest("POST", { ...validRecipe, title: "x".repeat(201) }));
expect(res.status).toBe(400);
});
it("returns 403 when tier limit reached", async () => {
const { checkTierLimit } = await import("@/lib/tiers");
const { TierLimitError } = await import("@/lib/tiers");
vi.mocked(checkTierLimit).mockRejectedValue(new TierLimitError("recipe", "free"));
const res = await POST(makeRequest("POST", validRecipe));
expect(res.status).toBe(403);
});
it("returns 401 when not authenticated", async () => {
vi.mocked(requireSessionOrApiKey).mockResolvedValue({
session: null,
response: new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401 }),
} as never);
const res = await POST(makeRequest("POST", validRecipe));
expect(res.status).toBe(401);
});
});
@@ -0,0 +1,96 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
const mockSession = {
user: { id: "user-1", name: "Test", email: "t@t.com", tier: "free" },
};
const { mockRequireSession } = vi.hoisted(() => ({
mockRequireSession: vi.fn(),
}));
vi.mock("@/lib/api-auth", () => ({
requireSession: mockRequireSession,
}));
vi.mock("@epicure/db", () => ({
db: {
delete: vi.fn(() => ({ where: vi.fn().mockResolvedValue(undefined) })),
update: vi.fn(() => ({
set: vi.fn(() => ({ where: vi.fn().mockResolvedValue(undefined) })),
})),
},
recipes: { id: "id", authorId: "author_id", visibility: "visibility" },
eq: vi.fn(),
and: vi.fn(),
inArray: vi.fn(),
}));
import { DELETE, PATCH } from "../route";
function makeRequest(method: string, body: unknown) {
return new NextRequest(`http://localhost/api/v1/recipes/bulk`, {
method,
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
}
beforeEach(() => {
vi.clearAllMocks();
mockRequireSession.mockResolvedValue({ session: mockSession, response: null });
});
describe("DELETE /api/v1/recipes/bulk", () => {
it("returns 200 on valid ids", async () => {
const res = await DELETE(makeRequest("DELETE", { ids: ["r1", "r2"] }));
expect(res.status).toBe(200);
const body = await res.json() as { ok: boolean };
expect(body.ok).toBe(true);
});
it("returns 400 when ids is empty array", async () => {
const res = await DELETE(makeRequest("DELETE", { ids: [] }));
expect(res.status).toBe(400);
});
it("returns 400 when body is invalid", async () => {
const res = await DELETE(makeRequest("DELETE", { notIds: "wrong" }));
expect(res.status).toBe(400);
});
it("returns 401 when not authenticated", async () => {
mockRequireSession.mockResolvedValue({
session: null,
response: new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401 }),
});
const res = await DELETE(makeRequest("DELETE", { ids: ["r1"] }));
expect(res.status).toBe(401);
});
});
describe("PATCH /api/v1/recipes/bulk", () => {
it("returns 200 on valid visibility update", async () => {
const res = await PATCH(makeRequest("PATCH", { ids: ["r1"], visibility: "public" }));
expect(res.status).toBe(200);
});
it("returns 400 when visibility is missing", async () => {
const res = await PATCH(makeRequest("PATCH", { ids: ["r1"] }));
expect(res.status).toBe(400);
});
it("returns 400 when ids is empty", async () => {
const res = await PATCH(makeRequest("PATCH", { ids: [], visibility: "public" }));
expect(res.status).toBe(400);
});
it("returns 401 when not authenticated", async () => {
mockRequireSession.mockResolvedValue({
session: null,
response: new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401 }),
});
const res = await PATCH(makeRequest("PATCH", { ids: ["r1"], visibility: "public" }));
expect(res.status).toBe(401);
});
});
+6 -6
View File
@@ -13,22 +13,22 @@ const BulkUpdateSchema = z.object({
});
export async function DELETE(req: NextRequest) {
const session = await requireSession();
if (session instanceof NextResponse) return session;
const { session, response } = await requireSession();
if (response) return response;
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)));
.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 { session, response } = await requireSession();
if (response) return response;
const body = BulkUpdateSchema.safeParse(await req.json());
if (!body.success) return NextResponse.json({ error: "Invalid request" }, { status: 400 });
@@ -39,7 +39,7 @@ export async function PATCH(req: NextRequest) {
await db
.update(recipes)
.set({ visibility, updatedAt: new Date() })
.where(and(inArray(recipes.id, ids), eq(recipes.authorId, session.user.id)));
.where(and(inArray(recipes.id, ids), eq(recipes.authorId, session!.user.id)));
return NextResponse.json({ ok: true });
}
+24 -52
View File
@@ -3,56 +3,21 @@ 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 { checkAndIncrementTierLimit, TierLimitError } 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);
}
import { parseQuantity } from "@/lib/parse-quantity";
const CreateRecipeSchema = z.object({
title: z.string().min(1).max(200),
description: z.string().optional(),
description: z.string().max(2000).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(),
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(),
dietaryTags: z.object({
vegan: z.boolean().optional(),
vegetarian: z.boolean().optional(),
@@ -63,17 +28,17 @@ const CreateRecipeSchema = z.object({
kosher: z.boolean().optional(),
}).optional(),
ingredients: z.array(z.object({
rawName: z.string().min(1),
rawName: z.string().min(1).max(200),
quantity: z.union([z.number(), z.string()]).optional().transform(parseQuantity),
unit: z.string().optional(),
note: z.string().optional(),
unit: z.string().max(50).optional(),
note: z.string().max(500).optional(),
order: z.number().int().default(0),
})).default([]),
})).max(100).default([]),
steps: z.array(z.object({
instruction: z.string().min(1),
timerSeconds: z.number().int().optional(),
instruction: z.string().min(1).max(2000),
timerSeconds: z.number().int().min(0).max(86400).optional(),
order: z.number().int().optional(),
})).default([]),
})).max(100).default([]),
});
export async function GET(req: NextRequest) {
@@ -109,7 +74,14 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: "Validation error", issues: parsed.error.issues }, { status: 400 });
}
await checkTierLimit(session!.user.id, session!.user.tier as "free" | "pro", "recipe");
try {
await checkAndIncrementTierLimit(session!.user.id, session!.user.tier as "free" | "pro", "recipe");
} catch (err) {
if (err instanceof TierLimitError) {
return NextResponse.json({ error: "Recipe limit reached for your tier" }, { status: 403 });
}
throw err;
}
const id = crypto.randomUUID();
const now = new Date();
@@ -126,8 +98,10 @@ export async function POST(req: NextRequest) {
difficulty: data.difficulty,
prepMins: data.prepMins,
cookMins: data.cookMins,
tags: data.tags,
dietaryTags: data.dietaryTags ?? {},
aiGenerated: data.aiGenerated ?? false,
language: data.language,
createdAt: now,
updatedAt: now,
});
@@ -159,8 +133,6 @@ export async function POST(req: NextRequest) {
}
});
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 });
+1 -1
View File
@@ -18,7 +18,7 @@ export async function GET(req: NextRequest) {
const { searchParams } = req.nextUrl;
// --- Parse & validate required param ---
const q = searchParams.get("q")?.trim() ?? "";
const q = (searchParams.get("q") ?? "").trim().slice(0, 200);
if (!q) {
return NextResponse.json(
{ error: "Query parameter 'q' is required and must not be empty." },
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { requireSession } from "@/lib/api-auth";
import { createPresignedUploadUrl } from "@/lib/storage";
import { db, recipes, eq, and } from "@epicure/db";
const ALLOWED_TYPES = ["image/jpeg", "image/png", "image/webp", "image/avif"] as const;
type AllowedType = (typeof ALLOWED_TYPES)[number];
@@ -23,6 +24,12 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: "Validation error", issues: parsed.error.issues }, { status: 400 });
}
const owned = await db.query.recipes.findFirst({
where: and(eq(recipes.id, parsed.data.recipeId), eq(recipes.authorId, session!.user.id)),
columns: { id: true },
});
if (!owned) return NextResponse.json({ error: "Not found" }, { status: 404 });
const ext = parsed.data.contentType.split("/")[1] ?? "jpg";
const key = `recipes/${parsed.data.recipeId}/photos/${session!.user.id}-${crypto.randomUUID()}.${ext}`;
const url = await createPresignedUploadUrl(key, parsed.data.contentType);
+2
View File
@@ -7,6 +7,8 @@ import { z } from "zod";
const PatchSchema = z.object({
name: z.string().min(1).max(100).optional(),
locale: z.string().max(10).optional(),
bio: z.string().max(500).optional().nullable(),
privateBio: z.string().max(2000).optional().nullable(),
});
export async function PATCH(req: Request) {
+4 -4
View File
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { db, webhooks, eq, and } from "@epicure/db";
import { requireSession } from "@/lib/api-auth";
import { validateWebhookUrl } from "@/lib/validate-webhook-url";
const VALID_EVENTS = ["recipe.created", "recipe.updated", "recipe.published", "recipe.deleted"] as const;
@@ -66,10 +67,9 @@ export async function PATCH(
}
if (parsed.data.url) {
try {
new URL(parsed.data.url);
} catch {
return NextResponse.json({ error: "Invalid URL" }, { status: 400 });
const ssrfError = await validateWebhookUrl(parsed.data.url);
if (ssrfError) {
return NextResponse.json({ error: ssrfError }, { status: 400 });
}
}
+5 -5
View File
@@ -3,6 +3,7 @@ import crypto from "node:crypto";
import { z } from "zod";
import { db, webhooks, eq } from "@epicure/db";
import { requireSession } from "@/lib/api-auth";
import { validateWebhookUrl } from "@/lib/validate-webhook-url";
const VALID_EVENTS = ["recipe.created", "recipe.updated", "recipe.published", "recipe.deleted"] as const;
@@ -49,11 +50,10 @@ export async function POST(req: NextRequest) {
);
}
// Validate URL format
try {
new URL(parsed.data.url);
} catch {
return NextResponse.json({ error: "Invalid URL" }, { status: 400 });
// Validate URL — enforce https/http only and block SSRF targets
const ssrfError = await validateWebhookUrl(parsed.data.url);
if (ssrfError) {
return NextResponse.json({ error: ssrfError }, { status: 400 });
}
const secret = crypto.randomBytes(32).toString("hex");
+80 -6
View File
@@ -1,19 +1,93 @@
import { NextRequest, NextResponse } from "next/server";
import crypto from "node:crypto";
// Stripe webhook stub — wire up when adding Stripe
// Verifies stripe-signature header (using raw body), handles:
// Stripe webhook handler — verifies stripe-signature header using HMAC-SHA256.
// Handles:
// - checkout.session.completed → upgrade user to pro
// - customer.subscription.deleted → downgrade user to free
const STRIPE_TOLERANCE_SECONDS = 300; // 5 minutes
function verifyStripeSignature(
rawBody: string,
sigHeader: string,
secret: string
): { valid: boolean; payload: string | null } {
// sigHeader format: "t=<timestamp>,v1=<hmac>[,v1=<hmac>...]"
const parts = sigHeader.split(",");
const tPart = parts.find((p) => p.startsWith("t="));
const v1Parts = parts.filter((p) => p.startsWith("v1="));
if (!tPart || v1Parts.length === 0) {
return { valid: false, payload: null };
}
const timestamp = tPart.slice(2);
const tsNum = parseInt(timestamp, 10);
if (isNaN(tsNum)) return { valid: false, payload: null };
// Reject stale webhooks
const nowSec = Math.floor(Date.now() / 1000);
if (Math.abs(nowSec - tsNum) > STRIPE_TOLERANCE_SECONDS) {
return { valid: false, payload: null };
}
const signedPayload = `${timestamp}.${rawBody}`;
const expected = crypto
.createHmac("sha256", secret)
.update(signedPayload, "utf8")
.digest();
const matched = v1Parts.some((v1Part) => {
const provided = v1Part.slice(3); // strip "v1="
let providedBuf: Buffer;
try {
providedBuf = Buffer.from(provided, "hex");
} catch {
return false;
}
if (providedBuf.length !== expected.length) return false;
return crypto.timingSafeEqual(expected, providedBuf);
});
return { valid: matched, payload: matched ? rawBody : null };
}
export async function POST(req: NextRequest) {
const body = await req.text();
const sig = req.headers.get("stripe-signature");
const webhookSecret = process.env["STRIPE_WEBHOOK_SECRET"];
if (!sig || !process.env["STRIPE_WEBHOOK_SECRET"]) {
if (!sig || !webhookSecret) {
return NextResponse.json({ error: "Stripe not configured" }, { status: 400 });
}
// TODO: const event = stripe.webhooks.constructEvent(body, sig, process.env["STRIPE_WEBHOOK_SECRET"]);
// For now just return 200 to acknowledge receipt
console.log("[stripe-webhook] received event, sig:", sig.slice(0, 20));
const { valid } = verifyStripeSignature(body, sig, webhookSecret);
if (!valid) {
return NextResponse.json({ error: "Invalid signature" }, { status: 400 });
}
let event: { type: string; data: { object: Record<string, unknown> } };
try {
event = JSON.parse(body) as typeof event;
} catch {
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
}
// TODO: wire up DB calls when Stripe billing is fully configured
switch (event.type) {
case "checkout.session.completed":
// upgrade user to pro
console.log("[stripe-webhook] checkout.session.completed", event.data.object["id"]);
break;
case "customer.subscription.deleted":
// downgrade user to free
console.log("[stripe-webhook] customer.subscription.deleted", event.data.object["id"]);
break;
default:
// ignore unhandled event types
break;
}
return NextResponse.json({ received: true });
}
+126
View File
@@ -0,0 +1,126 @@
import { headers } from "next/headers";
import { auth } from "@/lib/auth/server";
import { db, mealPlans, eq, and } from "@epicure/db";
import { PrintTrigger } from "@/components/recipe/print-trigger";
const DAYS = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"] as const;
const DAY_LABELS: Record<string, string> = {
mon: "Monday", tue: "Tuesday", wed: "Wednesday", thu: "Thursday",
fri: "Friday", sat: "Saturday", sun: "Sunday",
};
const MEAL_ORDER = ["breakfast", "lunch", "dinner", "snack"] as const;
const MEAL_LABELS: Record<string, string> = {
breakfast: "Breakfast", lunch: "Lunch", dinner: "Dinner", snack: "Snack",
};
function getMonday(dateStr?: string): Date {
const d = dateStr ? new Date(dateStr) : new Date();
const day = d.getDay();
const diff = day === 0 ? -6 : 1 - day;
d.setDate(d.getDate() + diff);
d.setHours(0, 0, 0, 0);
return d;
}
export default async function MealPlanPrintPage({
searchParams,
}: {
searchParams: Promise<{ week?: string }>;
}) {
const { week } = await searchParams;
const session = await auth.api.getSession({ headers: await headers() });
if (!session) return null;
const monday = getMonday(week);
const weekStart = monday.toISOString().slice(0, 10);
const sunday = new Date(monday);
sunday.setDate(sunday.getDate() + 6);
const plan = await db.query.mealPlans.findFirst({
where: and(eq(mealPlans.userId, session.user.id), eq(mealPlans.weekStart, weekStart)),
with: { entries: { with: { recipe: true } } },
});
const label = `${monday.toLocaleDateString("en-US", { month: "short", day: "numeric" })} ${sunday.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })}`;
const entries = plan?.entries ?? [];
return (
<>
<style>{`
@media print {
body { margin: 0; }
.no-print { display: none !important; }
}
body {
font-family: system-ui, sans-serif;
color: #1a1a1a;
max-width: 900px;
margin: 40px auto;
padding: 0 24px;
font-size: 13px;
line-height: 1.5;
}
h1 { font-size: 1.8em; margin: 0 0 2px; }
.subtitle { color: #666; font-size: 0.9em; margin-bottom: 24px; }
table { width: 100%; border-collapse: collapse; table-layout: fixed; }
th { text-align: left; font-size: 0.8em; text-transform: uppercase; letter-spacing: 0.06em; color: #888; padding: 6px 8px; border-bottom: 2px solid #ccc; }
td { padding: 6px 8px; border-bottom: 1px solid #eee; border-right: 1px solid #eee; vertical-align: top; min-height: 40px; }
td:last-child { border-right: none; }
.meal-label { font-size: 0.75em; text-transform: uppercase; letter-spacing: 0.05em; color: #aaa; display: block; margin-bottom: 2px; }
.recipe-title { font-weight: 500; }
.servings { font-size: 0.8em; color: #888; }
.empty { color: #ccc; font-size: 0.85em; }
footer { margin-top: 40px; font-size: 0.75em; color: #aaa; text-align: center; }
.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;
}
.print-btn:hover { background: #3f3f46; }
`}</style>
<PrintTrigger />
<h1>Meal Plan</h1>
<p className="subtitle">{label}</p>
<table>
<thead>
<tr>
<th style={{ width: "100px" }}>Day</th>
{MEAL_ORDER.map((m) => (
<th key={m}>{MEAL_LABELS[m]}</th>
))}
</tr>
</thead>
<tbody>
{DAYS.map((day) => (
<tr key={day}>
<td style={{ fontWeight: 600, color: "#555" }}>{DAY_LABELS[day]}</td>
{MEAL_ORDER.map((mealType) => {
const entry = entries.find((e) => e.day === day && e.mealType === mealType);
return (
<td key={mealType}>
{entry ? (
<>
<span className="recipe-title">{entry.recipe?.title ?? entry.note ?? "—"}</span>
{entry.servings && (
<span className="servings"> · {entry.servings} srv</span>
)}
</>
) : (
<span className="empty"></span>
)}
</td>
);
})}
</tr>
))}
</tbody>
</table>
<footer>Printed from Epicure</footer>
</>
);
}
+92
View File
@@ -0,0 +1,92 @@
import { headers } from "next/headers";
import { auth } from "@/lib/auth/server";
import { db, pantryItems, eq, asc } from "@epicure/db";
import { PrintTrigger } from "@/components/recipe/print-trigger";
export default async function PantryPrintPage() {
const session = await auth.api.getSession({ headers: await headers() });
if (!session) return null;
const items = await db.query.pantryItems.findMany({
where: eq(pantryItems.userId, session.user.id),
orderBy: asc(pantryItems.rawName),
});
const now = new Date();
return (
<>
<style>{`
@media print {
body { margin: 0; }
.no-print { display: none !important; }
}
body {
font-family: system-ui, sans-serif;
color: #1a1a1a;
max-width: 680px;
margin: 40px auto;
padding: 0 24px;
font-size: 14px;
line-height: 1.6;
}
h1 { font-size: 1.8em; margin: 0 0 4px; }
.subtitle { color: #666; font-size: 0.9em; margin-bottom: 24px; }
table { width: 100%; border-collapse: collapse; }
th { text-align: left; font-size: 0.8em; text-transform: uppercase; letter-spacing: 0.06em; color: #888; padding: 4px 8px 4px 0; border-bottom: 1px solid #ccc; }
td { padding: 6px 8px 6px 0; border-bottom: 1px dotted #eee; font-size: 0.9em; vertical-align: top; }
tr:last-child td { border-bottom: none; }
.expiry-soon { color: #b45309; }
.expiry-expired { color: #dc2626; }
footer { margin-top: 40px; font-size: 0.75em; color: #aaa; text-align: center; }
.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;
}
.print-btn:hover { background: #3f3f46; }
`}</style>
<PrintTrigger />
<h1>Pantry</h1>
<p className="subtitle">{items.length} item{items.length !== 1 ? "s" : ""}</p>
{items.length === 0 ? (
<p style={{ color: "#888" }}>No items in pantry.</p>
) : (
<table>
<thead>
<tr>
<th>Item</th>
<th>Quantity</th>
<th>Expires</th>
</tr>
</thead>
<tbody>
{items.map((item) => {
const exp = item.expiresAt ? new Date(item.expiresAt) : null;
const daysLeft = exp ? Math.ceil((exp.getTime() - now.getTime()) / 86400000) : null;
const expiryClass = daysLeft === null ? "" : daysLeft < 0 ? "expiry-expired" : daysLeft <= 3 ? "expiry-soon" : "";
return (
<tr key={item.id}>
<td>{item.rawName}</td>
<td>{[item.quantity, item.unit].filter(Boolean).join(" ") || "—"}</td>
<td className={expiryClass}>
{exp
? daysLeft !== null && daysLeft < 0
? `Expired ${Math.abs(daysLeft)}d ago`
: exp.toLocaleDateString("en", { month: "short", day: "numeric", year: "numeric" })
: "—"}
</td>
</tr>
);
})}
</tbody>
</table>
)}
<footer>Printed from Epicure</footer>
</>
);
}
@@ -0,0 +1,94 @@
import { notFound } from "next/navigation";
import { headers } from "next/headers";
import { auth } from "@/lib/auth/server";
import { db, shoppingLists, eq, and } from "@epicure/db";
import { PrintTrigger } from "@/components/recipe/print-trigger";
type Params = { params: Promise<{ id: string }> };
export default async function ShoppingListPrintPage({ params }: Params) {
const { id } = await params;
const session = await auth.api.getSession({ headers: await headers() });
if (!session) return null;
const list = await db.query.shoppingLists.findFirst({
where: and(eq(shoppingLists.id, id), eq(shoppingLists.userId, session.user.id)),
with: { items: { orderBy: (t, { asc }) => [asc(t.aisle), asc(t.rawName)] } },
});
if (!list) notFound();
const byAisle = list.items.reduce<Record<string, typeof list.items>>((acc, item) => {
const key = item.aisle ?? "Other";
(acc[key] ??= []).push(item);
return acc;
}, {});
const aisles = Object.keys(byAisle).sort((a, b) =>
a === "Other" ? 1 : b === "Other" ? -1 : a.localeCompare(b)
);
return (
<>
<style>{`
@media print {
body { margin: 0; }
.no-print { display: none !important; }
}
body {
font-family: system-ui, sans-serif;
color: #1a1a1a;
max-width: 680px;
margin: 40px auto;
padding: 0 24px;
font-size: 14px;
line-height: 1.6;
}
h1 { font-size: 1.8em; margin: 0 0 4px; }
.subtitle { color: #666; font-size: 0.9em; margin-bottom: 24px; }
h2 { font-size: 0.9em; text-transform: uppercase; letter-spacing: 0.08em; color: #888; border-bottom: 1px solid #e0e0e0; padding-bottom: 4px; margin: 20px 0 8px; }
ul { list-style: none; padding: 0; margin: 0; }
li { display: flex; align-items: baseline; gap: 8px; padding: 5px 0; border-bottom: 1px dotted #eee; font-size: 0.95em; }
li:last-child { border-bottom: none; }
.check { width: 16px; height: 16px; border: 1.5px solid #999; border-radius: 3px; flex-shrink: 0; display: inline-block; }
.checked .check { background: #18181b; border-color: #18181b; }
.checked span { text-decoration: line-through; color: #999; }
.qty { color: #666; min-width: 70px; font-variant-numeric: tabular-nums; }
footer { margin-top: 40px; font-size: 0.75em; color: #aaa; text-align: center; }
.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;
}
.print-btn:hover { background: #3f3f46; }
`}</style>
<PrintTrigger />
<h1>{list.name}</h1>
<p className="subtitle">
{list.items.filter(i => i.checked).length}/{list.items.length} items checked
{list.generatedAt ? " · From meal plan" : ""}
</p>
{aisles.map((aisle) => (
<section key={aisle}>
{aisles.length > 1 && <h2>{aisle}</h2>}
<ul>
{byAisle[aisle]!.map((item) => (
<li key={item.id} className={item.checked ? "checked" : ""}>
<span className="check" />
<span className="qty">
{[item.quantity, item.unit].filter(Boolean).join(" ")}
</span>
<span>{item.rawName}</span>
</li>
))}
</ul>
</section>
))}
<footer>Printed from Epicure</footer>
</>
);
}