From 8b57a3fd879d5ed64af0b2d0ddb140af2e20468c Mon Sep 17 00:00:00 2001 From: Arnaud Date: Wed, 1 Jul 2026 11:10:37 +0200 Subject: [PATCH] Update features and dependencies --- .env.example | 28 +- apps/web/app/(app)/explore/page.tsx | 9 +- apps/web/app/(app)/meal-plan/page.tsx | 6 +- apps/web/app/(app)/recipes/[id]/edit/page.tsx | 1 + apps/web/app/(app)/recipes/[id]/page.tsx | 68 +- apps/web/app/(app)/recipes/page.tsx | 71 +- apps/web/app/(app)/settings/page.tsx | 8 + .../app/(app)/shopping-lists/[id]/page.tsx | 20 +- apps/web/app/(auth)/login/page.tsx | 72 +- apps/web/app/api/v1/admin/test-email/route.ts | 6 +- apps/web/app/api/v1/ai/adapt/[id]/route.ts | 14 +- apps/web/app/api/v1/ai/drinks/[id]/route.ts | 14 +- .../app/api/v1/ai/generate-from-idea/route.ts | 85 + apps/web/app/api/v1/ai/generate/route.ts | 12 +- apps/web/app/api/v1/ai/import-photo/route.ts | 20 +- apps/web/app/api/v1/ai/import-url/route.ts | 12 +- .../app/api/v1/ai/meal-plan/generate/route.ts | 12 +- apps/web/app/api/v1/ai/pairings/[id]/route.ts | 14 +- apps/web/app/api/v1/ai/recipe-chat/route.ts | 78 + apps/web/app/api/v1/ai/recipe-ideas/route.ts | 59 + apps/web/app/api/v1/ai/scale/route.ts | 6 +- apps/web/app/api/v1/ai/substitute/route.ts | 16 +- .../web/app/api/v1/ai/translate/[id]/route.ts | 6 +- .../app/api/v1/ai/variations/[id]/route.ts | 14 +- apps/web/app/api/v1/api-keys/route.ts | 10 +- apps/web/app/api/v1/collections/[id]/route.ts | 9 +- apps/web/app/api/v1/feed/route.ts | 4 +- apps/web/app/api/v1/pantry/bulk/route.ts | 4 +- apps/web/app/api/v1/push/subscribe/route.ts | 2 +- .../comments/[commentId]/reactions/route.ts | 3 +- .../app/api/v1/recipes/[id]/cooked/route.ts | 2 +- .../api/v1/recipes/[id]/nutrition/route.ts | 2 +- apps/web/app/api/v1/recipes/[id]/route.ts | 25 +- .../api/v1/recipes/__tests__/route.test.ts | 154 + .../v1/recipes/bulk/__tests__/route.test.ts | 96 + apps/web/app/api/v1/recipes/bulk/route.ts | 12 +- apps/web/app/api/v1/recipes/route.ts | 76 +- apps/web/app/api/v1/search/route.ts | 2 +- apps/web/app/api/v1/upload/presign/route.ts | 7 + apps/web/app/api/v1/users/me/route.ts | 2 + apps/web/app/api/v1/webhooks/[id]/route.ts | 8 +- apps/web/app/api/v1/webhooks/route.ts | 10 +- apps/web/app/api/webhooks/stripe/route.ts | 86 +- .../[id]/print => print/[id]}/page.tsx | 0 apps/web/app/print/meal-plan/page.tsx | 126 + apps/web/app/print/pantry/page.tsx | 92 + .../web/app/print/shopping-list/[id]/page.tsx | 94 + .../components/auth/social-login-buttons.tsx | 66 + apps/web/components/layout/nav.tsx | 36 +- .../web/components/meal-plan/meal-planner.tsx | 41 +- .../components/pantry/pantry-page-header.tsx | 16 +- .../components/recipe/adapt-recipe-button.tsx | 15 +- .../recipe/add-to-shopping-list-button.tsx | 21 +- .../components/recipe/ai-generate-dialog.tsx | 83 +- .../recipe/delete-recipe-button.tsx | 66 +- .../recipe/drink-pairing-button.tsx | 17 +- .../recipe/generate-content-button.tsx | 97 + .../components/recipe/meal-pairing-button.tsx | 15 +- apps/web/components/recipe/print-button.tsx | 17 +- .../components/recipe/recipe-chat-panel.tsx | 179 + apps/web/components/recipe/recipe-form.tsx | 68 +- apps/web/components/recipe/recipes-grid.tsx | 15 + apps/web/components/recipe/recipes-header.tsx | 228 +- .../components/recipe/translate-button.tsx | 15 +- .../components/recipe/variations-button.tsx | 15 +- .../recipe/version-history-button.tsx | 26 +- .../search/explore-page-content.tsx | 426 ++- .../web/components/settings/settings-form.tsx | 59 + .../web/components/social/favorite-button.tsx | 16 +- apps/web/lib/__tests__/encrypt.test.ts | 53 + apps/web/lib/__tests__/fractions.test.ts | 65 + apps/web/lib/__tests__/rate-limit.test.ts | 59 + apps/web/lib/__tests__/site-settings.test.ts | 123 + apps/web/lib/__tests__/tiers.test.ts | 168 + apps/web/lib/__tests__/webhooks.test.ts | 156 + apps/web/lib/ai/__tests__/factory.test.ts | 95 + .../lib/ai/__tests__/resolve-user-key.test.ts | 138 + apps/web/lib/ai/features/adapt-recipe.ts | 4 +- .../web/lib/ai/features/generate-meal-plan.ts | 18 +- apps/web/lib/ai/features/generate-recipe.ts | 6 +- apps/web/lib/ai/features/import-url.ts | 60 + apps/web/lib/ai/features/suggest-drinks.ts | 4 +- apps/web/lib/ai/features/suggest-pairings.ts | 4 +- .../web/lib/ai/features/suggest-variations.ts | 4 +- apps/web/lib/ai/user-bio.ts | 14 + apps/web/lib/auth/client.ts | 5 +- apps/web/lib/auth/server.ts | 30 + apps/web/lib/encrypt.ts | 27 +- apps/web/lib/parse-quantity.ts | 37 + apps/web/lib/tiers.ts | 54 + apps/web/lib/validate-webhook-url.ts | 56 + apps/web/messages/en.json | 110 +- apps/web/messages/fr.json | 9 +- apps/web/next.config.ts | 50 +- apps/web/package.json | 13 +- apps/web/vitest.config.ts | 42 + apps/web/vitest.setup.ts | 7 + .../0009_abnormal_lady_bullseye.sql | 1 + .../db/src/migrations/0010_young_loners.sql | 1 + .../migrations/0011_premium_agent_zero.sql | 1 + .../db/src/migrations/meta/0009_snapshot.json | 3184 ++++++++++++++++ .../db/src/migrations/meta/0010_snapshot.json | 3190 ++++++++++++++++ .../db/src/migrations/meta/0011_snapshot.json | 3196 +++++++++++++++++ packages/db/src/migrations/meta/_journal.json | 21 + packages/db/src/schema/recipes.ts | 2 + packages/db/src/schema/users.ts | 1 + pnpm-lock.yaml | 1090 +++++- 107 files changed, 14654 insertions(+), 458 deletions(-) create mode 100644 apps/web/app/api/v1/ai/generate-from-idea/route.ts create mode 100644 apps/web/app/api/v1/ai/recipe-chat/route.ts create mode 100644 apps/web/app/api/v1/ai/recipe-ideas/route.ts create mode 100644 apps/web/app/api/v1/recipes/__tests__/route.test.ts create mode 100644 apps/web/app/api/v1/recipes/bulk/__tests__/route.test.ts rename apps/web/app/{(app)/recipes/[id]/print => print/[id]}/page.tsx (100%) create mode 100644 apps/web/app/print/meal-plan/page.tsx create mode 100644 apps/web/app/print/pantry/page.tsx create mode 100644 apps/web/app/print/shopping-list/[id]/page.tsx create mode 100644 apps/web/components/auth/social-login-buttons.tsx create mode 100644 apps/web/components/recipe/generate-content-button.tsx create mode 100644 apps/web/components/recipe/recipe-chat-panel.tsx create mode 100644 apps/web/lib/__tests__/encrypt.test.ts create mode 100644 apps/web/lib/__tests__/fractions.test.ts create mode 100644 apps/web/lib/__tests__/rate-limit.test.ts create mode 100644 apps/web/lib/__tests__/site-settings.test.ts create mode 100644 apps/web/lib/__tests__/tiers.test.ts create mode 100644 apps/web/lib/__tests__/webhooks.test.ts create mode 100644 apps/web/lib/ai/__tests__/factory.test.ts create mode 100644 apps/web/lib/ai/__tests__/resolve-user-key.test.ts create mode 100644 apps/web/lib/ai/user-bio.ts create mode 100644 apps/web/lib/parse-quantity.ts create mode 100644 apps/web/lib/validate-webhook-url.ts create mode 100644 apps/web/vitest.config.ts create mode 100644 apps/web/vitest.setup.ts create mode 100644 packages/db/src/migrations/0009_abnormal_lady_bullseye.sql create mode 100644 packages/db/src/migrations/0010_young_loners.sql create mode 100644 packages/db/src/migrations/0011_premium_agent_zero.sql create mode 100644 packages/db/src/migrations/meta/0009_snapshot.json create mode 100644 packages/db/src/migrations/meta/0010_snapshot.json create mode 100644 packages/db/src/migrations/meta/0011_snapshot.json diff --git a/.env.example b/.env.example index b4368e0..07f70f7 100644 --- a/.env.example +++ b/.env.example @@ -15,10 +15,36 @@ STORAGE_REGION=us-east-1 BETTER_AUTH_SECRET= BETTER_AUTH_URL=http://localhost:3000 -# OAuth +# Encryption key for BYOK AI keys stored in DB (generate with: openssl rand -base64 32) +# Separate from BETTER_AUTH_SECRET for key separation. Falls back to BETTER_AUTH_SECRET if unset. +ENCRYPTION_SECRET= + +# OAuth — Google (always available) GOOGLE_CLIENT_ID= GOOGLE_CLIENT_SECRET= +# OAuth — GitHub (optional; set NEXT_PUBLIC_GITHUB_ENABLED=true to show button in UI) +GITHUB_CLIENT_ID= +GITHUB_CLIENT_SECRET= +NEXT_PUBLIC_GITHUB_ENABLED= + +# OAuth — Discord (optional; set NEXT_PUBLIC_DISCORD_ENABLED=true to show button in UI) +DISCORD_CLIENT_ID= +DISCORD_CLIENT_SECRET= +NEXT_PUBLIC_DISCORD_ENABLED= + +# OIDC — Authentik (or any OIDC provider) +# AUTHENTIK_BASE_URL: base URL including application slug, e.g. +# https://auth.example.com/application/o/epicure +# The discovery document is fetched from $AUTHENTIK_BASE_URL/.well-known/openid-configuration +# In authentik: create an OAuth2/OpenID provider, set redirect URI to +# $BETTER_AUTH_URL/api/auth/callback/authentik +AUTHENTIK_CLIENT_ID= +AUTHENTIK_CLIENT_SECRET= +AUTHENTIK_BASE_URL= +# Set to true to show Authentik login button in UI +NEXT_PUBLIC_AUTHENTIK_ENABLED= + # SMTP (leave blank to log emails to console in dev) SMTP_HOST= SMTP_PORT=587 diff --git a/apps/web/app/(app)/explore/page.tsx b/apps/web/app/(app)/explore/page.tsx index e33f741..b0564e7 100644 --- a/apps/web/app/(app)/explore/page.tsx +++ b/apps/web/app/(app)/explore/page.tsx @@ -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 ; + return ; } diff --git a/apps/web/app/(app)/meal-plan/page.tsx b/apps/web/app/(app)/meal-plan/page.tsx index ddb4352..3464db9 100644 --- a/apps/web/app/(app)/meal-plan/page.tsx +++ b/apps/web/app/(app)/meal-plan/page.tsx @@ -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({ Shopping lists + + + Print + diff --git a/apps/web/app/(app)/recipes/[id]/edit/page.tsx b/apps/web/app/(app)/recipes/[id]/edit/page.tsx index 3b90747..847e21e 100644 --- a/apps/web/app/(app)/recipes/[id]/edit/page.tsx +++ b/apps/web/app/(app)/recipes/[id]/edit/page.tsx @@ -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) ?? {}, ingredients: recipe.ingredients.map((ing) => ({ id: ing.id, diff --git a/apps/web/app/(app)/recipes/[id]/page.tsx b/apps/web/app/(app)/recipes/[id]/page.tsx index 39b924e..4487143 100644 --- a/apps/web/app/(app)/recipes/[id]/page.tsx +++ b/apps/web/app/(app)/recipes/[id]/page.tsx @@ -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 */}

{recipe.title}

+
+ {recipe.steps.length > 0 && ( + + + + + } /> + Cook + + )} {recipe.visibility === "public" && ( - - - + + + + + } /> + View publicly + )} {recipe.ingredients.length > 0 && ( )} - + {(!recipe.language || recipe.language !== (session.user as { locale?: string }).locale) && ( + + )} {recipe.ingredients.length > 0 && ( - {recipe.steps.length > 0 && ( - - - Cook - - )} - - - Edit - + + + + + } /> + Edit +
+
{avgScore !== null && (
@@ -210,6 +230,24 @@ export default async function RecipePage({ params }: Params) { + {/* Empty state */} + {recipe.ingredients.length === 0 && recipe.steps.length === 0 && ( +
+

No ingredients or steps yet.

+
+ + + + Edit manually + +
+
+ )} + {/* Serving scaler + ingredients */} {recipe.ingredients.length > 0 && (
@@ -285,6 +323,8 @@ export default async function RecipePage({ params }: Params) { )} + +
); } diff --git a/apps/web/app/(app)/recipes/page.tsx b/apps/web/app/(app)/recipes/page.tsx index fdd4e9d..a18ed68 100644 --- a/apps/web/app/(app)/recipes/page.tsx +++ b/apps/web/app/(app)/recipes/page.tsx @@ -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 (
- - + - - +
); } diff --git a/apps/web/app/(app)/settings/page.tsx b/apps/web/app/(app)/settings/page.tsx index f034450..9a65f11 100644 --- a/apps/web/app/(app)/settings/page.tsx +++ b/apps/web/app/(app)/settings/page.tsx @@ -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 ( ); diff --git a/apps/web/app/(app)/shopping-lists/[id]/page.tsx b/apps/web/app/(app)/shopping-lists/[id]/page.tsx index 541b879..16e48de 100644 --- a/apps/web/app/(app)/shopping-lists/[id]/page.tsx +++ b/apps/web/app/(app)/shopping-lists/[id]/page.tsx @@ -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 (
-
-

{list.name}

-

- {list.items.length} items{list.generatedAt ? " · Generated from meal plan" : ""} -

+
+
+

{list.name}

+

+ {list.items.length} items{list.generatedAt ? " · Generated from meal plan" : ""} +

+
+ + + Print +
@@ -65,9 +61,15 @@ export default function LoginPage() {
- +
+ + } /> + } /> + +
{t("or")} @@ -118,3 +120,59 @@ export default function LoginPage() { ); } + +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 ( + + ); +} + +function AuthentikButton({ label }: { label: string }) { + if (!process.env["NEXT_PUBLIC_AUTHENTIK_ENABLED"]) return null; + return ( + + ); +} + +function GoogleIcon() { + return ( + + ); +} + +function GithubIcon() { + return ( + + ); +} + +function DiscordIcon() { + return ( + + ); +} + +function AuthentikIcon() { + return ( + + ); +} diff --git a/apps/web/app/api/v1/admin/test-email/route.ts b/apps/web/app/api/v1/admin/test-email/route.ts index 858e14f..dfb9221 100644 --- a/apps/web/app/api/v1/admin/test-email/route.ts +++ b/apps/web/app/api/v1/admin/test-email/route.ts @@ -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 }); } diff --git a/apps/web/app/api/v1/ai/adapt/[id]/route.ts b/apps/web/app/api/v1/ai/adapt/[id]/route.ts index 8470a7d..1c86d39 100644 --- a/apps/web/app/api/v1/ai/adapt/[id]/route.ts +++ b/apps/web/app/api/v1/ai/adapt/[id]/route.ts @@ -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(); diff --git a/apps/web/app/api/v1/ai/drinks/[id]/route.ts b/apps/web/app/api/v1/ai/drinks/[id]/route.ts index 6d4f7ef..d4192bf 100644 --- a/apps/web/app/api/v1/ai/drinks/[id]/route.ts +++ b/apps/web/app/api/v1/ai/drinks/[id]/route.ts @@ -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 }); } diff --git a/apps/web/app/api/v1/ai/generate-from-idea/route.ts b/apps/web/app/api/v1/ai/generate-from-idea/route.ts new file mode 100644 index 0000000..e3793ee --- /dev/null +++ b/apps/web/app/api/v1/ai/generate-from-idea/route.ts @@ -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 }); +} diff --git a/apps/web/app/api/v1/ai/generate/route.ts b/apps/web/app/api/v1/ai/generate/route.ts index a15a3bb..0dd91be 100644 --- a/apps/web/app/api/v1/ai/generate/route.ts +++ b/apps/web/app/api/v1/ai/generate/route.ts @@ -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); } diff --git a/apps/web/app/api/v1/ai/import-photo/route.ts b/apps/web/app/api/v1/ai/import-photo/route.ts index 7a12fb5..f2dadc5 100644 --- a/apps/web/app/api/v1/ai/import-photo/route.ts +++ b/apps/web/app/api/v1/ai/import-photo/route.ts @@ -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(); diff --git a/apps/web/app/api/v1/ai/import-url/route.ts b/apps/web/app/api/v1/ai/import-url/route.ts index 4ac13f8..d0056b3 100644 --- a/apps/web/app/api/v1/ai/import-url/route.ts +++ b/apps/web/app/api/v1/ai/import-url/route.ts @@ -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); } diff --git a/apps/web/app/api/v1/ai/meal-plan/generate/route.ts b/apps/web/app/api/v1/ai/meal-plan/generate/route.ts index 4dad711..8364e3d 100644 --- a/apps/web/app/api/v1/ai/meal-plan/generate/route.ts +++ b/apps/web/app/api/v1/ai/meal-plan/generate/route.ts @@ -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, })) diff --git a/apps/web/app/api/v1/ai/pairings/[id]/route.ts b/apps/web/app/api/v1/ai/pairings/[id]/route.ts index 3afbb0c..4a58d15 100644 --- a/apps/web/app/api/v1/ai/pairings/[id]/route.ts +++ b/apps/web/app/api/v1/ai/pairings/[id]/route.ts @@ -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 }); } diff --git a/apps/web/app/api/v1/ai/recipe-chat/route.ts b/apps/web/app/api/v1/ai/recipe-chat/route.ts new file mode 100644 index 0000000..f3ef927 --- /dev/null +++ b/apps/web/app/api/v1/ai/recipe-chat/route.ts @@ -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 }); +} diff --git a/apps/web/app/api/v1/ai/recipe-ideas/route.ts b/apps/web/app/api/v1/ai/recipe-ideas/route.ts new file mode 100644 index 0000000..ac688d5 --- /dev/null +++ b/apps/web/app/api/v1/ai/recipe-ideas/route.ts @@ -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); +} diff --git a/apps/web/app/api/v1/ai/scale/route.ts b/apps/web/app/api/v1/ai/scale/route.ts index e029a3d..ac9ad5e 100644 --- a/apps/web/app/api/v1/ai/scale/route.ts +++ b/apps/web/app/api/v1/ai/scale/route.ts @@ -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 }); } diff --git a/apps/web/app/api/v1/ai/substitute/route.ts b/apps/web/app/api/v1/ai/substitute/route.ts index 70e3239..be5ef00 100644 --- a/apps/web/app/api/v1/ai/substitute/route.ts +++ b/apps/web/app/api/v1/ai/substitute/route.ts @@ -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 }); } diff --git a/apps/web/app/api/v1/ai/translate/[id]/route.ts b/apps/web/app/api/v1/ai/translate/[id]/route.ts index 0818330..360e3be 100644 --- a/apps/web/app/api/v1/ai/translate/[id]/route.ts +++ b/apps/web/app/api/v1/ai/translate/[id]/route.ts @@ -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(); diff --git a/apps/web/app/api/v1/ai/variations/[id]/route.ts b/apps/web/app/api/v1/ai/variations/[id]/route.ts index bc6e089..e88baee 100644 --- a/apps/web/app/api/v1/ai/variations/[id]/route.ts +++ b/apps/web/app/api/v1/ai/variations/[id]/route.ts @@ -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 }); } diff --git a/apps/web/app/api/v1/api-keys/route.ts b/apps/web/app/api/v1/api-keys/route.ts index 47db067..bd7e0c0 100644 --- a/apps/web/app/api/v1/api-keys/route.ts +++ b/apps/web/app/api/v1/api-keys/route.ts @@ -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`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(); diff --git a/apps/web/app/api/v1/collections/[id]/route.ts b/apps/web/app/api/v1/collections/[id]/route.ts index 4ad63f4..fcdb923 100644 --- a/apps/web/app/api/v1/collections/[id]/route.ts +++ b/apps/web/app/api/v1/collections/[id]/route.ts @@ -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(); } diff --git a/apps/web/app/api/v1/feed/route.ts b/apps/web/app/api/v1/feed/route.ts index a535a5a..ff045d6 100644 --- a/apps/web/app/api/v1/feed/route.ts +++ b/apps/web/app/api/v1/feed/route.ts @@ -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); diff --git a/apps/web/app/api/v1/pantry/bulk/route.ts b/apps/web/app/api/v1/pantry/bulk/route.ts index f495f9f..c2db4d5 100644 --- a/apps/web/app/api/v1/pantry/bulk/route.ts +++ b/apps/web/app/api/v1/pantry/bulk/route.ts @@ -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) { diff --git a/apps/web/app/api/v1/push/subscribe/route.ts b/apps/web/app/api/v1/push/subscribe/route.ts index 7ac69ca..b2a3981 100644 --- a/apps/web/app/api/v1/push/subscribe/route.ts +++ b/apps/web/app/api/v1/push/subscribe/route.ts @@ -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 }); diff --git a/apps/web/app/api/v1/recipes/[id]/comments/[commentId]/reactions/route.ts b/apps/web/app/api/v1/recipes/[id]/comments/[commentId]/reactions/route.ts index 4247681..373dd9b 100644 --- a/apps/web/app/api/v1/recipes/[id]/comments/[commentId]/reactions/route.ts +++ b/apps/web/app/api/v1/recipes/[id]/comments/[commentId]/reactions/route.ts @@ -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[] = []; diff --git a/apps/web/app/api/v1/recipes/[id]/cooked/route.ts b/apps/web/app/api/v1/recipes/[id]/cooked/route.ts index ee3c391..2e76035 100644 --- a/apps/web/app/api/v1/recipes/[id]/cooked/route.ts +++ b/apps/web/app/api/v1/recipes/[id]/cooked/route.ts @@ -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), }); diff --git a/apps/web/app/api/v1/recipes/[id]/nutrition/route.ts b/apps/web/app/api/v1/recipes/[id]/nutrition/route.ts index 00b4f0a..6c8ecce 100644 --- a/apps/web/app/api/v1/recipes/[id]/nutrition/route.ts +++ b/apps/web/app/api/v1/recipes/[id]/nutrition/route.ts @@ -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 }); } diff --git a/apps/web/app/api/v1/recipes/[id]/route.ts b/apps/web/app/api/v1/recipes/[id]/route.ts index 2b300b6..42afd8f 100644 --- a/apps/web/app/api/v1/recipes/[id]/route.ts +++ b/apps/web/app/api/v1/recipes/[id]/route.ts @@ -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)); diff --git a/apps/web/app/api/v1/recipes/__tests__/route.test.ts b/apps/web/app/api/v1/recipes/__tests__/route.test.ts new file mode 100644 index 0000000..51535cf --- /dev/null +++ b/apps/web/app/api/v1/recipes/__tests__/route.test.ts @@ -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) => { + 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); + }); +}); diff --git a/apps/web/app/api/v1/recipes/bulk/__tests__/route.test.ts b/apps/web/app/api/v1/recipes/bulk/__tests__/route.test.ts new file mode 100644 index 0000000..203362e --- /dev/null +++ b/apps/web/app/api/v1/recipes/bulk/__tests__/route.test.ts @@ -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); + }); +}); diff --git a/apps/web/app/api/v1/recipes/bulk/route.ts b/apps/web/app/api/v1/recipes/bulk/route.ts index ca4ea78..c44bc6d 100644 --- a/apps/web/app/api/v1/recipes/bulk/route.ts +++ b/apps/web/app/api/v1/recipes/bulk/route.ts @@ -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 }); } diff --git a/apps/web/app/api/v1/recipes/route.ts b/apps/web/app/api/v1/recipes/route.ts index 3c86a86..38fc23c 100644 --- a/apps/web/app/api/v1/recipes/route.ts +++ b/apps/web/app/api/v1/recipes/route.ts @@ -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 = { - "½": 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 }); diff --git a/apps/web/app/api/v1/search/route.ts b/apps/web/app/api/v1/search/route.ts index 1c76686..f429d52 100644 --- a/apps/web/app/api/v1/search/route.ts +++ b/apps/web/app/api/v1/search/route.ts @@ -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." }, diff --git a/apps/web/app/api/v1/upload/presign/route.ts b/apps/web/app/api/v1/upload/presign/route.ts index 4d0086c..18a20fa 100644 --- a/apps/web/app/api/v1/upload/presign/route.ts +++ b/apps/web/app/api/v1/upload/presign/route.ts @@ -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); diff --git a/apps/web/app/api/v1/users/me/route.ts b/apps/web/app/api/v1/users/me/route.ts index 9fff55c..dafcbde 100644 --- a/apps/web/app/api/v1/users/me/route.ts +++ b/apps/web/app/api/v1/users/me/route.ts @@ -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) { diff --git a/apps/web/app/api/v1/webhooks/[id]/route.ts b/apps/web/app/api/v1/webhooks/[id]/route.ts index e36f237..093e7ed 100644 --- a/apps/web/app/api/v1/webhooks/[id]/route.ts +++ b/apps/web/app/api/v1/webhooks/[id]/route.ts @@ -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 }); } } diff --git a/apps/web/app/api/v1/webhooks/route.ts b/apps/web/app/api/v1/webhooks/route.ts index cb32b84..0e51512 100644 --- a/apps/web/app/api/v1/webhooks/route.ts +++ b/apps/web/app/api/v1/webhooks/route.ts @@ -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"); diff --git a/apps/web/app/api/webhooks/stripe/route.ts b/apps/web/app/api/webhooks/stripe/route.ts index 224a1de..7b795e0 100644 --- a/apps/web/app/api/webhooks/stripe/route.ts +++ b/apps/web/app/api/webhooks/stripe/route.ts @@ -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=,v1=[,v1=...]" + 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 } }; + 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 }); } diff --git a/apps/web/app/(app)/recipes/[id]/print/page.tsx b/apps/web/app/print/[id]/page.tsx similarity index 100% rename from apps/web/app/(app)/recipes/[id]/print/page.tsx rename to apps/web/app/print/[id]/page.tsx diff --git a/apps/web/app/print/meal-plan/page.tsx b/apps/web/app/print/meal-plan/page.tsx new file mode 100644 index 0000000..2141406 --- /dev/null +++ b/apps/web/app/print/meal-plan/page.tsx @@ -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 = { + 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 = { + 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 ( + <> + + + + +

Meal Plan

+

{label}

+ + + + + + {MEAL_ORDER.map((m) => ( + + ))} + + + + {DAYS.map((day) => ( + + + {MEAL_ORDER.map((mealType) => { + const entry = entries.find((e) => e.day === day && e.mealType === mealType); + return ( + + ); + })} + + ))} + +
Day{MEAL_LABELS[m]}
{DAY_LABELS[day]} + {entry ? ( + <> + {entry.recipe?.title ?? entry.note ?? "—"} + {entry.servings && ( + · {entry.servings} srv + )} + + ) : ( + + )} +
+ +
Printed from Epicure
+ + ); +} diff --git a/apps/web/app/print/pantry/page.tsx b/apps/web/app/print/pantry/page.tsx new file mode 100644 index 0000000..cb12172 --- /dev/null +++ b/apps/web/app/print/pantry/page.tsx @@ -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 ( + <> + + + + +

Pantry

+

{items.length} item{items.length !== 1 ? "s" : ""}

+ + {items.length === 0 ? ( +

No items in pantry.

+ ) : ( + + + + + + + + + + {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 ( + + + + + + ); + })} + +
ItemQuantityExpires
{item.rawName}{[item.quantity, item.unit].filter(Boolean).join(" ") || "—"} + {exp + ? daysLeft !== null && daysLeft < 0 + ? `Expired ${Math.abs(daysLeft)}d ago` + : exp.toLocaleDateString("en", { month: "short", day: "numeric", year: "numeric" }) + : "—"} +
+ )} + +
Printed from Epicure
+ + ); +} diff --git a/apps/web/app/print/shopping-list/[id]/page.tsx b/apps/web/app/print/shopping-list/[id]/page.tsx new file mode 100644 index 0000000..b3dc2c8 --- /dev/null +++ b/apps/web/app/print/shopping-list/[id]/page.tsx @@ -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>((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 ( + <> + + + + +

{list.name}

+

+ {list.items.filter(i => i.checked).length}/{list.items.length} items checked + {list.generatedAt ? " · From meal plan" : ""} +

+ + {aisles.map((aisle) => ( +
+ {aisles.length > 1 &&

{aisle}

} +
    + {byAisle[aisle]!.map((item) => ( +
  • + + + {[item.quantity, item.unit].filter(Boolean).join(" ")} + + {item.rawName} +
  • + ))} +
+
+ ))} + +
Printed from Epicure
+ + ); +} diff --git a/apps/web/components/auth/social-login-buttons.tsx b/apps/web/components/auth/social-login-buttons.tsx new file mode 100644 index 0000000..5570d50 --- /dev/null +++ b/apps/web/components/auth/social-login-buttons.tsx @@ -0,0 +1,66 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { authClient } from "@/lib/auth/client"; + +type Provider = { + id: string; + label: string; + icon: React.ReactNode; +}; + +const GithubIcon = () => ( + +); + +const DiscordIcon = () => ( + +); + +const authentikIcon = ( + +); + +type Props = { + showGithub?: boolean; + showDiscord?: boolean; + showAuthentik?: boolean; +}; + +export function SocialLoginButtons({ showGithub, showDiscord, showAuthentik }: Props) { + const providers: Provider[] = [ + ...(showGithub ? [{ id: "github", label: "Continue with GitHub", icon: }] : []), + ...(showDiscord ? [{ id: "discord", label: "Continue with Discord", icon: }] : []), + ...(showAuthentik ? [{ id: "authentik", label: "Continue with Authentik", icon: authentikIcon }] : []), + ]; + + if (providers.length === 0) return null; + + async function handleSocial(providerId: string) { + if (providerId === "authentik") { + await (authClient as unknown as { signIn: { genericOAuth: (opts: { providerId: string; callbackURL: string }) => Promise } }).signIn.genericOAuth({ + providerId: "authentik", + callbackURL: "/recipes", + }); + } else { + await authClient.signIn.social({ provider: providerId as "github" | "discord" | "google", callbackURL: "/recipes" }); + } + } + + return ( + <> + {providers.map((p) => ( + + ))} + + ); +} diff --git a/apps/web/components/layout/nav.tsx b/apps/web/components/layout/nav.tsx index 7795bbc..9b7ce89 100644 --- a/apps/web/components/layout/nav.tsx +++ b/apps/web/components/layout/nav.tsx @@ -2,7 +2,7 @@ import Link from "next/link"; import { usePathname } from "next/navigation"; -import { BookOpen, Calendar, Package, ChefHat, User, Rss, FolderOpen, ShoppingCart, Shield, Languages, Search, Compass } from "lucide-react"; +import { BookOpen, Calendar, Package, ChefHat, User, Rss, FolderOpen, ShoppingCart, Shield, Search, Compass } from "lucide-react"; import { cn } from "@/lib/utils"; import { buttonVariants } from "@/components/ui/button"; import { @@ -14,13 +14,11 @@ import { } from "@/components/ui/dropdown-menu"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { authClient } from "@/lib/auth/client"; -import { useLocale, SUPPORTED_LOCALES, type Locale } from "@/lib/i18n/provider"; import { useTranslations } from "next-intl"; const NAV_ITEMS = [ { href: "/recipes", key: "recipes", icon: BookOpen }, - { href: "/explore", key: "explore", icon: Compass }, - { href: "/search", key: "search", icon: Search }, + { href: "/explore", key: "explore", icon: Search }, { href: "/feed", key: "feed", icon: Rss }, { href: "/collections", key: "collections", icon: FolderOpen }, { href: "/meal-plan", key: "mealPlan", icon: Calendar }, @@ -32,7 +30,6 @@ export function Nav() { const pathname = usePathname(); const { data: session } = authClient.useSession(); const isAdmin = (session?.user as { role?: string } | undefined)?.role === "admin"; - const { locale, setLocale } = useLocale(); const t = useTranslations("nav"); return (
@@ -70,35 +67,6 @@ export function Nav() { {t("settings")} - - {t("apiKeys")} - - - {t("webhooks")} - - -
-

- - {t("language")} -

-
- {SUPPORTED_LOCALES.map((l) => ( - - ))} -
-
{isAdmin && ( <> diff --git a/apps/web/components/meal-plan/meal-planner.tsx b/apps/web/components/meal-plan/meal-planner.tsx index 355000b..847df2b 100644 --- a/apps/web/components/meal-plan/meal-planner.tsx +++ b/apps/web/components/meal-plan/meal-planner.tsx @@ -7,6 +7,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } f import { FakeProgressBar } from "@/components/ui/fake-progress-bar"; import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { useTranslations } from "next-intl"; type Day = "mon" | "tue" | "wed" | "thu" | "fri" | "sat" | "sun"; @@ -130,6 +131,7 @@ export function MealPlanner({ const [aiServings, setAiServings] = useState("2"); const [usePantry, setUsePantry] = useState(true); const [pantryMode, setPantryMode] = useState(false); + const [aiDifficulty, setAiDifficulty] = useState<"" | "easy" | "medium" | "hard">(""); const t = useTranslations("mealPlan"); async function generateWithAi() { @@ -154,6 +156,7 @@ export function MealPlanner({ servings: parseInt(aiServings) || 2, usePantry, pantryMode, + difficulty: aiDifficulty || undefined, }), }); if (!res.ok) { @@ -235,7 +238,7 @@ export function MealPlanner({ <> {/* AI Generate button */}
- @@ -329,16 +332,32 @@ export function MealPlanner({ disabled={aiGenerating} />
-
- - setAiServings(e.target.value)} - disabled={aiGenerating} - /> +
+
+ + setAiServings(e.target.value)} + disabled={aiGenerating} + /> +
+
+ + +
- - - {t("canCook")} - +
+ + + {t("canCook")} + + + + Print + +
); } diff --git a/apps/web/components/recipe/adapt-recipe-button.tsx b/apps/web/components/recipe/adapt-recipe-button.tsx index b40fb0e..5a8ce12 100644 --- a/apps/web/components/recipe/adapt-recipe-button.tsx +++ b/apps/web/components/recipe/adapt-recipe-button.tsx @@ -5,6 +5,7 @@ import { useRouter } from "next/navigation"; import { Wand2, Loader2, X } from "lucide-react"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { Dialog, DialogContent, @@ -86,10 +87,16 @@ export function AdaptRecipeButton({ return ( <> - + + + setOpen(true)}> + + + } /> + Adapt + + { setOpen(v); if (!v) reset(); }}> diff --git a/apps/web/components/recipe/add-to-shopping-list-button.tsx b/apps/web/components/recipe/add-to-shopping-list-button.tsx index 49b4565..14b583b 100644 --- a/apps/web/components/recipe/add-to-shopping-list-button.tsx +++ b/apps/web/components/recipe/add-to-shopping-list-button.tsx @@ -4,6 +4,7 @@ import { useState, useEffect } from "react"; import { ShoppingCart, Loader2, Plus, Check } from "lucide-react"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { Dialog, DialogContent, @@ -111,10 +112,16 @@ export function AddToShoppingListButton({ return ( <> - + + + setOpen(true)}> + + + } /> + Add to list + + @@ -134,13 +141,13 @@ export function AddToShoppingListButton({
{servings}
@@ -206,7 +213,7 @@ export function AddToShoppingListButton({

{items.length} ingredient{items.length !== 1 ? "s" : ""} will be added.

- + +