Update features and dependencies
This commit is contained in:
@@ -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 ?? ""} />;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user