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("continueWithGoogle")} - + + authClient.signIn.social({ provider: "google", callbackURL: "/recipes" })}> + + {t("continueWithGoogle")} + + } /> + } /> + + {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 ( + authClient.signIn.social({ provider, callbackURL: "/recipes" })}> + {icon} + {label} + + ); +} + +function AuthentikButton({ label }: { label: string }) { + if (!process.env["NEXT_PUBLIC_AUTHENTIK_ENABLED"]) return null; + return ( + (authClient as { signIn: { genericOAuth: (opts: { providerId: string; callbackURL: string }) => Promise } }).signIn.genericOAuth({ providerId: "authentik", callbackURL: "/recipes" })}> + + {label} + + ); +} + +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} + + + + + Day + {MEAL_ORDER.map((m) => ( + {MEAL_LABELS[m]} + ))} + + + + {DAYS.map((day) => ( + + {DAY_LABELS[day]} + {MEAL_ORDER.map((mealType) => { + const entry = entries.find((e) => e.day === day && e.mealType === mealType); + return ( + + {entry ? ( + <> + {entry.recipe?.title ?? entry.note ?? "—"} + {entry.servings && ( + · {entry.servings} srv + )} + > + ) : ( + — + )} + + ); + })} + + ))} + + + + + > + ); +} 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. + ) : ( + + + + Item + Quantity + Expires + + + + {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 ( + + {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" }) + : "—"} + + + ); + })} + + + )} + + + > + ); +} 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} + + ))} + + + ))} + + + > + ); +} 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) => ( + handleSocial(p.id)}> + {p.icon} + {p.label} + + ))} + > + ); +} 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) => ( - setLocale(l.code as Locale)} - className={cn( - "flex-1 rounded px-2 py-1 text-xs transition-colors", - locale === l.code - ? "bg-primary text-primary-foreground" - : "bg-muted hover:bg-accent" - )} - > - {l.label} - - ))} - - {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 */} - setShowAiModal(true)} className="gap-2"> + setShowAiModal(true)} className="gap-2"> {t("generateWithAi")} @@ -329,16 +332,32 @@ export function MealPlanner({ disabled={aiGenerating} /> - - {t("servings")} - setAiServings(e.target.value)} - disabled={aiGenerating} - /> + + + {t("servings")} + setAiServings(e.target.value)} + disabled={aiGenerating} + /> + + + Difficulty + setAiDifficulty(v as typeof aiDifficulty)} disabled={aiGenerating}> + + + + + Any + Easy + Medium + Hard + + + diff --git a/apps/web/components/pantry/pantry-page-header.tsx b/apps/web/components/pantry/pantry-page-header.tsx index d192249..1997922 100644 --- a/apps/web/components/pantry/pantry-page-header.tsx +++ b/apps/web/components/pantry/pantry-page-header.tsx @@ -1,7 +1,7 @@ "use client"; import { useTranslations } from "next-intl"; import Link from "next/link"; -import { ChefHat } from "lucide-react"; +import { ChefHat, Printer } from "lucide-react"; import { buttonVariants } from "@/components/ui/button"; import { cn } from "@/lib/utils"; @@ -13,10 +13,16 @@ export function PantryPageHeader() { {t("title")} {t("subtitle")} - - - {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(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 - + + + setOpen(true)}> + + + } /> + Add to list + + @@ -134,13 +141,13 @@ export function AddToShoppingListButton({ Servings setServings((s) => Math.max(1, s - 1))} disabled={servings <= 1} >− {servings} setServings((s) => s + 1)} >+ @@ -206,7 +213,7 @@ export function AddToShoppingListButton({ {items.length} ingredient{items.length !== 1 ? "s" : ""} will be added. - setOpen(false)} disabled={adding}>Cancel + setOpen(false)} disabled={adding}>Cancel {adding ? : } {adding ? "Adding…" : "Add ingredients"} diff --git a/apps/web/components/recipe/ai-generate-dialog.tsx b/apps/web/components/recipe/ai-generate-dialog.tsx index 5255d6a..6d8d0b7 100644 --- a/apps/web/components/recipe/ai-generate-dialog.tsx +++ b/apps/web/components/recipe/ai-generate-dialog.tsx @@ -2,7 +2,7 @@ import { useState, useRef } from "react"; import { useRouter } from "next/navigation"; -import { Sparkles, Loader2, Camera, Type, Upload, X } from "lucide-react"; +import { Sparkles, Loader2, Camera, Type, Upload, X, Shuffle } from "lucide-react"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { @@ -30,6 +30,24 @@ const LANGUAGES = [ { code: "ar", label: "Arabic" }, ]; +const SURPRISE_PROMPTS = [ + "A cozy one-pot dish with whatever is in my pantry on a rainy evening", + "A vibrant street food recipe from Southeast Asia", + "A classic French bistro dish reimagined as a weeknight dinner", + "A hearty plant-based bowl with bold spices", + "A 5-ingredient pasta with pantry staples", + "An unexpected fusion of Japanese and Mexican flavors", + "A light summer salad with fresh herbs and a citrus dressing", + "A slow-cooked braise that fills the house with aroma", + "A festive appetizer that looks impressive but is easy to make", + "A warming spiced soup from the Middle East", + "A crowd-pleasing comfort food with a twist", + "A 20-minute weeknight dinner using chicken thighs", + "A rich chocolate dessert with a molten center", + "A crispy baked dish that tastes deep-fried", + "A refreshing no-cook meal for hot summer days", +]; + type Tab = "describe" | "photo"; type GeneratedRecipe = { @@ -57,6 +75,7 @@ export function AiGenerateDialog({ // describe tab const [prompt, setPrompt] = useState(""); const [language, setLanguage] = useState("en"); + const [difficulty, setDifficulty] = useState<"" | "easy" | "medium" | "hard">(""); // photo tab const [photoPreview, setPhotoPreview] = useState(null); @@ -99,7 +118,7 @@ export function AiGenerateDialog({ const res = await fetch("/api/v1/ai/generate", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ prompt: prompt.trim(), language }), + body: JSON.stringify({ prompt: prompt.trim(), language, difficulty: difficulty || undefined }), }); if (!res.ok) { const err = await res.json() as { error?: string }; @@ -110,7 +129,7 @@ export function AiGenerateDialog({ const saveRes = await fetch("/api/v1/recipes", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ ...generated, visibility: "private", aiGenerated: true }), + body: JSON.stringify({ ...generated, visibility: "private", aiGenerated: true, language }), }); if (!saveRes.ok) { toast.error("Failed to save generated recipe"); return; } const saved = await saveRes.json() as { id: string }; @@ -188,7 +207,21 @@ export function AiGenerateDialog({ {tab === "describe" && ( - What do you want to cook? + + What do you want to cook? + { + const idx = Math.floor(Math.random() * SURPRISE_PROMPTS.length); + setPrompt(SURPRISE_PROMPTS[idx]!); + }} + disabled={busy} + className="flex items-center gap-1 text-xs text-muted-foreground hover:text-primary transition-colors disabled:opacity-50" + > + + Surprise me + + - - Recipe language - v && setLanguage(v)} disabled={busy}> - - - - - {LANGUAGES.map((l) => ( - {l.label} - ))} - - + + + Recipe language + v && setLanguage(v)} disabled={busy}> + + + + + {LANGUAGES.map((l) => ( + {l.label} + ))} + + + + + Difficulty + setDifficulty(v as typeof difficulty)} disabled={busy}> + + + + + Any + Easy + Medium + Hard + + + )} @@ -267,7 +316,7 @@ export function AiGenerateDialog({ /> {/* Actions */} - + Cancel - - - - - - - - Delete recipe? - - This action cannot be undone. The recipe and all its data will be permanently deleted. - - - - Cancel - { void handleDelete(); }} - disabled={deleting} - className="bg-destructive text-destructive-foreground hover:bg-destructive/90" - > - {deleting ? "Deleting…" : "Delete"} - - - - + <> + + + setOpen(true)} + > + + + } /> + Delete + + + + + + Delete recipe? + + This action cannot be undone. The recipe and all its data will be permanently deleted. + + + + Cancel + { void handleDelete(); }} + disabled={deleting} + className="bg-destructive text-destructive-foreground hover:bg-destructive/90" + > + {deleting ? "Deleting…" : "Delete"} + + + + + > ); } diff --git a/apps/web/components/recipe/drink-pairing-button.tsx b/apps/web/components/recipe/drink-pairing-button.tsx index 331f208..3b3c3e5 100644 --- a/apps/web/components/recipe/drink-pairing-button.tsx +++ b/apps/web/components/recipe/drink-pairing-button.tsx @@ -5,6 +5,7 @@ import { Wine, Sparkles, Loader2, Coffee, Beer, GlassWater, Leaf } from "lucide- import { FakeProgressBar } from "@/components/ui/fake-progress-bar"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { Dialog, DialogContent, @@ -75,10 +76,16 @@ export function DrinkPairingButton({ recipeId }: { recipeId: string }) { return ( <> - - - Drinks - + + + + + + } /> + Drinks + + @@ -142,7 +149,7 @@ export function DrinkPairingButton({ recipeId }: { recipeId: string }) { ); })} - + Regenerate diff --git a/apps/web/components/recipe/generate-content-button.tsx b/apps/web/components/recipe/generate-content-button.tsx new file mode 100644 index 0000000..49113ec --- /dev/null +++ b/apps/web/components/recipe/generate-content-button.tsx @@ -0,0 +1,97 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { Sparkles, Loader2 } from "lucide-react"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { FakeProgressBar } from "@/components/ui/fake-progress-bar"; + +type GeneratedRecipe = { + ingredients: Array<{ rawName: string; quantity?: number; unit?: string; note?: string }>; + steps: Array<{ instruction: string; timerSeconds?: number }>; +}; + +export function GenerateContentButton({ + recipeId, + title, + description, +}: { + recipeId: string; + title: string; + description?: string | null; +}) { + const router = useRouter(); + const [busy, setBusy] = useState(false); + + async function generate() { + setBusy(true); + try { + const prompt = description?.trim() + ? `${title}: ${description.trim()}` + : title; + + const genRes = await fetch("/api/v1/ai/generate", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ prompt }), + }); + + if (!genRes.ok) { + const err = await genRes.json() as { error?: string }; + toast.error(err.error ?? "Generation failed"); + return; + } + + const generated = await genRes.json() as GeneratedRecipe; + + const saveRes = await fetch(`/api/v1/recipes/${recipeId}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + ingredients: generated.ingredients.map((ing, i) => ({ + rawName: ing.rawName, + quantity: ing.quantity, + unit: ing.unit, + note: ing.note, + order: i, + })), + steps: generated.steps.map((s, i) => ({ + instruction: s.instruction, + timerSeconds: s.timerSeconds, + order: i, + })), + }), + }); + + if (!saveRes.ok) { + toast.error("Failed to save generated content"); + return; + } + + toast.success("Ingredients and steps generated!"); + router.refresh(); + } finally { + setBusy(false); + } + } + + return ( + + + { void generate(); }} + disabled={busy} + className="gap-1.5" + > + {busy ? ( + <>Generating…> + ) : ( + <>Generate with AI> + )} + + + ); +} diff --git a/apps/web/components/recipe/meal-pairing-button.tsx b/apps/web/components/recipe/meal-pairing-button.tsx index 7b6f97f..040360d 100644 --- a/apps/web/components/recipe/meal-pairing-button.tsx +++ b/apps/web/components/recipe/meal-pairing-button.tsx @@ -6,6 +6,7 @@ import { FakeProgressBar } from "@/components/ui/fake-progress-bar"; import { UtensilsCrossed, Sparkles, Loader2, ChefHat, Salad, Wine, Cake, Sandwich, Soup, 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, @@ -135,10 +136,16 @@ export function MealPairingButton({ recipeId }: { recipeId: string }) { return ( <> - { setOpen(true); if (pairings.length === 0) suggest(); }}> - - Pair meal - + + + { setOpen(true); if (pairings.length === 0) suggest(); }}> + + + } /> + Pair meal + + diff --git a/apps/web/components/recipe/print-button.tsx b/apps/web/components/recipe/print-button.tsx index 908c5ac..c52c0cf 100644 --- a/apps/web/components/recipe/print-button.tsx +++ b/apps/web/components/recipe/print-button.tsx @@ -2,17 +2,24 @@ import { Printer } from "lucide-react"; import { Button } from "@/components/ui/button"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; export function PrintButton({ recipeId }: { recipeId: string }) { function handlePrint() { - const win = window.open(`/recipes/${recipeId}/print`, "_blank", "width=800,height=900"); + const win = window.open(`/print/${recipeId}`, "_blank", "width=800,height=900"); win?.focus(); } return ( - - - Print - + + + + + + } /> + Print + + ); } diff --git a/apps/web/components/recipe/recipe-chat-panel.tsx b/apps/web/components/recipe/recipe-chat-panel.tsx new file mode 100644 index 0000000..4cd0aa3 --- /dev/null +++ b/apps/web/components/recipe/recipe-chat-panel.tsx @@ -0,0 +1,179 @@ +"use client"; + +import { useState, useRef, useEffect } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet"; +import { MessageCircle, Send, X, Bot, User } from "lucide-react"; +import { cn } from "@/lib/utils"; + +type Message = { + role: "user" | "assistant"; + content: string; +}; + +type Props = { + recipeId: string; + recipeTitle: string; +}; + +export function RecipeChatPanel({ recipeId, recipeTitle }: Props) { + const [open, setOpen] = useState(false); + const [messages, setMessages] = useState([]); + const [input, setInput] = useState(""); + const [loading, setLoading] = useState(false); + const bottomRef = useRef(null); + + useEffect(() => { + if (open && bottomRef.current) { + bottomRef.current.scrollIntoView({ behavior: "smooth" }); + } + }, [messages, open]); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + const question = input.trim(); + if (!question || loading) return; + + setInput(""); + setMessages((prev) => [...prev, { role: "user", content: question }]); + setLoading(true); + + try { + const res = await fetch("/api/v1/ai/recipe-chat", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ recipeId, question }), + }); + const data = await res.json() as { answer?: string; error?: string }; + setMessages((prev) => [ + ...prev, + { role: "assistant", content: data.answer ?? "Sorry, I couldn't answer that." }, + ]); + } catch { + setMessages((prev) => [ + ...prev, + { role: "assistant", content: "Something went wrong. Please try again." }, + ]); + } finally { + setLoading(false); + } + }; + + const suggestions = [ + "Can I substitute any ingredients?", + "How do I know when it's done?", + "Can I make this ahead of time?", + "What can I serve with this?", + ]; + + return ( + <> + setOpen(true)} + aria-label="Ask AI about this recipe" + > + + + + + + + + + + Ask about this recipe + + setOpen(false)}> + + + + {recipeTitle} + + + + {messages.length === 0 && ( + + + Ask anything about this recipe — ingredients, techniques, substitutions, timing… + + + {suggestions.map((s) => ( + setInput(s)} + className="w-full text-left text-sm px-3 py-2 rounded-lg border bg-muted/50 hover:bg-muted transition-colors" + > + {s} + + ))} + + + )} + + {messages.map((msg, i) => ( + + + {msg.role === "user" ? : } + + + {msg.content} + + + ))} + + {loading && ( + + + + + + + + + + + + + )} + + + + + + setInput(e.target.value)} + placeholder="Ask a question…" + disabled={loading} + className="flex-1" + autoComplete="off" + /> + + + + + + + > + ); +} diff --git a/apps/web/components/recipe/recipe-form.tsx b/apps/web/components/recipe/recipe-form.tsx index 021306a..d1551b7 100644 --- a/apps/web/components/recipe/recipe-form.tsx +++ b/apps/web/components/recipe/recipe-form.tsx @@ -1,8 +1,8 @@ "use client"; -import { useState } from "react"; +import { useState, useRef, KeyboardEvent } from "react"; import { useRouter } from "next/navigation"; -import { Plus, Trash2, GripVertical } from "lucide-react"; +import { Plus, Trash2, GripVertical, X, Tag } from "lucide-react"; import { toast } from "sonner"; import { useTranslations } from "next-intl"; import { Button } from "@/components/ui/button"; @@ -48,6 +48,7 @@ type RecipeFormProps = { prepMins?: number | null; cookMins?: number | null; dietaryTags?: DietaryTags; + tags?: string[]; ingredients?: IngredientRow[]; steps?: StepRow[]; photos?: PhotoEntry[]; @@ -77,6 +78,9 @@ export function RecipeForm({ recipeId, defaultValues }: RecipeFormProps) { const [difficulty, setDifficulty] = useState<"easy" | "medium" | "hard" | "">(defaultValues?.difficulty ?? ""); const [prepMins, setPrepMins] = useState(String(defaultValues?.prepMins ?? "")); const [cookMins, setCookMins] = useState(String(defaultValues?.cookMins ?? "")); + const [tags, setTags] = useState(defaultValues?.tags ?? []); + const [tagInput, setTagInput] = useState(""); + const tagInputRef = useRef(null); const [dietaryTags, setDietaryTags] = useState(defaultValues?.dietaryTags ?? {}); const [ingredients, setIngredients] = useState( defaultValues?.ingredients?.length ? defaultValues.ingredients : [newIngredient()] @@ -95,6 +99,26 @@ export function RecipeForm({ recipeId, defaultValues }: RecipeFormProps) { setIngredients((prev) => prev.filter((_, idx) => idx !== i)); } + function addTag(raw: string) { + const tag = raw.trim().toLowerCase().slice(0, 50); + if (!tag || tags.includes(tag) || tags.length >= 20) return; + setTags((prev) => [...prev, tag]); + setTagInput(""); + } + + function removeTag(tag: string) { + setTags((prev) => prev.filter((t) => t !== tag)); + } + + function handleTagKeyDown(e: KeyboardEvent) { + if (e.key === "Enter") { + e.preventDefault(); + addTag(tagInput); + } else if (e.key === "Backspace" && !tagInput && tags.length > 0) { + setTags((prev) => prev.slice(0, -1)); + } + } + function updateStep(i: number, patch: Partial) { setSteps((prev) => prev.map((row, idx) => idx === i ? { ...row, ...patch } : row)); } @@ -120,6 +144,7 @@ export function RecipeForm({ recipeId, defaultValues }: RecipeFormProps) { difficulty: difficulty || undefined, prepMins: prepMins ? parseInt(prepMins) : undefined, cookMins: cookMins ? parseInt(cookMins) : undefined, + tags, dietaryTags, ingredients: ingredients .filter((ing) => ing.rawName.trim()) @@ -252,6 +277,45 @@ export function RecipeForm({ recipeId, defaultValues }: RecipeFormProps) { {t_recipe("visibility.public")} + + {/* Tags */} + + Tags + tagInputRef.current?.focus()} + > + {tags.map((tag) => ( + + + {tag} + { e.stopPropagation(); removeTag(tag); }} + className="hover:text-foreground transition-colors" + aria-label={`Remove tag ${tag}`} + > + + + + ))} + {tags.length < 20 && ( + setTagInput(e.target.value)} + onKeyDown={handleTagKeyDown} + onBlur={() => { if (tagInput.trim()) addTag(tagInput); }} + placeholder={tags.length === 0 ? "Add tags… (press Enter)" : ""} + className="flex-1 min-w-[120px] bg-transparent text-sm outline-none placeholder:text-muted-foreground" + /> + )} + + Press Enter to add · Backspace to remove last · max 20 tags + diff --git a/apps/web/components/recipe/recipes-grid.tsx b/apps/web/components/recipe/recipes-grid.tsx index 816871d..24d1537 100644 --- a/apps/web/components/recipe/recipes-grid.tsx +++ b/apps/web/components/recipe/recipes-grid.tsx @@ -25,6 +25,7 @@ type Recipe = { cookMins: number | null; difficulty: "easy" | "medium" | "hard" | null; visibility: "private" | "unlisted" | "public"; + tags: string[]; updatedAt: Date; photos?: Array<{ storageKey: string; isCover: boolean }>; }; @@ -111,6 +112,20 @@ function SelectableRecipeCard({ {recipe.description} )} + {recipe.tags.length > 0 && ( + + {recipe.tags.slice(0, 3).map((tag) => ( + + {tag} + + ))} + {recipe.tags.length > 3 && ( + + +{recipe.tags.length - 3} + + )} + + )} {/* Footer */} diff --git a/apps/web/components/recipe/recipes-header.tsx b/apps/web/components/recipe/recipes-header.tsx index 67016fc..a538b12 100644 --- a/apps/web/components/recipe/recipes-header.tsx +++ b/apps/web/components/recipe/recipes-header.tsx @@ -3,16 +3,76 @@ import { useState, useTransition } from "react"; import Link from "next/link"; import { useRouter, usePathname } from "next/navigation"; -import { PlusCircle, Sparkles, Link2, Search, X } from "lucide-react"; +import { PlusCircle, Sparkles, Link2, Search, X, SlidersHorizontal, ArrowUpDown } from "lucide-react"; import { useTranslations } from "next-intl"; -import { Button } from "@/components/ui/button"; -import { buttonVariants } from "@/components/ui/button"; +import { Button, buttonVariants } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; +import { Badge } from "@/components/ui/badge"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; import { AiGenerateDialog } from "./ai-generate-dialog"; import { UrlImportDialog } from "./url-import-dialog"; + +function TagFilterInput({ value, onChange }: { value: string; onChange: (v: string) => void }) { + const [local, setLocal] = useState(value); + return ( + setLocal(e.target.value)} + onKeyDown={(e) => { if (e.key === "Enter") onChange(local); }} + onBlur={() => onChange(local)} + placeholder="Filter by tag…" + className="w-full h-7 px-2 text-sm rounded-md border border-input bg-background focus:outline-none focus:ring-1 focus:ring-ring" + /> + ); +} import { cn } from "@/lib/utils"; -export function RecipesHeader({ count, initialQuery = "" }: { count: number; initialQuery?: string }) { +const SORT_LABELS: Record = { + updated_desc: "Recently updated", + updated_asc: "Oldest updated", + created_desc: "Newest first", + created_asc: "Oldest first", + title_asc: "Title A–Z", + title_desc: "Title Z–A", +}; + +const VISIBILITY_LABELS: Record = { + "": "All", + private: "Private", + unlisted: "Unlisted", + public: "Public", +}; + +const DIFFICULTY_LABELS: Record = { + "": "Any difficulty", + easy: "Easy", + medium: "Medium", + hard: "Hard", +}; + +export function RecipesHeader({ + count, + initialQuery = "", + initialSort = "updated_desc", + initialVisibility = "", + initialDifficulty = "", + initialTag = "", +}: { + count: number; + initialQuery?: string; + initialSort?: string; + initialVisibility?: string; + initialDifficulty?: string; + initialTag?: string; +}) { const router = useRouter(); const pathname = usePathname(); const t = useTranslations("recipes"); @@ -21,15 +81,32 @@ export function RecipesHeader({ count, initialQuery = "" }: { count: number; ini const [query, setQuery] = useState(initialQuery); const [, startTransition] = useTransition(); + function pushParams(overrides: Record) { + const params = new URLSearchParams(); + const current = { + q: query, + sort: initialSort, + visibility: initialVisibility, + difficulty: initialDifficulty, + tag: initialTag, + ...overrides, + }; + if (current.q?.trim()) params.set("q", current.q.trim()); + if (current.sort && current.sort !== "updated_desc") params.set("sort", current.sort); + if (current.visibility) params.set("visibility", current.visibility); + if (current.difficulty) params.set("difficulty", current.difficulty); + if (current.tag?.trim()) params.set("tag", current.tag.trim()); + startTransition(() => router.push(`${pathname}?${params.toString()}`)); + } + function handleSearch(value: string) { setQuery(value); - startTransition(() => { - const params = new URLSearchParams(); - if (value.trim()) params.set("q", value.trim()); - router.push(`${pathname}?${params.toString()}`); - }); + pushParams({ q: value }); } + const activeFilterCount = [initialVisibility, initialDifficulty, initialTag].filter(Boolean).length; + const sortChanged = initialSort !== "updated_desc"; + return ( <> @@ -43,11 +120,11 @@ export function RecipesHeader({ count, initialQuery = "" }: { count: number; ini - setUrlOpen(true)}> + setUrlOpen(true)}> {t("importUrl")} - setAiOpen(true)}> + setAiOpen(true)}> {t("generate")} @@ -58,22 +135,121 @@ export function RecipesHeader({ count, initialQuery = "" }: { count: number; ini - - - handleSearch(e.target.value)} - placeholder={t("search")} - className="pl-9 pr-9" - /> - {query && ( - handleSearch("")} - className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground" + + {/* Search */} + + + handleSearch(e.target.value)} + placeholder={t("search")} + className="pl-9 pr-9" + /> + {query && ( + handleSearch("")} + className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground" + > + + + )} + + + {/* Sort */} + + - - - )} + + {SORT_LABELS[initialSort] ?? "Sort"} + + + + Sort by + + {Object.entries(SORT_LABELS).map(([value, label]) => ( + pushParams({ sort: value })} + className={cn(initialSort === value && "font-medium text-primary")} + > + {label} + + ))} + + + + + {/* Filters */} + + 0 ? "secondary" : "outline", size: "sm" }), + "gap-1.5" + )} + > + + Filter + {activeFilterCount > 0 && ( + + {activeFilterCount} + + )} + + + + Visibility + {Object.entries(VISIBILITY_LABELS).map(([value, label]) => ( + pushParams({ visibility: value })} + className={cn(initialVisibility === value && "font-medium text-primary")} + > + {label} + + ))} + + + + Difficulty + {Object.entries(DIFFICULTY_LABELS).map(([value, label]) => ( + pushParams({ difficulty: value })} + className={cn(initialDifficulty === value && "font-medium text-primary")} + > + {label} + + ))} + + + + Tag + + pushParams({ tag: v })} + /> + + + {activeFilterCount > 0 && ( + <> + + + pushParams({ visibility: "", difficulty: "", tag: "" })} + className="text-muted-foreground" + > + Clear filters + + + > + )} + + diff --git a/apps/web/components/recipe/translate-button.tsx b/apps/web/components/recipe/translate-button.tsx index f74157f..f3f2d92 100644 --- a/apps/web/components/recipe/translate-button.tsx +++ b/apps/web/components/recipe/translate-button.tsx @@ -5,6 +5,7 @@ import { useRouter } from "next/navigation"; import { Languages, Loader2 } 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, @@ -62,10 +63,16 @@ export function TranslateButton({ recipeId }: { recipeId: string }) { return ( <> - setOpen(true)}> - - Translate - + + + setOpen(true)}> + + + } /> + Translate + + diff --git a/apps/web/components/recipe/variations-button.tsx b/apps/web/components/recipe/variations-button.tsx index 5b3cb9a..f8cc06e 100644 --- a/apps/web/components/recipe/variations-button.tsx +++ b/apps/web/components/recipe/variations-button.tsx @@ -3,6 +3,7 @@ import { useState } from "react"; import { GitBranch } from "lucide-react"; import { Button } from "@/components/ui/button"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { VariationsDialog } from "./variations-dialog"; export function VariationsButton({ @@ -26,10 +27,16 @@ export function VariationsButton({ return ( <> - setOpen(true)}> - - Variations - + + + setOpen(true)}> + + + } /> + Variations + + - - - History - - } /> - + <> + + + handleOpen(true)}> + + + } /> + History + + + + Version History - + {loading && ( Loading versions... )} @@ -173,5 +178,6 @@ export function VersionHistoryButton({ recipeId }: { recipeId: string }) { + > ); } diff --git a/apps/web/components/search/explore-page-content.tsx b/apps/web/components/search/explore-page-content.tsx index 86aeb60..2360eda 100644 --- a/apps/web/components/search/explore-page-content.tsx +++ b/apps/web/components/search/explore-page-content.tsx @@ -1,12 +1,14 @@ "use client"; -import { useRef } from "react"; -import { useRouter } from "next/navigation"; +import { useState, useEffect, useRef, useCallback } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; import Link from "next/link"; import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; -import { Flame, Clock, ChefHat, Search } from "lucide-react"; -import type { RecipeResult } from "@/app/(app)/explore/page"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Flame, Clock, ChefHat, Search, Sparkles, Wand2, ArrowRight } from "lucide-react"; +import type { RecipeResult as ExploreRecipeResult } from "@/app/(app)/explore/page"; const DIFFICULTY_COLORS: Record = { easy: "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200", @@ -14,8 +16,34 @@ const DIFFICULTY_COLORS: Record = { hard: "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200", }; -function RecipeCard({ recipe }: { recipe: RecipeResult }) { +type SearchRecipeResult = { + id: string; + title: string; + description: string | null; + difficulty: string | null; + prepMins: number | null; + cookMins: number | null; + authorName: string | null; +}; + +type SearchResponse = { + data: SearchRecipeResult[]; + total: number; + limit: number; + offset: number; +}; + +type RecipeIdea = { + title: string; + description: string; + tags: string[]; + difficulty: "easy" | "medium" | "hard"; + totalMins?: number; +}; + +function RecipeCard({ recipe }: { recipe: ExploreRecipeResult | SearchRecipeResult }) { const totalMins = (recipe.prepMins ?? 0) + (recipe.cookMins ?? 0); + const description = "description" in recipe ? recipe.description : null; return ( )} + {description && ( + {description} + )} {totalMins > 0 && ( @@ -61,75 +92,370 @@ function HorizontalScroll({ children }: { children: React.ReactNode }) { } type Props = { - trending: RecipeResult[]; - recent: RecipeResult[]; + trending: ExploreRecipeResult[]; + recent: ExploreRecipeResult[]; + initialQuery: string; }; -export function ExplorePageContent({ trending, recent }: Props) { +export function ExplorePageContent({ trending, recent, initialQuery }: Props) { const router = useRouter(); - const inputRef = useRef(null); + const searchParams = useSearchParams(); - const handleSearchSubmit = (e: React.FormEvent) => { - e.preventDefault(); - const q = inputRef.current?.value.trim(); - if (q) router.push(`/search?q=${encodeURIComponent(q)}`); - else router.push("/search"); + const [inputValue, setInputValue] = useState(initialQuery); + const [query, setQuery] = useState(initialQuery); + const [difficulty, setDifficulty] = useState("any"); + const [maxMins, setMaxMins] = useState(""); + const [results, setResults] = useState([]); + const [total, setTotal] = useState(0); + const [offset, setOffset] = useState(0); + const [loading, setLoading] = useState(false); + const [loadingMore, setLoadingMore] = useState(false); + + const [ideasPrompt, setIdeasPrompt] = useState(""); + const [ideas, setIdeas] = useState([]); + const [ideasLoading, setIdeasLoading] = useState(false); + const [generatingId, setGeneratingId] = useState(null); + + const inputRef = useRef(null); + const debounceRef = useRef | null>(null); + + const fetchResults = useCallback( + async (q: string, diff: string, maxM: string, off: number, append = false) => { + if (!q.trim()) { + setResults([]); + setTotal(0); + setOffset(0); + return; + } + if (off === 0) setLoading(true); + else setLoadingMore(true); + try { + const params = new URLSearchParams({ q, limit: "20", offset: String(off) }); + if (diff && diff !== "any") params.set("difficulty", diff); + if (maxM) params.set("maxMins", maxM); + const res = await fetch(`/api/v1/search?${params}`); + if (!res.ok) return; + const json = await res.json() as SearchResponse; + if (append) setResults((prev) => [...prev, ...json.data]); + else setResults(json.data); + setTotal(json.total); + setOffset(off + json.data.length); + } finally { + setLoading(false); + setLoadingMore(false); + } + }, + [] + ); + + useEffect(() => { + if (initialQuery) fetchResults(initialQuery, "any", "", 0); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const updateUrl = useCallback( + (q: string) => { + const params = new URLSearchParams(searchParams.toString()); + if (q) params.set("q", q); + else params.delete("q"); + router.replace(`/explore?${params}`, { scroll: false }); + }, + [router, searchParams] + ); + + const fetchIdeas = useCallback(async (prompt: string) => { + setIdeasLoading(true); + setIdeas([]); + try { + const res = await fetch("/api/v1/ai/recipe-ideas", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ prompt }), + }); + if (!res.ok) return; + const data = await res.json() as RecipeIdea[]; + setIdeas(data); + } finally { + setIdeasLoading(false); + } + }, []); + + const generateFromIdea = useCallback(async (idea: RecipeIdea) => { + const key = idea.title; + setGeneratingId(key); + try { + const res = await fetch("/api/v1/ai/generate-from-idea", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ title: idea.title }), + }); + if (!res.ok) return; + const { id } = await res.json() as { id: string }; + router.push(`/recipes/${id}`); + } finally { + setGeneratingId(null); + } + }, [router]); + + const handleInputChange = (value: string) => { + setInputValue(value); + if (debounceRef.current) clearTimeout(debounceRef.current); + debounceRef.current = setTimeout(() => { + setQuery(value); + updateUrl(value); + fetchResults(value, difficulty, maxMins, 0); + }, 300); }; + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (debounceRef.current) clearTimeout(debounceRef.current); + setQuery(inputValue); + updateUrl(inputValue); + fetchResults(inputValue, difficulty, maxMins, 0); + }; + + const handleDifficultyChange = (value: string | null) => { + const v = value ?? "any"; + setDifficulty(v); + fetchResults(query, v, maxMins, 0); + }; + + const handleMaxMinsChange = (value: string) => { + setMaxMins(value); + if (debounceRef.current) clearTimeout(debounceRef.current); + debounceRef.current = setTimeout(() => { + fetchResults(query, difficulty, value, 0); + }, 300); + }; + + const hasQuery = query.trim().length > 0; + const hasMore = results.length < total; + return ( {/* Search bar */} - - Explore - + + Explore + handleInputChange(e.target.value)} + placeholder="Search public recipes…" className="pl-10 h-12 text-base" + autoFocus={!!initialQuery} /> + + {hasQuery && ( + + + + + + + Any difficulty + Easy + Medium + Hard + + + handleMaxMinsChange(e.target.value)} + className="w-36" + /> + {!loading && ( + + {total} {total === 1 ? "recipe" : "recipes"} found + + )} + + )} - {/* Trending this week */} - - - - Trending this week + {/* Search results */} + {hasQuery && loading && ( + + {Array.from({ length: 8 }).map((_, i) => ( + + + + + + ))} - {trending.length === 0 ? ( - - No trending recipes yet. Be the first to share one! - - ) : ( - - {trending.map((recipe) => ( - - - - ))} - - )} - + )} - {/* Recently added */} - - - - Recently added + {hasQuery && !loading && results.length === 0 && ( + + + No recipes found for “{query}” + Try different keywords or remove filters - {recent.length === 0 ? ( - - No public recipes yet. - - ) : ( + )} + + {hasQuery && !loading && results.length > 0 && ( + <> - {recent.map((recipe) => ( + {results.map((recipe) => ( ))} - )} - + {hasMore && ( + + fetchResults(query, difficulty, maxMins, offset, true)} + disabled={loadingMore} + className="min-w-32" + > + {loadingMore ? "Loading…" : "Load more"} + + + )} + > + )} + + {/* Explore sections — shown when no query */} + {!hasQuery && ( + <> + {/* AI Recipe Ideas */} + + + + Recipe ideas + + Tell the AI what you're in the mood for, or let it surprise you. + { + e.preventDefault(); + fetchIdeas(ideasPrompt); + }} + className="flex gap-2" + > + setIdeasPrompt(e.target.value)} + placeholder="e.g. quick weeknight dinners, Italian comfort food…" + className="bg-background" + /> + + {ideasLoading ? ( + Generating… + ) : ( + Get ideas + )} + + { setIdeasPrompt(""); fetchIdeas(""); }} + className="shrink-0" + > + Surprise me + + + + {ideasLoading && ( + + {Array.from({ length: 6 }).map((_, i) => ( + + + + + + ))} + + )} + + {!ideasLoading && ideas.length > 0 && ( + + {ideas.map((idea) => ( + + + {idea.title} + + {idea.difficulty} + + + {idea.description} + + {idea.tags.map((tag) => ( + {tag} + ))} + {idea.totalMins && ( + + {idea.totalMins}m + + )} + + generateFromIdea(idea)} + > + {generatingId === idea.title ? ( + Generating… + ) : ( + Generate full recipe + )} + + + ))} + + )} + + + + + + Trending this week + + {trending.length === 0 ? ( + + No trending recipes yet. Be the first to share one! + + ) : ( + + {trending.map((recipe) => ( + + + + ))} + + )} + + + + + + Recently added + + {recent.length === 0 ? ( + + No public recipes yet. + + ) : ( + + {recent.map((recipe) => ( + + ))} + + )} + + > + )} ); } diff --git a/apps/web/components/settings/settings-form.tsx b/apps/web/components/settings/settings-form.tsx index 5533757..b90b517 100644 --- a/apps/web/components/settings/settings-form.tsx +++ b/apps/web/components/settings/settings-form.tsx @@ -5,6 +5,7 @@ import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { useTranslations } from "next-intl"; import { useLocale, SUPPORTED_LOCALES, type Locale } from "@/lib/i18n/provider"; @@ -14,6 +15,8 @@ type UserProps = { email: string; image: string | null; locale: string; + bio: string | null; + privateBio: string | null; }; export function SettingsForm({ user }: { user: UserProps }) { @@ -22,7 +25,10 @@ export function SettingsForm({ user }: { user: UserProps }) { const { setLocale } = useLocale(); const [name, setName] = useState(user.name); + const [bio, setBio] = useState(user.bio ?? ""); + const [privateBio, setPrivateBio] = useState(user.privateBio ?? ""); const [saving, setSaving] = useState(false); + const [savingBio, setSavingBio] = useState(false); async function saveProfile() { setSaving(true); @@ -39,6 +45,26 @@ export function SettingsForm({ user }: { user: UserProps }) { } } + async function saveBios() { + setSavingBio(true); + try { + const res = await fetch("/api/v1/users/me", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + bio: bio.trim() || null, + privateBio: privateBio.trim() || null, + }), + }); + if (res.ok) toast.success(t_common("saved")); + else toast.error(t_common("saveFailed")); + } finally { + setSavingBio(false); + } + } + + const bioUnchanged = bio === (user.bio ?? "") && privateBio === (user.privateBio ?? ""); + return ( @@ -56,6 +82,39 @@ export function SettingsForm({ user }: { user: UserProps }) { + + {t("bio")} + + {t("publicBio")} + {t("publicBioDescription")} + setBio(e.target.value)} + placeholder={t("publicBioPlaceholder")} + rows={3} + maxLength={500} + className="resize-none" + /> + {bio.length}/500 + + + {t("privateBio")} + {t("privateBioDescription")} + setPrivateBio(e.target.value)} + placeholder={t("privateBioPlaceholder")} + rows={5} + maxLength={2000} + className="resize-none" + /> + {privateBio.length}/2000 + + + {savingBio ? t("saving") : t_common("save")} + + + {t("language")} {t("languageDescription")} diff --git a/apps/web/components/social/favorite-button.tsx b/apps/web/components/social/favorite-button.tsx index 21f506d..24e084f 100644 --- a/apps/web/components/social/favorite-button.tsx +++ b/apps/web/components/social/favorite-button.tsx @@ -2,8 +2,8 @@ import { useState } from "react"; import { Heart } from "lucide-react"; -import { toast } from "sonner"; import { Button } from "@/components/ui/button"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { cn } from "@/lib/utils"; export function FavoriteButton({ @@ -30,9 +30,15 @@ export function FavoriteButton({ } return ( - - - {favorited ? "Saved" : "Save"} - + + + + + + } /> + {favorited ? "Saved" : "Save"} + + ); } diff --git a/apps/web/lib/__tests__/encrypt.test.ts b/apps/web/lib/__tests__/encrypt.test.ts new file mode 100644 index 0000000..6794125 --- /dev/null +++ b/apps/web/lib/__tests__/encrypt.test.ts @@ -0,0 +1,53 @@ +import { describe, it, expect } from "vitest"; +import { encrypt, decrypt } from "../encrypt"; + +describe("encrypt / decrypt", () => { + it("round-trips a short string", () => { + const plain = "hello world"; + expect(decrypt(encrypt(plain))).toBe(plain); + }); + + it("round-trips an empty string", () => { + expect(decrypt(encrypt(""))).toBe(""); + }); + + it("round-trips a unicode string", () => { + const plain = "café ☕ résumé"; + expect(decrypt(encrypt(plain))).toBe(plain); + }); + + it("round-trips a long string (API key-sized)", () => { + const plain = "sk-proj-" + "a".repeat(128); + expect(decrypt(encrypt(plain))).toBe(plain); + }); + + it("produces different ciphertexts for same input (random IV)", () => { + const plain = "same input"; + const c1 = encrypt(plain); + const c2 = encrypt(plain); + expect(c1).not.toBe(c2); + // but both decrypt to same value + expect(decrypt(c1)).toBe(plain); + expect(decrypt(c2)).toBe(plain); + }); + + it("ciphertext format is iv:authTag:encrypted (3 hex segments)", () => { + const ct = encrypt("test"); + const parts = ct.split(":"); + expect(parts).toHaveLength(3); + parts.forEach((p) => expect(p).toMatch(/^[0-9a-f]+$/)); + }); + + it("throws on malformed ciphertext (wrong segments)", () => { + expect(() => decrypt("notvalid")).toThrow("Invalid ciphertext format"); + expect(() => decrypt("a:b")).toThrow("Invalid ciphertext format"); + }); + + it("throws on tampered ciphertext (auth tag mismatch)", () => { + const ct = encrypt("sensitive"); + const parts = ct.split(":"); + // flip a byte in the encrypted payload + const tampered = parts[0] + ":" + parts[1] + ":" + "00" + parts[2]!.slice(2); + expect(() => decrypt(tampered)).toThrow(); + }); +}); diff --git a/apps/web/lib/__tests__/fractions.test.ts b/apps/web/lib/__tests__/fractions.test.ts new file mode 100644 index 0000000..a617d48 --- /dev/null +++ b/apps/web/lib/__tests__/fractions.test.ts @@ -0,0 +1,65 @@ +import { describe, it, expect } from "vitest"; +import { formatQuantity, scaleQuantity } from "../fractions"; + +describe("formatQuantity", () => { + it("returns '0' for 0", () => { + expect(formatQuantity(0)).toBe("0"); + }); + + it("returns whole numbers as strings", () => { + expect(formatQuantity(1)).toBe("1"); + expect(formatQuantity(4)).toBe("4"); + }); + + it("rounds near-integer values up", () => { + expect(formatQuantity(0.97)).toBe("1"); + expect(formatQuantity(2.96)).toBe("3"); + }); + + it("formats known fractions with unicode symbols", () => { + expect(formatQuantity(0.5)).toBe("½"); + expect(formatQuantity(0.25)).toBe("¼"); + expect(formatQuantity(0.75)).toBe("¾"); + expect(formatQuantity(0.333)).toBe("⅓"); + expect(formatQuantity(0.667)).toBe("⅔"); + expect(formatQuantity(0.125)).toBe("⅛"); + }); + + it("formats mixed numbers", () => { + expect(formatQuantity(1.5)).toBe("1 ½"); + expect(formatQuantity(2.25)).toBe("2 ¼"); + expect(formatQuantity(3.75)).toBe("3 ¾"); + }); + + it("falls back to one decimal for values that don't match a fraction", () => { + // 0.4375 is midpoint between ⅜ (0.375) and ½ (0.5), diff=0.0625 from both — exceeds 0.06 threshold + expect(formatQuantity(0.4375)).toMatch(/^0\.\d$/); + }); +}); + +describe("scaleQuantity", () => { + it("returns empty string for null/empty base quantity", () => { + expect(scaleQuantity(null, 4, 2)).toBe(""); + expect(scaleQuantity("", 4, 2)).toBe(""); + }); + + it("returns base quantity unchanged for non-numeric strings", () => { + expect(scaleQuantity("to taste", 4, 2)).toBe("to taste"); + }); + + it("scales down by half", () => { + expect(scaleQuantity("4", 4, 2)).toBe("2"); + }); + + it("scales up by double", () => { + expect(scaleQuantity("2", 2, 4)).toBe("4"); + }); + + it("handles base=0 gracefully (no division by zero)", () => { + expect(scaleQuantity("3", 0, 4)).toBe("3"); + }); + + it("produces fraction symbols when result is fractional", () => { + expect(scaleQuantity("1", 2, 1)).toBe("½"); + }); +}); diff --git a/apps/web/lib/__tests__/rate-limit.test.ts b/apps/web/lib/__tests__/rate-limit.test.ts new file mode 100644 index 0000000..2b76b16 --- /dev/null +++ b/apps/web/lib/__tests__/rate-limit.test.ts @@ -0,0 +1,59 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const pipelineChain = vi.hoisted(() => ({ + zadd: vi.fn().mockReturnThis(), + zremrangebyscore: vi.fn().mockReturnThis(), + zcard: vi.fn().mockReturnThis(), + expire: vi.fn().mockReturnThis(), + exec: vi.fn(), +})); + +vi.mock("../redis", () => ({ + getRedis: vi.fn(() => ({ pipeline: vi.fn(() => pipelineChain) })), +})); + +vi.mock("next/server", () => ({ + NextResponse: { + json: vi.fn((body: unknown, init?: { status?: number }) => ({ body, init, isNextResponse: true })), + }, +})); + +import { NextResponse } from "next/server"; +const { applyRateLimit } = await import("../rate-limit"); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("applyRateLimit", () => { + it("returns null when under the limit", async () => { + pipelineChain.exec.mockResolvedValue([null, null, [null, 3], null]); + const result = await applyRateLimit("test-key", 10, 60); + expect(result).toBeNull(); + }); + + it("returns 429 response when over the limit", async () => { + pipelineChain.exec.mockResolvedValue([null, null, [null, 11], null]); + const result = await applyRateLimit("test-key", 10, 60); + expect(result).not.toBeNull(); + expect(NextResponse.json).toHaveBeenCalledWith( + expect.objectContaining({ error: expect.any(String) }), + expect.objectContaining({ status: 429 }) + ); + }); + + it("returns null when exactly at limit (count === limit is allowed)", async () => { + pipelineChain.exec.mockResolvedValue([null, null, [null, 10], null]); + const result = await applyRateLimit("test-key", 10, 60); + expect(result).toBeNull(); + }); + + it("calls pipeline with zadd, zremrangebyscore, zcard, expire", async () => { + pipelineChain.exec.mockResolvedValue([null, null, [null, 1], null]); + await applyRateLimit("my-key", 5, 30); + expect(pipelineChain.zadd).toHaveBeenCalled(); + expect(pipelineChain.zremrangebyscore).toHaveBeenCalled(); + expect(pipelineChain.zcard).toHaveBeenCalled(); + expect(pipelineChain.expire).toHaveBeenCalled(); + }); +}); diff --git a/apps/web/lib/__tests__/site-settings.test.ts b/apps/web/lib/__tests__/site-settings.test.ts new file mode 100644 index 0000000..2fc7db6 --- /dev/null +++ b/apps/web/lib/__tests__/site-settings.test.ts @@ -0,0 +1,123 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +const mockFindFirst = vi.fn(); +const mockFindMany = vi.fn(); +const mockInsert = vi.fn(); +const mockDelete = vi.fn(); + +vi.mock("@epicure/db", () => ({ + db: { + query: { + siteSettings: { + findFirst: mockFindFirst, + findMany: mockFindMany, + }, + }, + insert: mockInsert, + delete: mockDelete, + }, + siteSettings: { key: "key", value: "value", isSecret: "is_secret", updatedAt: "updated_at", updatedById: "updated_by_id" }, + eq: vi.fn((a, b) => ({ a, b })), +})); + +const insertChain = { + values: vi.fn().mockReturnThis(), + onConflictDoUpdate: vi.fn().mockResolvedValue(undefined), +}; +const deleteChain = { + where: vi.fn().mockResolvedValue(undefined), +}; + +const { getSiteSetting, setSiteSetting, isSecretKey } = await import("../site-settings"); + +beforeEach(() => { + vi.clearAllMocks(); + mockInsert.mockReturnValue(insertChain); + mockDelete.mockReturnValue(deleteChain); +}); + +afterEach(() => { + delete process.env["OPENAI_API_KEY"]; + delete process.env["ANTHROPIC_API_KEY"]; +}); + +describe("isSecretKey", () => { + it("marks API keys as secret", () => { + expect(isSecretKey("OPENAI_API_KEY")).toBe(true); + expect(isSecretKey("ANTHROPIC_API_KEY")).toBe(true); + expect(isSecretKey("VAPID_PRIVATE_KEY")).toBe(true); + }); + + it("marks non-secret settings as public", () => { + expect(isSecretKey("OPENROUTER_DEFAULT_MODEL")).toBe(false); + expect(isSecretKey("OLLAMA_BASE_URL")).toBe(false); + }); +}); + +describe("getSiteSetting", () => { + it("returns DB value when present (plain)", async () => { + mockFindFirst.mockResolvedValue({ value: "gpt-4o", isSecret: false }); + const val = await getSiteSetting("OPENROUTER_DEFAULT_MODEL"); + expect(val).toBe("gpt-4o"); + }); + + it("decrypts DB value when secret", async () => { + // Pre-encrypt something using the real encrypt function + const { encrypt } = await import("../encrypt"); + const ciphertext = encrypt("sk-real-key"); + mockFindFirst.mockResolvedValue({ value: ciphertext, isSecret: true }); + + const val = await getSiteSetting("OPENAI_API_KEY"); + expect(val).toBe("sk-real-key"); + }); + + it("falls back to env var when no DB row", async () => { + mockFindFirst.mockResolvedValue(null); + process.env["OPENAI_API_KEY"] = "sk-from-env"; + const val = await getSiteSetting("OPENAI_API_KEY"); + expect(val).toBe("sk-from-env"); + }); + + it("returns null when neither DB nor env is set", async () => { + mockFindFirst.mockResolvedValue(null); + const val = await getSiteSetting("OPENAI_API_KEY"); + expect(val).toBeNull(); + }); + + it("falls back to env when DB throws", async () => { + mockFindFirst.mockRejectedValue(new Error("DB error")); + process.env["ANTHROPIC_API_KEY"] = "sk-anthropic-env"; + const val = await getSiteSetting("ANTHROPIC_API_KEY"); + expect(val).toBe("sk-anthropic-env"); + }); +}); + +describe("setSiteSetting", () => { + it("deletes the row when value is null", async () => { + await setSiteSetting("OPENAI_API_KEY", null, "admin-user"); + expect(mockDelete).toHaveBeenCalled(); + expect(mockInsert).not.toHaveBeenCalled(); + }); + + it("deletes the row when value is empty string", async () => { + await setSiteSetting("OPENAI_API_KEY", "", "admin-user"); + expect(mockDelete).toHaveBeenCalled(); + }); + + it("inserts encrypted value for secret key", async () => { + await setSiteSetting("OPENAI_API_KEY", "sk-test", "admin-user"); + expect(mockInsert).toHaveBeenCalled(); + const inserted = insertChain.values.mock.calls[0]![0] as Record; + expect(inserted.isSecret).toBe(true); + // stored value should not be plain text + expect(inserted.value).not.toBe("sk-test"); + expect(typeof inserted.value).toBe("string"); + }); + + it("inserts plain value for non-secret key", async () => { + await setSiteSetting("OPENROUTER_DEFAULT_MODEL", "gpt-4o", "admin-user"); + const inserted = insertChain.values.mock.calls[0]![0] as Record; + expect(inserted.isSecret).toBe(false); + expect(inserted.value).toBe("gpt-4o"); + }); +}); diff --git a/apps/web/lib/__tests__/tiers.test.ts b/apps/web/lib/__tests__/tiers.test.ts new file mode 100644 index 0000000..70c605f --- /dev/null +++ b/apps/web/lib/__tests__/tiers.test.ts @@ -0,0 +1,168 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { TierLimitError } from "../tiers"; + +const mockDb = vi.hoisted(() => ({ + select: vi.fn(), + insert: vi.fn(), +})); + +vi.mock("@epicure/db", () => ({ + db: mockDb, + tierDefinitions: { tier: "tier" }, + userUsage: { + userId: "user_id", + month: "month", + aiCallsUsed: "ai_calls_used", + recipeCount: "recipe_count", + storageUsedMb: "storage_used_mb", + }, + eq: vi.fn((col, val) => ({ col, val, op: "eq" })), + and: vi.fn((...args) => ({ args, op: "and" })), + sql: vi.fn((strings, ...values) => ({ strings, values, op: "sql" })), +})); + +// Import after mock +const { checkTierLimit, incrementUsage } = await import("../tiers"); + +function makeChain(finalValue: unknown) { + const chain = { + from: vi.fn().mockReturnThis(), + where: vi.fn().mockResolvedValue(finalValue), + }; + return chain; +} + +function makeInsertChain() { + const chain = { + values: vi.fn().mockReturnThis(), + onConflictDoUpdate: vi.fn().mockResolvedValue(undefined), + }; + return chain; +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("TierLimitError", () => { + it("has correct name and message", () => { + const err = new TierLimitError("aiCall", "free"); + expect(err.name).toBe("TierLimitError"); + expect(err.message).toContain("aiCall"); + expect(err.message).toContain("free"); + expect(err).toBeInstanceOf(Error); + }); +}); + +describe("checkTierLimit", () => { + const tierDef = { + tier: "free", + maxRecipes: 10, + aiCallsPerMonth: 5, + storageMb: 100, + maxPublicRecipes: 3, + }; + + it("does not throw when usage is under limit", async () => { + mockDb.select + .mockReturnValueOnce(makeChain([tierDef])) + .mockReturnValueOnce(makeChain([{ aiCallsUsed: 3, recipeCount: 2, storageUsedMb: 0 }])); + + await expect(checkTierLimit("user1", "free", "aiCall")).resolves.toBeUndefined(); + }); + + it("throws TierLimitError when aiCall limit reached", async () => { + mockDb.select + .mockReturnValueOnce(makeChain([tierDef])) + .mockReturnValueOnce(makeChain([{ aiCallsUsed: 5, recipeCount: 0, storageUsedMb: 0 }])); + + await expect(checkTierLimit("user1", "free", "aiCall")).rejects.toThrow(TierLimitError); + }); + + it("throws TierLimitError when recipe limit reached", async () => { + mockDb.select + .mockReturnValueOnce(makeChain([tierDef])) + .mockReturnValueOnce(makeChain([{ aiCallsUsed: 0, recipeCount: 10, storageUsedMb: 0 }])); + + await expect(checkTierLimit("user1", "free", "recipe")).rejects.toThrow(TierLimitError); + }); + + it("does not throw when no usage row exists (treats as zero)", async () => { + mockDb.select + .mockReturnValueOnce(makeChain([tierDef])) + .mockReturnValueOnce(makeChain([])); + + await expect(checkTierLimit("user1", "free", "aiCall")).resolves.toBeUndefined(); + }); + + it("does not throw when tier definition does not exist", async () => { + mockDb.select.mockReturnValueOnce(makeChain([])); + + await expect(checkTierLimit("user1", "free", "aiCall")).resolves.toBeUndefined(); + }); + + it("does not throw for storage key (no limit enforced)", async () => { + mockDb.select + .mockReturnValueOnce(makeChain([tierDef])) + .mockReturnValueOnce(makeChain([{ aiCallsUsed: 0, recipeCount: 0, storageUsedMb: 9999 }])); + + await expect(checkTierLimit("user1", "free", "storage")).resolves.toBeUndefined(); + }); +}); + +describe("incrementUsage", () => { + it("calls insert with correct aiCall initial values", async () => { + const chain = makeInsertChain(); + mockDb.insert.mockReturnValue(chain); + + await incrementUsage("user1", "aiCall"); + + expect(chain.values).toHaveBeenCalledWith( + expect.objectContaining({ + userId: "user1", + aiCallsUsed: 1, + recipeCount: 0, + storageUsedMb: 0, + }) + ); + }); + + it("calls insert with correct recipe initial values", async () => { + const chain = makeInsertChain(); + mockDb.insert.mockReturnValue(chain); + + await incrementUsage("user1", "recipe"); + + expect(chain.values).toHaveBeenCalledWith( + expect.objectContaining({ + recipeCount: 1, + aiCallsUsed: 0, + storageUsedMb: 0, + }) + ); + }); + + it("respects custom amount", async () => { + const chain = makeInsertChain(); + mockDb.insert.mockReturnValue(chain); + + await incrementUsage("user1", "storage", 50); + + expect(chain.values).toHaveBeenCalledWith( + expect.objectContaining({ storageUsedMb: 50 }) + ); + }); + + it("uses onConflictDoUpdate to increment (not overwrite)", async () => { + const chain = makeInsertChain(); + mockDb.insert.mockReturnValue(chain); + + await incrementUsage("user1", "aiCall"); + + expect(chain.onConflictDoUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + set: expect.objectContaining({ aiCallsUsed: expect.anything() }), + }) + ); + }); +}); diff --git a/apps/web/lib/__tests__/webhooks.test.ts b/apps/web/lib/__tests__/webhooks.test.ts new file mode 100644 index 0000000..6d600d8 --- /dev/null +++ b/apps/web/lib/__tests__/webhooks.test.ts @@ -0,0 +1,156 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import crypto from "crypto"; + +const mockWebhookSelect = vi.fn(); +const mockInsert = vi.fn(); +const mockInsertValues = vi.fn().mockResolvedValue(undefined); + +vi.mock("@epicure/db", () => ({ + db: { + select: vi.fn(() => ({ + from: vi.fn(() => ({ + where: mockWebhookSelect, + })), + })), + insert: vi.fn(() => ({ values: mockInsertValues })), + }, + webhooks: {}, + webhookDeliveries: {}, + eq: vi.fn(), + and: vi.fn(), +})); + +let fetchCalls: { url: string; body: string; headers: Record }[] = []; + +global.fetch = vi.fn(async (url: RequestInfo, init?: RequestInit) => { + const body = (init?.body as string) ?? ""; + fetchCalls.push({ + url: url as string, + body, + headers: (init?.headers as Record) ?? {}, + }); + return new Response(null, { status: 200 }); +}); + +const { dispatchWebhook } = await import("../webhooks"); + +beforeEach(() => { + fetchCalls = []; + vi.clearAllMocks(); + mockInsert.mockReturnValue({ values: mockInsertValues }); + global.fetch = vi.fn(async (url: RequestInfo, init?: RequestInit) => { + const body = (init?.body as string) ?? ""; + fetchCalls.push({ + url: url as string, + body, + headers: (init?.headers as Record) ?? {}, + }); + return new Response(null, { status: 200 }); + }); +}); + +describe("dispatchWebhook", () => { + it("does not fetch when no matching webhooks", async () => { + mockWebhookSelect.mockResolvedValue([]); + await dispatchWebhook("user1", "recipe.created", { id: "r1" }); + expect(global.fetch).not.toHaveBeenCalled(); + }); + + it("sends POST to webhook URL with event and payload", async () => { + mockWebhookSelect.mockResolvedValue([ + { id: "wh1", url: "https://example.com/hook", secret: "mysecret", events: [], active: true }, + ]); + + await dispatchWebhook("user1", "recipe.created", { id: "r1", title: "Test" }); + + expect(global.fetch).toHaveBeenCalledOnce(); + const call = fetchCalls[0]!; + expect(call.url).toBe("https://example.com/hook"); + const parsed = JSON.parse(call.body) as { event: string; payload: unknown }; + expect(parsed.event).toBe("recipe.created"); + expect(parsed.payload).toMatchObject({ id: "r1", title: "Test" }); + }); + + it("includes valid HMAC-SHA256 signature header", async () => { + const secret = "test-webhook-secret"; + mockWebhookSelect.mockResolvedValue([ + { id: "wh1", url: "https://example.com/hook", secret, events: [], active: true }, + ]); + + await dispatchWebhook("user1", "recipe.updated", { id: "r2" }); + + const call = fetchCalls[0]!; + const sigHeader = call.headers["X-Epicure-Signature"]; + expect(sigHeader).toBeTruthy(); + expect(sigHeader).toMatch(/^sha256=/); + + const expectedSig = "sha256=" + crypto.createHmac("sha256", secret).update(call.body).digest("hex"); + expect(sigHeader).toBe(expectedSig); + }); + + it("sends X-Epicure-Event header", async () => { + mockWebhookSelect.mockResolvedValue([ + { id: "wh1", url: "https://example.com/hook", secret: "s", events: [], active: true }, + ]); + + await dispatchWebhook("user1", "comment.added", {}); + + expect(fetchCalls[0]!.headers["X-Epicure-Event"]).toBe("comment.added"); + }); + + it("filters webhooks by event subscription", async () => { + mockWebhookSelect.mockResolvedValue([ + { id: "wh1", url: "https://a.com/hook", secret: "s", events: ["recipe.deleted"], active: true }, + { id: "wh2", url: "https://b.com/hook", secret: "s", events: [], active: true }, + ]); + + await dispatchWebhook("user1", "recipe.created", {}); + + // wh1 only subscribes to recipe.deleted, wh2 subscribes to all (empty = all) + expect(global.fetch).toHaveBeenCalledOnce(); + expect(fetchCalls[0]!.url).toBe("https://b.com/hook"); + }); + + it("records delivery success in DB", async () => { + const { db } = await import("@epicure/db"); + mockWebhookSelect.mockResolvedValue([ + { id: "wh1", url: "https://example.com/hook", secret: "s", events: [], active: true }, + ]); + + await dispatchWebhook("user1", "recipe.created", {}); + + expect(db.insert).toHaveBeenCalled(); + const insertedRow = mockInsertValues.mock.calls[0]![0] as Record; + expect(insertedRow.success).toBe(true); + expect(insertedRow.event).toBe("recipe.created"); + }); + + it("records delivery failure when fetch throws", async () => { + const { db } = await import("@epicure/db"); + global.fetch = vi.fn().mockRejectedValue(new Error("Network error")); + mockWebhookSelect.mockResolvedValue([ + { id: "wh1", url: "https://example.com/hook", secret: "s", events: [], active: true }, + ]); + + await dispatchWebhook("user1", "recipe.created", {}); + + expect(db.insert).toHaveBeenCalled(); + const insertedRow = mockInsertValues.mock.calls[0]![0] as Record; + expect(insertedRow.success).toBe(false); + expect(insertedRow.statusCode).toBe(0); + }); + + it("still dispatches remaining hooks if one fails (allSettled)", async () => { + global.fetch = vi.fn() + .mockRejectedValueOnce(new Error("Timeout")) + .mockResolvedValueOnce(new Response(null, { status: 200 })); + + mockWebhookSelect.mockResolvedValue([ + { id: "wh1", url: "https://fail.com/hook", secret: "s", events: [], active: true }, + { id: "wh2", url: "https://ok.com/hook", secret: "s", events: [], active: true }, + ]); + + await expect(dispatchWebhook("user1", "recipe.created", {})).resolves.toBeUndefined(); + expect(global.fetch).toHaveBeenCalledTimes(2); + }); +}); diff --git a/apps/web/lib/ai/__tests__/factory.test.ts b/apps/web/lib/ai/__tests__/factory.test.ts new file mode 100644 index 0000000..8aa7a67 --- /dev/null +++ b/apps/web/lib/ai/__tests__/factory.test.ts @@ -0,0 +1,95 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +// Mock AI SDK providers +const mockOpenAiModel = vi.fn(() => "openai-model-instance"); +const mockAnthropicModel = vi.fn(() => "anthropic-model-instance"); +const mockOpenAiProvider = vi.fn(() => mockOpenAiModel); +const mockAnthropicProvider = vi.fn(() => mockAnthropicModel); + +vi.mock("@ai-sdk/openai", () => ({ + createOpenAI: vi.fn(() => mockOpenAiProvider), +})); + +vi.mock("@ai-sdk/anthropic", () => ({ + createAnthropic: vi.fn(() => mockAnthropicProvider), +})); + +const { createOpenAI } = await import("@ai-sdk/openai"); +const { createAnthropic } = await import("@ai-sdk/anthropic"); +const { resolveModel } = await import("../factory"); + +beforeEach(() => { + vi.clearAllMocks(); + delete process.env["OPENAI_API_KEY"]; + delete process.env["ANTHROPIC_API_KEY"]; + delete process.env["OPENROUTER_API_KEY"]; + delete process.env["OLLAMA_BASE_URL"]; +}); + +describe("resolveModel", () => { + it("uses openai provider when specified", () => { + resolveModel({ provider: "openai", apiKey: "sk-test" }); + expect(createOpenAI).toHaveBeenCalledWith(expect.objectContaining({ apiKey: "sk-test" })); + }); + + it("uses anthropic provider when specified", () => { + resolveModel({ provider: "anthropic", apiKey: "sk-ant-test" }); + expect(createAnthropic).toHaveBeenCalledWith(expect.objectContaining({ apiKey: "sk-ant-test" })); + }); + + it("uses openrouter base URL when provider is openrouter", () => { + resolveModel({ provider: "openrouter", apiKey: "or-key" }); + expect(createOpenAI).toHaveBeenCalledWith( + expect.objectContaining({ baseURL: "https://openrouter.ai/api/v1" }) + ); + }); + + it("uses ollama base URL from env or default", () => { + process.env["OLLAMA_BASE_URL"] = "http://custom:11434/v1"; + resolveModel({ provider: "ollama" }); + expect(createOpenAI).toHaveBeenCalledWith( + expect.objectContaining({ baseURL: "http://custom:11434/v1" }) + ); + }); + + it("falls back to ollama default URL when env not set", () => { + resolveModel({ provider: "ollama" }); + expect(createOpenAI).toHaveBeenCalledWith( + expect.objectContaining({ baseURL: "http://localhost:11434/v1" }) + ); + }); + + it("uses custom model when specified", () => { + resolveModel({ provider: "openai", model: "gpt-4-turbo", apiKey: "sk-test" }); + expect(mockOpenAiProvider).toHaveBeenCalledWith("gpt-4-turbo"); + }); + + it("uses default openai model when none specified", () => { + resolveModel({ provider: "openai", apiKey: "sk-test" }); + expect(mockOpenAiProvider).toHaveBeenCalledWith("gpt-4o-mini"); + }); + + it("uses default anthropic model when none specified", () => { + resolveModel({ provider: "anthropic", apiKey: "sk-ant" }); + expect(mockAnthropicProvider).toHaveBeenCalledWith("claude-haiku-4-5-20251001"); + }); + + it("defaults to openrouter when OPENROUTER_API_KEY is set (no explicit provider)", () => { + process.env["OPENROUTER_API_KEY"] = "or-key"; + resolveModel({}); + expect(createOpenAI).toHaveBeenCalledWith( + expect.objectContaining({ baseURL: "https://openrouter.ai/api/v1" }) + ); + }); + + it("defaults to openai when OPENAI_API_KEY set and no openrouter", () => { + process.env["OPENAI_API_KEY"] = "sk-test"; + resolveModel({}); + expect(createOpenAI).toHaveBeenCalledWith(expect.objectContaining({ apiKey: "sk-test" })); + }); + + it("throws on unknown provider", () => { + // @ts-expect-error - testing invalid input + expect(() => resolveModel({ provider: "nonexistent" })).toThrow(); + }); +}); diff --git a/apps/web/lib/ai/__tests__/resolve-user-key.test.ts b/apps/web/lib/ai/__tests__/resolve-user-key.test.ts new file mode 100644 index 0000000..84fbbec --- /dev/null +++ b/apps/web/lib/ai/__tests__/resolve-user-key.test.ts @@ -0,0 +1,138 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { encrypt } from "../../encrypt"; + +const mockUserAiKeysFindMany = vi.fn(); +const mockUserModelPrefsFindFirst = vi.fn(); +const mockSiteSettingFindFirst = vi.fn(); + +vi.mock("@epicure/db", () => ({ + db: { + query: { + userAiKeys: { findMany: mockUserAiKeysFindMany }, + userModelPrefs: { findFirst: mockUserModelPrefsFindFirst }, + siteSettings: { findFirst: mockSiteSettingFindFirst }, + }, + }, + userAiKeys: {}, + userModelPrefs: {}, + siteSettings: {}, + eq: vi.fn((a, b) => ({ a, b })), + and: vi.fn((...args) => args), +})); + +const { getDefaultProviderWithKey, getModelConfigForUseCase, withUserKey } = await import("../resolve-user-key"); + +beforeEach(() => { + vi.clearAllMocks(); + delete process.env["OPENAI_API_KEY"]; + delete process.env["ANTHROPIC_API_KEY"]; + delete process.env["OPENROUTER_API_KEY"]; + // Make getSiteSetting return null by default + mockSiteSettingFindFirst.mockResolvedValue(null); +}); + +describe("getDefaultProviderWithKey", () => { + it("returns empty config when no keys at all", async () => { + mockUserAiKeysFindMany.mockResolvedValue([]); + const config = await getDefaultProviderWithKey("user1"); + expect(config).toEqual({}); + }); + + it("returns openrouter key first (priority order)", async () => { + const encOpenrouter = encrypt("or-key"); + const encOpenai = encrypt("sk-test"); + mockUserAiKeysFindMany.mockResolvedValue([ + { provider: "openai", encryptedKey: encOpenai }, + { provider: "openrouter", encryptedKey: encOpenrouter }, + ]); + + const config = await getDefaultProviderWithKey("user1"); + expect(config.provider).toBe("openrouter"); + expect(config.apiKey).toBe("or-key"); + }); + + it("falls back to openai when no openrouter", async () => { + const encOpenai = encrypt("sk-openai"); + mockUserAiKeysFindMany.mockResolvedValue([ + { provider: "openai", encryptedKey: encOpenai }, + ]); + + const config = await getDefaultProviderWithKey("user1"); + expect(config.provider).toBe("openai"); + expect(config.apiKey).toBe("sk-openai"); + }); + + it("uses site settings when no BYOK key exists", async () => { + mockUserAiKeysFindMany.mockResolvedValue([]); + // Mock getSiteSetting via siteSettings.findFirst to return encrypted key for OPENAI + const encKey = encrypt("sk-from-site-settings"); + mockSiteSettingFindFirst.mockResolvedValueOnce(null) // openrouter + .mockResolvedValueOnce({ value: encKey, isSecret: true }); // openai + + const config = await getDefaultProviderWithKey("user1"); + expect(config.provider).toBe("openai"); + expect(config.apiKey).toBe("sk-from-site-settings"); + }); + + it("skips corrupted BYOK key and tries next provider", async () => { + mockUserAiKeysFindMany.mockResolvedValue([ + { provider: "openrouter", encryptedKey: "CORRUPT:NOT:VALID" }, + { provider: "openai", encryptedKey: encrypt("sk-valid") }, + ]); + + const config = await getDefaultProviderWithKey("user1"); + expect(config.provider).toBe("openai"); + expect(config.apiKey).toBe("sk-valid"); + }); +}); + +describe("withUserKey", () => { + it("injects BYOK key when user has one for this provider", async () => { + const encKey = encrypt("sk-user-key"); + vi.mocked(mockUserAiKeysFindMany); // just ensure mock is ready + // withUserKey uses findFirst via userAiKeys + const mockFindFirst = vi.fn().mockResolvedValue({ encryptedKey: encKey }); + vi.mocked((await import("@epicure/db")).db.query.userAiKeys as unknown as { findFirst: typeof mockFindFirst }).findFirst = mockFindFirst; + + const config = await withUserKey("user1", { provider: "openai" }); + expect(config.apiKey).toBe("sk-user-key"); + }); + + it("returns config unchanged when no user key for provider", async () => { + const mockFindFirst = vi.fn().mockResolvedValue(null); + vi.mocked((await import("@epicure/db")).db.query.userAiKeys as unknown as { findFirst: typeof mockFindFirst }).findFirst = mockFindFirst; + + const config = await withUserKey("user1", { provider: "anthropic" }); + expect(config.apiKey).toBeUndefined(); + }); +}); + +describe("getModelConfigForUseCase", () => { + it("uses user model prefs when set", async () => { + mockUserModelPrefsFindFirst.mockResolvedValue({ + textProvider: "anthropic", + textModel: "claude-sonnet-4-6", + visionProvider: null, + visionModel: null, + mealPlanProvider: null, + mealPlanModel: null, + }); + const mockFindFirst = vi.fn().mockResolvedValue(null); // no BYOK key + vi.mocked((await import("@epicure/db")).db.query.userAiKeys as unknown as { findFirst: typeof mockFindFirst }).findFirst = mockFindFirst; + + const config = await getModelConfigForUseCase("user1", "text"); + expect(config.provider).toBe("anthropic"); + expect(config.model).toBe("claude-sonnet-4-6"); + }); + + it("falls back to getDefaultProviderWithKey when no prefs", async () => { + mockUserModelPrefsFindFirst.mockResolvedValue(null); + mockUserAiKeysFindMany.mockResolvedValue([ + { provider: "openai", encryptedKey: encrypt("sk-default") }, + ]); + + const config = await getModelConfigForUseCase("user1", "vision"); + expect(config.provider).toBe("openai"); + expect(config.apiKey).toBe("sk-default"); + }); +}); diff --git a/apps/web/lib/ai/features/adapt-recipe.ts b/apps/web/lib/ai/features/adapt-recipe.ts index 16007dc..f21eafa 100644 --- a/apps/web/lib/ai/features/adapt-recipe.ts +++ b/apps/web/lib/ai/features/adapt-recipe.ts @@ -47,7 +47,7 @@ export async function adaptRecipe( excludeIngredients: string[]; extraConstraints?: string; }, - config?: AiConfig, + config?: AiConfig & { userContext?: string }, locale?: string ): Promise { const model = resolveModel(config); @@ -74,7 +74,7 @@ export async function adaptRecipe( model, schema: AdaptedRecipeSchema, system: - `You are a professional chef specializing in recipe adaptation. When adapting recipes, find the best substitutes that preserve the spirit, flavor profile, and texture of the original. Always explain what changed and why in adaptationNotes. For ingredient quantities: use numbers only, units separately. Respond in ${lang}.`, + `You are a professional chef specializing in recipe adaptation. When adapting recipes, find the best substitutes that preserve the spirit, flavor profile, and texture of the original. Always explain what changed and why in adaptationNotes. For ingredient quantities: use numbers only, units separately. Respond in ${lang}.${config?.userContext ? `\n\nUser preferences and context:\n${config.userContext}` : ""}`, prompt: `Adapt the following recipe with these constraints:\n\n${constraintText}\n\nOriginal recipe:\n${recipeText}`, }); diff --git a/apps/web/lib/ai/features/generate-meal-plan.ts b/apps/web/lib/ai/features/generate-meal-plan.ts index 5fe8b77..05657dc 100644 --- a/apps/web/lib/ai/features/generate-meal-plan.ts +++ b/apps/web/lib/ai/features/generate-meal-plan.ts @@ -11,7 +11,7 @@ const MealPlanSchema = z.object({ description: z.string().max(300), ingredients: z.array(z.object({ rawName: z.string(), - quantity: z.string().optional(), + quantity: z.number().optional(), unit: z.string().optional(), })).max(20), steps: z.array(z.object({ @@ -36,8 +36,9 @@ export async function generateMealPlan( pantryItems?: string[]; days?: Array<"mon" | "tue" | "wed" | "thu" | "fri" | "sat" | "sun">; pantryMode?: boolean; + difficulty?: "easy" | "medium" | "hard"; }, - config?: AiConfig, + config?: AiConfig & { userContext?: string }, locale?: string ): Promise { const model = resolveModel(config); @@ -45,28 +46,35 @@ export async function generateMealPlan( const servings = options.servings ?? 2; const days = options.days ?? ["mon", "tue", "wed", "thu", "fri", "sat", "sun"]; const pantryMode = options.pantryMode ?? false; + const difficulty = options.difficulty; const dietaryClause = options.dietaryPrefs?.trim() ? `Dietary requirements: ${options.dietaryPrefs.trim()}.` : ""; + const difficultyClause = difficulty + ? `All recipes must be ${difficulty} difficulty: ${{ easy: "simple techniques, few steps, everyday ingredients", medium: "moderate skill, standard techniques", hard: "advanced techniques, multiple components" }[difficulty]}.` + : ""; const pantryClause = options.pantryItems && options.pantryItems.length > 0 ? `Available pantry ingredients to use: ${options.pantryItems.slice(0, 20).join(", ")}.` : ""; const systemPrompt = pantryMode - ? `You are a professional nutritionist and chef. Generate a meal plan that maximizes use of the provided pantry items. Prefer meals using multiple pantry ingredients. Minimize additional shopping required. Vary cuisines and cooking methods where possible. Make meals achievable for home cooks. Respond in ${lang}.` - : `You are a professional nutritionist and chef. Generate balanced, practical, and delicious weekly meal plans. Vary cuisines and cooking methods throughout the week. Make meals achievable for home cooks. Respond in ${lang}.`; + ? `You are a professional nutritionist and chef. Generate a meal plan that maximizes use of the provided pantry items. Prefer meals using multiple pantry ingredients. Minimize additional shopping required. Vary cuisines and cooking methods where possible. Make meals achievable for home cooks. For ingredients: quantity must be a number only (e.g. 0.25, 1.5, 2), unit is a separate string (e.g. 'cup', 'tbsp', 'g', 'ml'). Never combine quantity and unit. Respond in ${lang}.` + : `You are a professional nutritionist and chef. Generate balanced, practical, and delicious weekly meal plans. Vary cuisines and cooking methods throughout the week. Make meals achievable for home cooks. For ingredients: quantity must be a number only (e.g. 0.25, 1.5, 2), unit is a separate string (e.g. 'cup', 'tbsp', 'g', 'ml'). Never combine quantity and unit. Respond in ${lang}.`; + + const systemPromptWithContext = systemPrompt + (config?.userContext ? `\n\nUser preferences and context:\n${config.userContext}` : ""); const { object } = await generateObject({ model, schema: MealPlanSchema, - system: systemPrompt, + system: systemPromptWithContext, prompt: [ `Generate a meal plan for ${days.length} day(s): ${days.join(", ")}.`, `Include breakfast, lunch, and dinner for each day.`, `Plan for ${servings} servings per meal.`, dietaryClause, + difficultyClause, pantryClause, pantryMode ? "Prioritize using the listed pantry ingredients across as many meals as possible. Minimize ingredients that need to be purchased." diff --git a/apps/web/lib/ai/features/generate-recipe.ts b/apps/web/lib/ai/features/generate-recipe.ts index b751242..3d0bbc5 100644 --- a/apps/web/lib/ai/features/generate-recipe.ts +++ b/apps/web/lib/ai/features/generate-recipe.ts @@ -34,17 +34,19 @@ export type GeneratedRecipe = z.infer; export async function generateRecipe( prompt: string, - config?: AiConfig & { language?: string } + config?: AiConfig & { language?: string; difficulty?: "easy" | "medium" | "hard"; userContext?: string } ): Promise { const model = resolveModel(config); const lang = config?.language ?? "en"; const langInstruction = lang !== "en" ? ` Write the entire recipe (title, description, ingredient names, step instructions) in ${lang}.` : ""; + const difficultyInstruction = config?.difficulty ? ` The recipe must be ${config.difficulty} difficulty: ${{ easy: "simple techniques, minimal steps, common ingredients", medium: "moderate skill required, standard techniques", hard: "advanced techniques, multiple components, skilled cook required" }[config.difficulty]}.` : ""; + const userInstruction = config?.userContext ? `\n\nUser preferences and context:\n${config.userContext}` : ""; const { object } = await generateObject({ model, schema: RecipeOutputSchema, system: - `You are a professional chef and culinary writer. Generate detailed, accurate, and delicious recipes. For ingredients: quantity must be a number only (e.g. 0.25, 1.5, 2), unit is a separate string (e.g. 'cup', 'tbsp', 'g', 'ml'). Never combine quantity and unit into one string. Include helpful notes for ingredients when relevant.${langInstruction}`, + `You are a professional chef and culinary writer. Generate detailed, accurate, and delicious recipes. For ingredients: quantity must be a number only (e.g. 0.25, 1.5, 2), unit is a separate string (e.g. 'cup', 'tbsp', 'g', 'ml'). Never combine quantity and unit into one string. Include helpful notes for ingredients when relevant.${langInstruction}${difficultyInstruction}${userInstruction}`, prompt: `Create a recipe for: ${prompt}`, }); diff --git a/apps/web/lib/ai/features/import-url.ts b/apps/web/lib/ai/features/import-url.ts index 47d30f2..99662d3 100644 --- a/apps/web/lib/ai/features/import-url.ts +++ b/apps/web/lib/ai/features/import-url.ts @@ -1,7 +1,64 @@ import { generateObject } from "ai"; +import dns from "node:dns/promises"; import { z } from "zod"; import { resolveModel, type AiConfig } from "../factory"; +/** + * Resolves the hostname in `rawUrl` and returns an error string if the + * URL targets a private/reserved address range (SSRF guard), or null if safe. + */ +async function validateImportUrl(rawUrl: string): Promise { + let url: URL; + try { + url = new URL(rawUrl); + } catch { + return "Invalid URL"; + } + + if (url.protocol !== "https:" && url.protocol !== "http:") { + return "URL must use http or https"; + } + + let addresses: string[]; + try { + const results = await dns.lookup(url.hostname, { all: true, family: 0 }); + addresses = results.map((r) => r.address); + } catch { + return "Unable to resolve hostname"; + } + + for (const addr of addresses) { + if (isPrivateAddress(addr)) { + return "URL must not point to a private or reserved address"; + } + } + + return null; +} + +function isPrivateAddress(ip: string): boolean { + const v4 = ip.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/); + if (v4) { + const [, a, b, c] = v4.map(Number) as [number, number, number, number, number]; + if (a === 127) return true; + if (a === 10) return true; + if (a === 172 && b >= 16 && b <= 31) return true; + if (a === 192 && b === 168) return true; + if (a === 169 && b === 254) return true; + if (a >= 224) return true; + return false; + } + const lower = ip.toLowerCase(); + if (lower === "::1") return true; + if (lower === "::") return true; + if (lower.startsWith("fe80:")) return true; + if (lower.startsWith("fc") || lower.startsWith("fd")) return true; + const v4mapped = lower.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/); + if (v4mapped) return isPrivateAddress(v4mapped[1]!); + return false; +} + + const ImportedRecipeSchema = z.object({ title: z.string(), description: z.string().optional(), @@ -33,6 +90,9 @@ const ImportedRecipeSchema = z.object({ export type ImportedRecipe = z.infer; export async function importFromUrl(url: string, config?: AiConfig): Promise { + const ssrfError = await validateImportUrl(url); + if (ssrfError) throw new Error(ssrfError); + const res = await fetch(url, { headers: { "User-Agent": "Mozilla/5.0 (compatible; Epicure/1.0; recipe importer)" }, signal: AbortSignal.timeout(10000), diff --git a/apps/web/lib/ai/features/suggest-drinks.ts b/apps/web/lib/ai/features/suggest-drinks.ts index 270d0a7..b1b3723 100644 --- a/apps/web/lib/ai/features/suggest-drinks.ts +++ b/apps/web/lib/ai/features/suggest-drinks.ts @@ -27,7 +27,7 @@ export async function suggestDrinks( ingredients: Array<{ rawName: string }>; }, count = 4, - config?: AiConfig, + config?: AiConfig & { userContext?: string }, locale?: string ): Promise { const model = resolveModel(config); @@ -41,7 +41,7 @@ export async function suggestDrinks( model, schema: DrinksOutputSchema, system: - `You are a sommelier and beverage expert. For each suggestion, lead with a style or category (e.g. 'Dry white Burgundy', 'Session IPA', 'Sparkling water with citrus') rather than a specific product. Then provide 2–3 concrete examples to illustrate the style. Consider flavor profiles, acidity, tannins, sweetness, weight, and cultural pairing traditions. Include a mix of alcoholic and non-alcoholic options. Respond in ${lang}.`, + `You are a sommelier and beverage expert. For each suggestion, lead with a style or category (e.g. 'Dry white Burgundy', 'Session IPA', 'Sparkling water with citrus') rather than a specific product. Then provide 2–3 concrete examples to illustrate the style. Consider flavor profiles, acidity, tannins, sweetness, weight, and cultural pairing traditions. Include a mix of alcoholic and non-alcoholic options. Respond in ${lang}.${config?.userContext ? `\n\nUser preferences and context:\n${config.userContext}` : ""}`, prompt: `Suggest ${count} drinks to serve with this recipe.\n\nRecipe: ${recipe.title}\n${recipe.description ? `Description: ${recipe.description}` : ""}\n${dietaryContext ? `Dietary: ${dietaryContext}` : ""}\nKey ingredients: ${recipe.ingredients.slice(0, 8).map((i) => i.rawName).join(", ")}`, }); diff --git a/apps/web/lib/ai/features/suggest-pairings.ts b/apps/web/lib/ai/features/suggest-pairings.ts index 613406e..3072133 100644 --- a/apps/web/lib/ai/features/suggest-pairings.ts +++ b/apps/web/lib/ai/features/suggest-pairings.ts @@ -26,7 +26,7 @@ export async function suggestPairings( ingredients: Array<{ rawName: string }>; }, count = 4, - config?: AiConfig, + config?: AiConfig & { userContext?: string }, locale?: string ): Promise { const model = resolveModel(config); @@ -40,7 +40,7 @@ export async function suggestPairings( model, schema: PairingsOutputSchema, system: - `You are an expert chef and menu planner. Suggest complementary dishes that form a balanced, cohesive complete meal. Consider flavor profiles, cooking techniques, dietary restrictions, and cultural harmony. Avoid duplicating ingredients heavily. Suggest a balanced mix of roles (starter, sides, dessert, drinks). Respond in ${lang}.`, + `You are an expert chef and menu planner. Suggest complementary dishes that form a balanced, cohesive complete meal. Consider flavor profiles, cooking techniques, dietary restrictions, and cultural harmony. Avoid duplicating ingredients heavily. Suggest a balanced mix of roles (starter, sides, dessert, drinks). Respond in ${lang}.${config?.userContext ? `\n\nUser preferences and context:\n${config.userContext}` : ""}`, prompt: `Suggest ${count} dishes that pair well with this recipe to form a complete meal:\n\nRecipe: ${recipe.title}\n${recipe.description ? `Description: ${recipe.description}` : ""}\n${dietaryContext ? `Dietary: ${dietaryContext}` : ""}\nKey ingredients: ${recipe.ingredients.slice(0, 8).map((i) => i.rawName).join(", ")}`, }); diff --git a/apps/web/lib/ai/features/suggest-variations.ts b/apps/web/lib/ai/features/suggest-variations.ts index 0c12ad6..b990ad5 100644 --- a/apps/web/lib/ai/features/suggest-variations.ts +++ b/apps/web/lib/ai/features/suggest-variations.ts @@ -39,7 +39,7 @@ export async function suggestVariations( steps: Array<{ instruction: string }>; }, count = 3, - config?: AiConfig, + config?: AiConfig & { userContext?: string }, directions?: string, locale?: string ): Promise { @@ -61,7 +61,7 @@ export async function suggestVariations( model, schema: VariationsOutputSchema, system: - `You are a creative chef. Suggest meaningful variations of recipes — dietary adaptations, flavor profiles, technique changes. Each variation should be distinct and practical. Respond in ${lang}.`, + `You are a creative chef. Suggest meaningful variations of recipes — dietary adaptations, flavor profiles, technique changes. Each variation should be distinct and practical. Respond in ${lang}.${config?.userContext ? `\n\nUser preferences and context:\n${config.userContext}` : ""}`, prompt: `Suggest ${count} variations of this recipe:\n\n${recipeText}${directionsClause}`, }); diff --git a/apps/web/lib/ai/user-bio.ts b/apps/web/lib/ai/user-bio.ts new file mode 100644 index 0000000..784d114 --- /dev/null +++ b/apps/web/lib/ai/user-bio.ts @@ -0,0 +1,14 @@ +import { db, users, eq } from "@epicure/db"; + +export async function getUserPrivateBio(userId: string): Promise { + const row = await db.query.users.findFirst({ + where: eq(users.id, userId), + columns: { privateBio: true }, + }); + return row?.privateBio ?? null; +} + +export function buildUserBioContext(privateBio: string | null): string { + if (!privateBio?.trim()) return ""; + return `\n\nUser preferences and context:\n${privateBio.trim()}`; +} diff --git a/apps/web/lib/auth/client.ts b/apps/web/lib/auth/client.ts index 54b494f..8974fb1 100644 --- a/apps/web/lib/auth/client.ts +++ b/apps/web/lib/auth/client.ts @@ -1,8 +1,11 @@ "use client"; import { createAuthClient } from "better-auth/react"; +import { genericOAuthClient } from "better-auth/client/plugins"; -export const authClient = createAuthClient(); +export const authClient = createAuthClient({ + plugins: [genericOAuthClient()], +}); export const { signIn, diff --git a/apps/web/lib/auth/server.ts b/apps/web/lib/auth/server.ts index 78e94b7..dde383b 100644 --- a/apps/web/lib/auth/server.ts +++ b/apps/web/lib/auth/server.ts @@ -1,4 +1,5 @@ import { betterAuth } from "better-auth"; +import { genericOAuth } from "better-auth/plugins"; import { drizzleAdapter } from "better-auth/adapters/drizzle"; import { db, users, sessions, accounts, verifications, eq, count } from "@epicure/db"; import { sendEmail, verifyEmailHtml, resetPasswordHtml, welcomeHtml } from "@/lib/email"; @@ -38,8 +39,37 @@ export const auth = betterAuth({ clientId: process.env["GOOGLE_CLIENT_ID"] ?? "", clientSecret: process.env["GOOGLE_CLIENT_SECRET"] ?? "", }, + ...(process.env["GITHUB_CLIENT_ID"] && { + github: { + clientId: process.env["GITHUB_CLIENT_ID"], + clientSecret: process.env["GITHUB_CLIENT_SECRET"] ?? "", + }, + }), + ...(process.env["DISCORD_CLIENT_ID"] && { + discord: { + clientId: process.env["DISCORD_CLIENT_ID"], + clientSecret: process.env["DISCORD_CLIENT_SECRET"] ?? "", + }, + }), }, + plugins: [ + ...(process.env["AUTHENTIK_CLIENT_ID"] && process.env["AUTHENTIK_BASE_URL"] ? [ + genericOAuth({ + config: [ + { + providerId: "authentik", + clientId: process.env["AUTHENTIK_CLIENT_ID"], + clientSecret: process.env["AUTHENTIK_CLIENT_SECRET"] ?? "", + // Authentik OIDC discovery URL: https:///application/o// + discoveryUrl: `${process.env["AUTHENTIK_BASE_URL"]}/.well-known/openid-configuration`, + scopes: ["openid", "email", "profile"], + }, + ], + }), + ] : []), + ], + session: { cookieCache: { enabled: true, diff --git a/apps/web/lib/encrypt.ts b/apps/web/lib/encrypt.ts index 1d25f6f..085b925 100644 --- a/apps/web/lib/encrypt.ts +++ b/apps/web/lib/encrypt.ts @@ -1,10 +1,31 @@ -import { createCipheriv, createDecipheriv, randomBytes, createHash } from "crypto"; +import { + createCipheriv, + createDecipheriv, + randomBytes, + hkdfSync, +} from "crypto"; const ALGORITHM = "aes-256-gcm"; +// Static salt — committed to source so it is stable across deployments. +// It does NOT need to be secret; its purpose is domain-separation and to +// prevent the raw secret from being used directly as a key. +const HKDF_SALT = Buffer.from("epicure-encryption-v1-salt", "utf8"); +const HKDF_INFO = Buffer.from("epicure-aes-256-gcm-key", "utf8"); + function getKey(): Buffer { - const secret = process.env["BETTER_AUTH_SECRET"] ?? "dev-secret-needs-32-bytes-padding!"; - return createHash("sha256").update(secret).digest(); + // Prefer a dedicated encryption secret; fall back to BETTER_AUTH_SECRET so + // existing deployments keep working without an immediate migration. + const secret = + process.env["ENCRYPTION_SECRET"] ?? process.env["BETTER_AUTH_SECRET"]; + if (!secret) throw new Error("ENCRYPTION_SECRET (or BETTER_AUTH_SECRET) is required"); + + // HKDF-SHA256 provides proper key derivation with domain separation. + // This replaces the previous raw SHA-256 hash which offered no stretching + // or salt, making brute-force against leaked ciphertexts trivial. + return Buffer.from( + hkdfSync("sha256", secret, HKDF_SALT, HKDF_INFO, 32) + ); } export function encrypt(plaintext: string): string { diff --git a/apps/web/lib/parse-quantity.ts b/apps/web/lib/parse-quantity.ts new file mode 100644 index 0000000..3f74c37 --- /dev/null +++ b/apps/web/lib/parse-quantity.ts @@ -0,0 +1,37 @@ +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, +}; + +export 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); +} diff --git a/apps/web/lib/tiers.ts b/apps/web/lib/tiers.ts index efb729d..b97c97c 100644 --- a/apps/web/lib/tiers.ts +++ b/apps/web/lib/tiers.ts @@ -16,6 +16,60 @@ export class TierLimitError extends Error { } } +/** + * Atomically checks the tier limit and increments the usage counter in a + * single SQL statement, eliminating the TOCTOU race that existed when + * checkTierLimit and incrementUsage were called separately. + * + * Throws TierLimitError if the limit has already been reached. + * Use this instead of the separate checkTierLimit + incrementUsage pair + * for "recipe" and "aiCall" keys. + */ +export async function checkAndIncrementTierLimit( + userId: string, + userTier: "free" | "pro", + key: "recipe" | "aiCall" +): Promise { + const [tierDef] = await db + .select() + .from(tierDefinitions) + .where(eq(tierDefinitions.tier, userTier)); + + if (!tierDef) return; + + const month = currentMonth(); + const id = `${userId}-${month}`; + + if (key === "aiCall") { + const limit = tierDef.aiCallsPerMonth; + const result = await db.execute(sql` + INSERT INTO user_usage (id, user_id, month, ai_calls_used, recipe_count, storage_used_mb) + VALUES (${id}, ${userId}, ${month}, 1, 0, 0) + ON CONFLICT (user_id, month) DO UPDATE + SET ai_calls_used = user_usage.ai_calls_used + 1 + WHERE user_usage.ai_calls_used < ${limit} + RETURNING ai_calls_used + `); + if (result.length === 0) { + throw new TierLimitError("aiCall", userTier); + } + } else { + const limit = tierDef.maxRecipes; + const result = await db.execute(sql` + INSERT INTO user_usage (id, user_id, month, ai_calls_used, recipe_count, storage_used_mb) + VALUES (${id}, ${userId}, ${month}, 0, 1, 0) + ON CONFLICT (user_id, month) DO UPDATE + SET recipe_count = user_usage.recipe_count + 1 + WHERE user_usage.recipe_count < ${limit} + RETURNING recipe_count + `); + if (result.length === 0) { + throw new TierLimitError("recipe", userTier); + } + } +} + +/** @deprecated Use checkAndIncrementTierLimit for recipe/aiCall keys to avoid TOCTOU races. */ export async function checkTierLimit( userId: string, userTier: "free" | "pro", diff --git a/apps/web/lib/validate-webhook-url.ts b/apps/web/lib/validate-webhook-url.ts new file mode 100644 index 0000000..eff466d --- /dev/null +++ b/apps/web/lib/validate-webhook-url.ts @@ -0,0 +1,56 @@ +import dns from "node:dns/promises"; + +function isPrivateAddress(ip: string): boolean { + const v4 = ip.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/); + if (v4) { + const [, a, b, c] = v4.map(Number) as [number, number, number, number, number]; + if (a === 127) return true; + if (a === 10) return true; + if (a === 172 && b >= 16 && b <= 31) return true; + if (a === 192 && b === 168) return true; + if (a === 169 && b === 254) return true; + if (a >= 224) return true; + return false; + } + + const lower = ip.toLowerCase(); + if (lower === "::1" || lower === "::") return true; + if (lower.startsWith("fe80:")) return true; + if (lower.startsWith("fc") || lower.startsWith("fd")) return true; + const v4mapped = lower.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/); + if (v4mapped) return isPrivateAddress(v4mapped[1]!); + return false; +} + +/** + * Returns an error string if the URL is disallowed (SSRF), or null if safe. + * Blocks non-http(s) schemes, RFC-1918, loopback, link-local, cloud metadata. + */ +export async function validateWebhookUrl(rawUrl: string): Promise { + let url: URL; + try { + url = new URL(rawUrl); + } catch { + return "Invalid URL"; + } + + if (url.protocol !== "https:" && url.protocol !== "http:") { + return "Webhook URL must use http or https"; + } + + let addresses: string[]; + try { + const results = await dns.lookup(url.hostname, { all: true, family: 0 }); + addresses = results.map((r) => r.address); + } catch { + return "Unable to resolve webhook hostname"; + } + + for (const addr of addresses) { + if (isPrivateAddress(addr)) { + return "Webhook URL must not point to a private or reserved address"; + } + } + + return null; +} diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index af20b62..0bd9c0b 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -28,6 +28,28 @@ "save": "Save", "cancel": "Cancel", "delete": "Delete", + "noIngredientsSteps": "No ingredients or steps yet.", + "generateContent": "Generate with AI", + "editManually": "Edit manually", + "deleted": "Recipe deleted", + "deleteFailed": "Delete failed", + "imported": "Recipe imported! Review before publishing.", + "adapted": "Adapted recipe saved as draft", + "adaptFailed": "Failed to adapt recipe", + "adaptConstraintRequired": "Select at least one ingredient to exclude or add a constraint", + "variationsDirectionsPlaceholder": "e.g. make it vegan, use only pantry staples, reduce sugar…", + "historyRestoreFailed": "Failed to restore version", + "pairingMealFailed": "Failed to suggest pairings", + "pairingDrinkFailed": "Failed to suggest drinks", + "pairingGenerateFailed": "Failed to generate \"{name}\"", + "pairingSaveFailed": "Failed to save \"{name}\"", + "pairingSuccess": "Recipe generated — review before publishing", + "shoppingListCreateFailed": "Failed to create list", + "shoppingListAddFailed": "Failed to add ingredients", + "bulkDeleted": "{count, plural, one {1 recipe deleted} other {{count} recipes deleted}}", + "bulkVisibility": "{count, plural, one {1 recipe set to {visibility}} other {{count} recipes set to {visibility}}}", + "bulkDeleteFailed": "Delete failed", + "bulkUpdateFailed": "Update failed", "visibility": { "private": "Private", "unlisted": "Unlisted", @@ -52,14 +74,32 @@ "generate": { "title": "Generate recipe with AI", "description": "Describe what you want to cook and AI will create a complete recipe.", + "descriptionFull": "Describe a dish in words or snap a photo — AI builds the full recipe.", + "tabDescribe": "Describe", + "tabPhoto": "From photo", + "surpriseMe": "Surprise me", "prompt": "What do you want to cook?", "placeholder": "e.g. A creamy mushroom risotto with parmesan, serves 4, easy difficulty", - "language": "Language", + "language": "Recipe language", + "difficulty": "Difficulty", + "anyDifficulty": "Any", "button": "Generate", "generating": "Generating…", "success": "Recipe generated! Review and edit before publishing.", "error": "Failed to generate recipe", - "saveError": "Failed to save generated recipe" + "saveError": "Failed to save generated recipe", + "uploadClick": "Click to upload", + "uploadDrag": "or drag a food photo", + "uploadFormats": "JPEG, PNG or WebP · AI will reverse-engineer the recipe", + "analyzing": "Analyzing…", + "analyzePhoto": "Analyzing dish photo…", + "recognizeDish": "Recognize dish", + "photoSuccess": "Recipe recognized! Review and edit before publishing.", + "photoError": "Failed to analyze photo", + "contentGenerated": "Ingredients and steps generated!", + "contentGenerateFailed": "Generation failed", + "contentSaveFailed": "Failed to save generated content", + "generatingContent": "Generating recipe content…" }, "variations": { "title": "AI Recipe Variations", @@ -102,19 +142,31 @@ "save": "Save", "saved": "Saved", "saveFailed": "Failed to save", + "deleteFailed": "Delete failed", + "updateFailed": "Update failed", + "copyFailed": "Failed to copy to clipboard", + "deleted": "Deleted", "cancel": "Cancel", "delete": "Delete", "confirm": "Confirm", - "back": "Back" + "back": "Back", + "difficulty": "Difficulty", + "anyDifficulty": "Any", + "editManually": "Edit manually", + "surpriseMe": "Surprise me", + "generatingContent": "Generating recipe content…" }, "auth": { "signIn": "Sign in", "signInTitle": "Sign in to your recipe library", "signInLoading": "Signing in…", + "signInFailed": "Sign in failed", "signUp": "Sign up", "signUpTitle": "Create account", "signUpSubtitle": "Start building your recipe library", "signUpLoading": "Creating account…", + "signUpFailed": "Sign up failed", + "signUpSuccess": "Account created — check your email to verify", "continueWithGoogle": "Continue with Google", "email": "Email", "emailPlaceholder": "you@example.com", @@ -126,6 +178,7 @@ "forgotPasswordSent": "Check your inbox", "forgotPasswordDescription": "Enter your email and we'll send you a reset link.", "forgotPasswordSentDescription": "If an account exists for that email, you'll receive a reset link shortly.", + "forgotPasswordFailed": "Failed to send reset email", "sendResetLink": "Send reset link", "sendingLink": "Sending…", "resetPasswordTitle": "Reset password", @@ -135,6 +188,9 @@ "updatePassword": "Update password", "updatingPassword": "Updating…", "invalidToken": "Invalid or missing reset token.", + "resetPasswordMismatch": "Passwords don't match", + "resetPasswordFailed": "Reset failed — link may have expired", + "resetPasswordSuccess": "Password updated", "noAccount": "No account? Sign up", "alreadyHaveAccount": "Already have an account? Sign in", "backToSignIn": "Back to sign in", @@ -142,6 +198,8 @@ "checkInboxVerification": "Check your inbox for a verification email.", "resendVerification": "Resend verification email", "resendingSending": "Sending…", + "resendFailed": "Failed to resend", + "resendSuccess": "Verification email sent — check your inbox", "or": "or" }, "recipes": { @@ -172,6 +230,9 @@ "cookMins": "Cook (min)", "difficulty": "Difficulty", "visibility": "Visibility", + "tags": "Tags", + "tagsHelp": "Press Enter to add · Backspace to remove last · max 20 tags", + "tagsPlaceholder": "Add tags… (press Enter)", "dietaryTags": "Dietary tags", "photos": "Photos", "ingredients": "Ingredients", @@ -209,7 +270,11 @@ "addTo": "Add to {day} – {meal}", "searchRecipes": "Search recipes…", "servings": "Servings", + "difficulty": "Difficulty", "noRecipes": "No recipes found", + "addFailed": "Failed to add", + "addedToPantry": "{count, plural, one {1 item added to pantry} other {{count} items added to pantry}}", + "listCreated": "List created", "days": { "mon": "Mon", "tue": "Tue", @@ -268,6 +333,17 @@ "recipeCount": "{count} recipe", "recipeCountPlural": "{count} recipes" }, + "social": { + "followed": "Following", + "unfollowed": "Unfollowed", + "commentFailed": "Failed to post comment", + "deleted": "Deleted", + "ratingSaved": "Rating saved", + "collectionCreated": "Collection created", + "collectionForked": "Collection forked to your library", + "inviteSent": "Invitation sent", + "memberRemoved": "Member removed" + }, "cookingMode": { "cooking": "Cooking", "stepOf": "Step {current} of {total}", @@ -336,6 +412,30 @@ "changingPassword": "Changing…", "changePasswordButton": "Change password", "passwordChanged": "Password changed successfully.", - "passwordChangeFailed": "Failed to change password." + "passwordChangeFailed": "Failed to change password.", + "webhookCreateFailed": "Failed to create webhook", + "webhookDeleteFailed": "Failed to delete webhook", + "webhookUpdateFailed": "Failed to update webhook", + "redeliverySuccess": "Redelivery queued", + "redeliveryFailed": "Failed to redeliver", + "apiKeyCreateFailed": "Failed to create API key", + "apiKeyRevokeFailed": "Failed to revoke API key", + "copyFailed": "Failed to copy to clipboard", + "byokSaveFailed": "Failed to save key", + "byokRemoveFailed": "Failed to remove key", + "modelSaved": "Model preferences saved", + "modelSaveFailed": "Failed to save", + "userUpdated": "User updated successfully", + "userUpdateFailed": "Failed to save", + "adminSettingsSaved": "Settings saved", + "adminSettingsFailed": "Failed to save settings", + "nutritionGoalsSaved": "Nutrition goals saved", + "bio": "Bio", + "publicBio": "Public bio", + "publicBioDescription": "Shown on your public profile.", + "publicBioPlaceholder": "Tell other cooks about yourself…", + "privateBio": "AI context (private)", + "privateBioDescription": "Never shown publicly. Injected into AI prompts to personalise suggestions — add your dietary preferences, kitchen equipment, cooking skill level, allergies, etc.", + "privateBioPlaceholder": "e.g. I'm vegetarian, have a stand mixer and an air fryer, intermediate cook, allergic to tree nuts, prefer Mediterranean flavours…" } -} \ No newline at end of file +} diff --git a/apps/web/messages/fr.json b/apps/web/messages/fr.json index 608aefa..e611b1f 100644 --- a/apps/web/messages/fr.json +++ b/apps/web/messages/fr.json @@ -336,6 +336,13 @@ "changingPassword": "Modification…", "changePasswordButton": "Changer le mot de passe", "passwordChanged": "Mot de passe modifié avec succès.", - "passwordChangeFailed": "Échec de la modification du mot de passe." + "passwordChangeFailed": "Échec de la modification du mot de passe.", + "bio": "Bio", + "publicBio": "Bio publique", + "publicBioDescription": "Affichée sur votre profil public.", + "publicBioPlaceholder": "Parlez de vous aux autres cuisiniers…", + "privateBio": "Contexte IA (privé)", + "privateBioDescription": "Jamais visible publiquement. Injecté dans les prompts IA pour personnaliser les suggestions — ajoutez vos préférences alimentaires, équipements, niveau de cuisine, allergies, etc.", + "privateBioPlaceholder": "ex. Je suis végétarien, j'ai un robot pâtissier et une friteuse à air, niveau intermédiaire, allergique aux fruits à coque, je préfère les saveurs méditerranéennes…" } } \ No newline at end of file diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts index e9ffa30..008df9f 100644 --- a/apps/web/next.config.ts +++ b/apps/web/next.config.ts @@ -1,7 +1,55 @@ import type { NextConfig } from "next"; +const securityHeaders = [ + { + key: "X-Content-Type-Options", + value: "nosniff", + }, + { + key: "X-Frame-Options", + value: "DENY", + }, + { + key: "Referrer-Policy", + value: "strict-origin-when-cross-origin", + }, + { + key: "Strict-Transport-Security", + value: "max-age=63072000; includeSubDomains; preload", + }, + { + key: "Permissions-Policy", + value: "camera=(), microphone=(), geolocation=(), interest-cohort=()", + }, + { + key: "X-DNS-Prefetch-Control", + value: "on", + }, + { + key: "Content-Security-Policy", + value: [ + "default-src 'self'", + "script-src 'self' 'unsafe-inline' 'unsafe-eval'", // unsafe-eval needed by Next.js dev; tighten in prod + "style-src 'self' 'unsafe-inline'", + "img-src 'self' data: blob:", + "font-src 'self'", + "connect-src 'self'", + "frame-ancestors 'none'", + "base-uri 'self'", + "form-action 'self'", + ].join("; "), + }, +]; + const nextConfig: NextConfig = { - /* config options here */ + async headers() { + return [ + { + source: "/(.*)", + headers: securityHeaders, + }, + ]; + }, }; export default nextConfig; diff --git a/apps/web/package.json b/apps/web/package.json index 3907c28..1e61fca 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -6,7 +6,10 @@ "dev": "next dev", "build": "next build", "start": "next start", - "lint": "eslint" + "lint": "eslint", + "test": "vitest run", + "test:watch": "vitest", + "test:coverage": "vitest run --coverage" }, "dependencies": { "@ai-sdk/anthropic": "^3.0.86", @@ -42,14 +45,20 @@ }, "devDependencies": { "@tailwindcss/postcss": "^4", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", "@types/node": "^20.19.43", "@types/nodemailer": "^8.0.1", "@types/react": "^19", "@types/react-dom": "^19", "@types/web-push": "^3.6.4", + "@vitejs/plugin-react": "^6.0.3", + "@vitest/coverage-v8": "^4.1.9", "eslint": "^9", "eslint-config-next": "16.2.9", + "jsdom": "^29.1.1", "tailwindcss": "^4", - "typescript": "^5" + "typescript": "^5", + "vitest": "^4.1.9" } } diff --git a/apps/web/vitest.config.ts b/apps/web/vitest.config.ts new file mode 100644 index 0000000..92e2913 --- /dev/null +++ b/apps/web/vitest.config.ts @@ -0,0 +1,42 @@ +import { defineConfig } from "vitest/config"; +import react from "@vitejs/plugin-react"; +import path from "path"; + +export default defineConfig({ + plugins: [react()], + test: { + environment: "node", + globals: true, + setupFiles: ["./vitest.setup.ts"], + coverage: { + provider: "v8", + reporter: ["text", "lcov", "html"], + include: ["lib/**/*.ts"], + exclude: [ + "lib/auth/**", + "lib/i18n/**", + "lib/ai/features/**", + "lib/openapi.ts", + "lib/push.ts", + "lib/redis.ts", + "lib/storage.ts", + "lib/utils.ts", + "lib/email.ts", + "**/*.d.ts", + "**/__tests__/**", + ], + thresholds: { + lines: 60, + functions: 60, + branches: 55, + statements: 60, + }, + }, + }, + resolve: { + alias: { + "@": path.resolve(__dirname, "."), + "@epicure/db": path.resolve(__dirname, "../../packages/db/src/index.ts"), + }, + }, +}); diff --git a/apps/web/vitest.setup.ts b/apps/web/vitest.setup.ts new file mode 100644 index 0000000..ee91958 --- /dev/null +++ b/apps/web/vitest.setup.ts @@ -0,0 +1,7 @@ +import { vi } from "vitest"; + +// Silence Next.js server-only imports in tests +vi.mock("server-only", () => ({})); + +// Default env for encrypt +process.env["BETTER_AUTH_SECRET"] = "test-secret-for-vitest-32-bytes!!"; diff --git a/packages/db/src/migrations/0009_abnormal_lady_bullseye.sql b/packages/db/src/migrations/0009_abnormal_lady_bullseye.sql new file mode 100644 index 0000000..cb0683a --- /dev/null +++ b/packages/db/src/migrations/0009_abnormal_lady_bullseye.sql @@ -0,0 +1 @@ +ALTER TABLE "recipes" ADD COLUMN "tags" text[] DEFAULT '{}' NOT NULL; \ No newline at end of file diff --git a/packages/db/src/migrations/0010_young_loners.sql b/packages/db/src/migrations/0010_young_loners.sql new file mode 100644 index 0000000..6b6860e --- /dev/null +++ b/packages/db/src/migrations/0010_young_loners.sql @@ -0,0 +1 @@ +ALTER TABLE "recipes" ADD COLUMN "language" text; \ No newline at end of file diff --git a/packages/db/src/migrations/0011_premium_agent_zero.sql b/packages/db/src/migrations/0011_premium_agent_zero.sql new file mode 100644 index 0000000..aeffd3b --- /dev/null +++ b/packages/db/src/migrations/0011_premium_agent_zero.sql @@ -0,0 +1 @@ +ALTER TABLE "users" ADD COLUMN "private_bio" text; \ No newline at end of file diff --git a/packages/db/src/migrations/meta/0009_snapshot.json b/packages/db/src/migrations/meta/0009_snapshot.json new file mode 100644 index 0000000..2334110 --- /dev/null +++ b/packages/db/src/migrations/meta/0009_snapshot.json @@ -0,0 +1,3184 @@ +{ + "id": "8c40f072-b093-4324-ba39-21da07490857", + "prevId": "2bcb8807-3cc0-4fcb-9a0e-fe4df6eb03a0", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.accounts": { + "name": "accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "accounts_user_id_users_id_fk": { + "name": "accounts_user_id_users_id_fk", + "tableFrom": "accounts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.api_keys": { + "name": "api_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "api_keys_user_id_users_id_fk": { + "name": "api_keys_user_id_users_id_fk", + "tableFrom": "api_keys", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "api_keys_key_hash_unique": { + "name": "api_keys_key_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "key_hash" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.push_subscriptions": { + "name": "push_subscriptions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "endpoint": { + "name": "endpoint", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "p256dh": { + "name": "p256dh", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "auth": { + "name": "auth", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "push_subscriptions_user_id_users_id_fk": { + "name": "push_subscriptions_user_id_users_id_fk", + "tableFrom": "push_subscriptions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "push_subscriptions_endpoint_unique": { + "name": "push_subscriptions_endpoint_unique", + "nullsNotDistinct": false, + "columns": [ + "endpoint" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sessions_token_unique": { + "name": "sessions_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_ai_keys": { + "name": "user_ai_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_key": { + "name": "encrypted_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_ai_keys_user_id_users_id_fk": { + "name": "user_ai_keys_user_id_users_id_fk", + "tableFrom": "user_ai_keys", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_follows": { + "name": "user_follows", + "schema": "", + "columns": { + "follower_id": { + "name": "follower_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "following_id": { + "name": "following_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_follows_follower_id_users_id_fk": { + "name": "user_follows_follower_id_users_id_fk", + "tableFrom": "user_follows", + "tableTo": "users", + "columnsFrom": [ + "follower_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_follows_following_id_users_id_fk": { + "name": "user_follows_following_id_users_id_fk", + "tableFrom": "user_follows", + "tableTo": "users", + "columnsFrom": [ + "following_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_model_prefs": { + "name": "user_model_prefs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "text_provider": { + "name": "text_provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "text_model": { + "name": "text_model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "vision_provider": { + "name": "vision_provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "vision_model": { + "name": "vision_model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "meal_plan_provider": { + "name": "meal_plan_provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "meal_plan_model": { + "name": "meal_plan_model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_model_prefs_user_id_users_id_fk": { + "name": "user_model_prefs_user_id_users_id_fk", + "tableFrom": "user_model_prefs", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_model_prefs_user_id_unique": { + "name": "user_model_prefs_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_nutrition_goals": { + "name": "user_nutrition_goals", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "calories_kcal": { + "name": "calories_kcal", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "protein_g": { + "name": "protein_g", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "carbs_g": { + "name": "carbs_g", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "fat_g": { + "name": "fat_g", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_nutrition_goals_user_id_users_id_fk": { + "name": "user_nutrition_goals_user_id_users_id_fk", + "tableFrom": "user_nutrition_goals", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_nutrition_goals_user_id_unique": { + "name": "user_nutrition_goals_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "bio": { + "name": "bio", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "user_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'user'" + }, + "tier": { + "name": "tier", + "type": "tier", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'free'" + }, + "unit_pref": { + "name": "unit_pref", + "type": "unit_pref", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'metric'" + }, + "locale": { + "name": "locale", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'en'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + }, + "users_username_unique": { + "name": "users_username_unique", + "nullsNotDistinct": false, + "columns": [ + "username" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verifications": { + "name": "verifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ingredients": { + "name": "ingredients", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "aliases": { + "name": "aliases", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "known_allergens": { + "name": "known_allergens", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "ingredients_name_unique": { + "name": "ingredients_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.recipe_ingredients": { + "name": "recipe_ingredients", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "recipe_id": { + "name": "recipe_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ingredient_id": { + "name": "ingredient_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "raw_name": { + "name": "raw_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "quantity": { + "name": "quantity", + "type": "numeric(10, 4)", + "primaryKey": false, + "notNull": false + }, + "unit": { + "name": "unit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": { + "recipe_ingredients_recipe_id_recipes_id_fk": { + "name": "recipe_ingredients_recipe_id_recipes_id_fk", + "tableFrom": "recipe_ingredients", + "tableTo": "recipes", + "columnsFrom": [ + "recipe_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "recipe_ingredients_ingredient_id_ingredients_id_fk": { + "name": "recipe_ingredients_ingredient_id_ingredients_id_fk", + "tableFrom": "recipe_ingredients", + "tableTo": "ingredients", + "columnsFrom": [ + "ingredient_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.recipe_notes": { + "name": "recipe_notes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "recipe_id": { + "name": "recipe_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "recipe_notes_recipe_id_recipes_id_fk": { + "name": "recipe_notes_recipe_id_recipes_id_fk", + "tableFrom": "recipe_notes", + "tableTo": "recipes", + "columnsFrom": [ + "recipe_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "recipe_notes_user_id_users_id_fk": { + "name": "recipe_notes_user_id_users_id_fk", + "tableFrom": "recipe_notes", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.recipe_photos": { + "name": "recipe_photos", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "recipe_id": { + "name": "recipe_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_cover": { + "name": "is_cover", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "recipe_photos_recipe_id_recipes_id_fk": { + "name": "recipe_photos_recipe_id_recipes_id_fk", + "tableFrom": "recipe_photos", + "tableTo": "recipes", + "columnsFrom": [ + "recipe_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.recipe_snapshots": { + "name": "recipe_snapshots", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "recipe_id": { + "name": "recipe_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "author_id": { + "name": "author_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "snapshot_data": { + "name": "snapshot_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "recipe_snapshots_recipe_idx": { + "name": "recipe_snapshots_recipe_idx", + "columns": [ + { + "expression": "recipe_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "recipe_snapshots_recipe_id_recipes_id_fk": { + "name": "recipe_snapshots_recipe_id_recipes_id_fk", + "tableFrom": "recipe_snapshots", + "tableTo": "recipes", + "columnsFrom": [ + "recipe_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "recipe_snapshots_author_id_users_id_fk": { + "name": "recipe_snapshots_author_id_users_id_fk", + "tableFrom": "recipe_snapshots", + "tableTo": "users", + "columnsFrom": [ + "author_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.recipe_steps": { + "name": "recipe_steps", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "recipe_id": { + "name": "recipe_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "instruction": { + "name": "instruction", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timer_seconds": { + "name": "timer_seconds", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "photo_url": { + "name": "photo_url", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "recipe_steps_recipe_id_recipes_id_fk": { + "name": "recipe_steps_recipe_id_recipes_id_fk", + "tableFrom": "recipe_steps", + "tableTo": "recipes", + "columnsFrom": [ + "recipe_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.recipe_variations": { + "name": "recipe_variations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "parent_recipe_id": { + "name": "parent_recipe_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_recipe_id": { + "name": "child_recipe_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ai_generated": { + "name": "ai_generated", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "recipe_variations_parent_recipe_id_recipes_id_fk": { + "name": "recipe_variations_parent_recipe_id_recipes_id_fk", + "tableFrom": "recipe_variations", + "tableTo": "recipes", + "columnsFrom": [ + "parent_recipe_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "recipe_variations_child_recipe_id_recipes_id_fk": { + "name": "recipe_variations_child_recipe_id_recipes_id_fk", + "tableFrom": "recipe_variations", + "tableTo": "recipes", + "columnsFrom": [ + "child_recipe_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.recipes": { + "name": "recipes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "author_id": { + "name": "author_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "base_servings": { + "name": "base_servings", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 4 + }, + "visibility": { + "name": "visibility", + "type": "visibility", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'private'" + }, + "source_url": { + "name": "source_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ai_generated": { + "name": "ai_generated", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "ai_model": { + "name": "ai_model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ai_prompt": { + "name": "ai_prompt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "dietary_tags": { + "name": "dietary_tags", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "dietary_verified": { + "name": "dietary_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "nutrition_data": { + "name": "nutrition_data", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "difficulty": { + "name": "difficulty", + "type": "difficulty", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "prep_mins": { + "name": "prep_mins", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "cook_mins": { + "name": "cook_mins", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "tags": { + "name": "tags", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "recipes_author_idx": { + "name": "recipes_author_idx", + "columns": [ + { + "expression": "author_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "recipes_visibility_idx": { + "name": "recipes_visibility_idx", + "columns": [ + { + "expression": "visibility", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "recipes_author_id_users_id_fk": { + "name": "recipes_author_id_users_id_fk", + "tableFrom": "recipes", + "tableTo": "users", + "columnsFrom": [ + "author_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_allergens": { + "name": "user_allergens", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "allergen_tag": { + "name": "allergen_tag", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "user_allergens_user_id_users_id_fk": { + "name": "user_allergens_user_id_users_id_fk", + "tableFrom": "user_allergens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.collection_members": { + "name": "collection_members", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "collection_member_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'viewer'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "collection_members_collection_id_collections_id_fk": { + "name": "collection_members_collection_id_collections_id_fk", + "tableFrom": "collection_members", + "tableTo": "collections", + "columnsFrom": [ + "collection_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "collection_members_user_id_users_id_fk": { + "name": "collection_members_user_id_users_id_fk", + "tableFrom": "collection_members", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.collection_recipes": { + "name": "collection_recipes", + "schema": "", + "columns": { + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "recipe_id": { + "name": "recipe_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "added_at": { + "name": "added_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "collection_recipes_collection_id_collections_id_fk": { + "name": "collection_recipes_collection_id_collections_id_fk", + "tableFrom": "collection_recipes", + "tableTo": "collections", + "columnsFrom": [ + "collection_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "collection_recipes_recipe_id_recipes_id_fk": { + "name": "collection_recipes_recipe_id_recipes_id_fk", + "tableFrom": "collection_recipes", + "tableTo": "recipes", + "columnsFrom": [ + "recipe_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.collections": { + "name": "collections", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_public": { + "name": "is_public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "collections_user_id_users_id_fk": { + "name": "collections_user_id_users_id_fk", + "tableFrom": "collections", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.comment_reactions": { + "name": "comment_reactions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "comment_id": { + "name": "comment_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "comment_reaction_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "comment_reactions_comment_idx": { + "name": "comment_reactions_comment_idx", + "columns": [ + { + "expression": "comment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "comment_reactions_comment_id_comments_id_fk": { + "name": "comment_reactions_comment_id_comments_id_fk", + "tableFrom": "comment_reactions", + "tableTo": "comments", + "columnsFrom": [ + "comment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "comment_reactions_user_id_users_id_fk": { + "name": "comment_reactions_user_id_users_id_fk", + "tableFrom": "comment_reactions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.comments": { + "name": "comments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "recipe_id": { + "name": "recipe_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "comments_recipe_idx": { + "name": "comments_recipe_idx", + "columns": [ + { + "expression": "recipe_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "comments_recipe_id_recipes_id_fk": { + "name": "comments_recipe_id_recipes_id_fk", + "tableFrom": "comments", + "tableTo": "recipes", + "columnsFrom": [ + "recipe_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "comments_user_id_users_id_fk": { + "name": "comments_user_id_users_id_fk", + "tableFrom": "comments", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.cooking_history": { + "name": "cooking_history", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "recipe_id": { + "name": "recipe_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cooked_at": { + "name": "cooked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "servings": { + "name": "servings", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "cooking_history_user_id_users_id_fk": { + "name": "cooking_history_user_id_users_id_fk", + "tableFrom": "cooking_history", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "cooking_history_recipe_id_recipes_id_fk": { + "name": "cooking_history_recipe_id_recipes_id_fk", + "tableFrom": "cooking_history", + "tableTo": "recipes", + "columnsFrom": [ + "recipe_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.favorites": { + "name": "favorites", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "recipe_id": { + "name": "recipe_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "favorites_user_id_users_id_fk": { + "name": "favorites_user_id_users_id_fk", + "tableFrom": "favorites", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "favorites_recipe_id_recipes_id_fk": { + "name": "favorites_recipe_id_recipes_id_fk", + "tableFrom": "favorites", + "tableTo": "recipes", + "columnsFrom": [ + "recipe_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.feed_items": { + "name": "feed_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "feed_item_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subject_id": { + "name": "subject_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "feed_items_user_idx": { + "name": "feed_items_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "feed_items_user_id_users_id_fk": { + "name": "feed_items_user_id_users_id_fk", + "tableFrom": "feed_items", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "feed_items_actor_id_users_id_fk": { + "name": "feed_items_actor_id_users_id_fk", + "tableFrom": "feed_items", + "tableTo": "users", + "columnsFrom": [ + "actor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ratings": { + "name": "ratings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "recipe_id": { + "name": "recipe_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "score": { + "name": "score", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "review_text": { + "name": "review_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "ratings_recipe_id_recipes_id_fk": { + "name": "ratings_recipe_id_recipes_id_fk", + "tableFrom": "ratings", + "tableTo": "recipes", + "columnsFrom": [ + "recipe_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ratings_user_id_users_id_fk": { + "name": "ratings_user_id_users_id_fk", + "tableFrom": "ratings", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.meal_plan_entries": { + "name": "meal_plan_entries", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "meal_plan_id": { + "name": "meal_plan_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "day": { + "name": "day", + "type": "weekday", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "meal_type": { + "name": "meal_type", + "type": "meal_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "recipe_id": { + "name": "recipe_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "servings": { + "name": "servings", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 2 + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "meal_plan_entries_meal_plan_id_meal_plans_id_fk": { + "name": "meal_plan_entries_meal_plan_id_meal_plans_id_fk", + "tableFrom": "meal_plan_entries", + "tableTo": "meal_plans", + "columnsFrom": [ + "meal_plan_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "meal_plan_entries_recipe_id_recipes_id_fk": { + "name": "meal_plan_entries_recipe_id_recipes_id_fk", + "tableFrom": "meal_plan_entries", + "tableTo": "recipes", + "columnsFrom": [ + "recipe_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.meal_plans": { + "name": "meal_plans", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "week_start": { + "name": "week_start", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "meal_plans_user_id_users_id_fk": { + "name": "meal_plans_user_id_users_id_fk", + "tableFrom": "meal_plans", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pantry_items": { + "name": "pantry_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ingredient_id": { + "name": "ingredient_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "raw_name": { + "name": "raw_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "quantity": { + "name": "quantity", + "type": "numeric(10, 4)", + "primaryKey": false, + "notNull": false + }, + "unit": { + "name": "unit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "pantry_items_user_id_users_id_fk": { + "name": "pantry_items_user_id_users_id_fk", + "tableFrom": "pantry_items", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pantry_items_ingredient_id_ingredients_id_fk": { + "name": "pantry_items_ingredient_id_ingredients_id_fk", + "tableFrom": "pantry_items", + "tableTo": "ingredients", + "columnsFrom": [ + "ingredient_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.shopping_list_items": { + "name": "shopping_list_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "list_id": { + "name": "list_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ingredient_id": { + "name": "ingredient_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "raw_name": { + "name": "raw_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "quantity": { + "name": "quantity", + "type": "numeric(10, 4)", + "primaryKey": false, + "notNull": false + }, + "unit": { + "name": "unit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "aisle": { + "name": "aisle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "checked": { + "name": "checked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": { + "shopping_list_items_list_id_shopping_lists_id_fk": { + "name": "shopping_list_items_list_id_shopping_lists_id_fk", + "tableFrom": "shopping_list_items", + "tableTo": "shopping_lists", + "columnsFrom": [ + "list_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shopping_list_items_ingredient_id_ingredients_id_fk": { + "name": "shopping_list_items_ingredient_id_ingredients_id_fk", + "tableFrom": "shopping_list_items", + "tableTo": "ingredients", + "columnsFrom": [ + "ingredient_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.shopping_lists": { + "name": "shopping_lists", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "generated_at": { + "name": "generated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "shopping_lists_user_id_users_id_fk": { + "name": "shopping_lists_user_id_users_id_fk", + "tableFrom": "shopping_lists", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_logs": { + "name": "audit_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_logs_created_idx": { + "name": "audit_logs_created_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audit_logs_user_id_users_id_fk": { + "name": "audit_logs_user_id_users_id_fk", + "tableFrom": "audit_logs", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.site_settings": { + "name": "site_settings", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_secret": { + "name": "is_secret", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_by_id": { + "name": "updated_by_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "site_settings_updated_by_id_users_id_fk": { + "name": "site_settings_updated_by_id_users_id_fk", + "tableFrom": "site_settings", + "tableTo": "users", + "columnsFrom": [ + "updated_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tier_definitions": { + "name": "tier_definitions", + "schema": "", + "columns": { + "tier": { + "name": "tier", + "type": "tier", + "typeSchema": "public", + "primaryKey": true, + "notNull": true + }, + "max_recipes": { + "name": "max_recipes", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "ai_calls_per_month": { + "name": "ai_calls_per_month", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "storage_mb": { + "name": "storage_mb", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "max_public_recipes": { + "name": "max_public_recipes", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_usage": { + "name": "user_usage", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "month": { + "name": "month", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ai_calls_used": { + "name": "ai_calls_used", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "recipe_count": { + "name": "recipe_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "storage_used_mb": { + "name": "storage_used_mb", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": { + "user_usage_user_month_idx": { + "name": "user_usage_user_month_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "month", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_usage_user_id_users_id_fk": { + "name": "user_usage_user_id_users_id_fk", + "tableFrom": "user_usage", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_usage_user_month_uniq": { + "name": "user_usage_user_month_uniq", + "nullsNotDistinct": false, + "columns": [ + "user_id", + "month" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.webhook_deliveries": { + "name": "webhook_deliveries", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "webhook_id": { + "name": "webhook_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event": { + "name": "event", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status_code": { + "name": "status_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "success": { + "name": "success", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "webhook_deliveries_webhook_id_webhooks_id_fk": { + "name": "webhook_deliveries_webhook_id_webhooks_id_fk", + "tableFrom": "webhook_deliveries", + "tableTo": "webhooks", + "columnsFrom": [ + "webhook_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.webhooks": { + "name": "webhooks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "events": { + "name": "events", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "secret": { + "name": "secret", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "webhooks_user_id_users_id_fk": { + "name": "webhooks_user_id_users_id_fk", + "tableFrom": "webhooks", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.tier": { + "name": "tier", + "schema": "public", + "values": [ + "free", + "pro" + ] + }, + "public.unit_pref": { + "name": "unit_pref", + "schema": "public", + "values": [ + "metric", + "imperial" + ] + }, + "public.user_role": { + "name": "user_role", + "schema": "public", + "values": [ + "user", + "moderator", + "admin" + ] + }, + "public.difficulty": { + "name": "difficulty", + "schema": "public", + "values": [ + "easy", + "medium", + "hard" + ] + }, + "public.visibility": { + "name": "visibility", + "schema": "public", + "values": [ + "private", + "unlisted", + "public" + ] + }, + "public.collection_member_role": { + "name": "collection_member_role", + "schema": "public", + "values": [ + "viewer", + "editor" + ] + }, + "public.comment_reaction_type": { + "name": "comment_reaction_type", + "schema": "public", + "values": [ + "like", + "love", + "laugh", + "wow", + "sad", + "fire" + ] + }, + "public.feed_item_type": { + "name": "feed_item_type", + "schema": "public", + "values": [ + "new_recipe", + "new_follow", + "recipe_rated" + ] + }, + "public.meal_type": { + "name": "meal_type", + "schema": "public", + "values": [ + "breakfast", + "lunch", + "dinner", + "snack" + ] + }, + "public.weekday": { + "name": "weekday", + "schema": "public", + "values": [ + "mon", + "tue", + "wed", + "thu", + "fri", + "sat", + "sun" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/db/src/migrations/meta/0010_snapshot.json b/packages/db/src/migrations/meta/0010_snapshot.json new file mode 100644 index 0000000..9b5aa1e --- /dev/null +++ b/packages/db/src/migrations/meta/0010_snapshot.json @@ -0,0 +1,3190 @@ +{ + "id": "ea50008a-55d4-461d-a170-ee1ab60fa013", + "prevId": "8c40f072-b093-4324-ba39-21da07490857", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.accounts": { + "name": "accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "accounts_user_id_users_id_fk": { + "name": "accounts_user_id_users_id_fk", + "tableFrom": "accounts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.api_keys": { + "name": "api_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "api_keys_user_id_users_id_fk": { + "name": "api_keys_user_id_users_id_fk", + "tableFrom": "api_keys", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "api_keys_key_hash_unique": { + "name": "api_keys_key_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "key_hash" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.push_subscriptions": { + "name": "push_subscriptions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "endpoint": { + "name": "endpoint", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "p256dh": { + "name": "p256dh", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "auth": { + "name": "auth", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "push_subscriptions_user_id_users_id_fk": { + "name": "push_subscriptions_user_id_users_id_fk", + "tableFrom": "push_subscriptions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "push_subscriptions_endpoint_unique": { + "name": "push_subscriptions_endpoint_unique", + "nullsNotDistinct": false, + "columns": [ + "endpoint" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sessions_token_unique": { + "name": "sessions_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_ai_keys": { + "name": "user_ai_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_key": { + "name": "encrypted_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_ai_keys_user_id_users_id_fk": { + "name": "user_ai_keys_user_id_users_id_fk", + "tableFrom": "user_ai_keys", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_follows": { + "name": "user_follows", + "schema": "", + "columns": { + "follower_id": { + "name": "follower_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "following_id": { + "name": "following_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_follows_follower_id_users_id_fk": { + "name": "user_follows_follower_id_users_id_fk", + "tableFrom": "user_follows", + "tableTo": "users", + "columnsFrom": [ + "follower_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_follows_following_id_users_id_fk": { + "name": "user_follows_following_id_users_id_fk", + "tableFrom": "user_follows", + "tableTo": "users", + "columnsFrom": [ + "following_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_model_prefs": { + "name": "user_model_prefs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "text_provider": { + "name": "text_provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "text_model": { + "name": "text_model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "vision_provider": { + "name": "vision_provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "vision_model": { + "name": "vision_model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "meal_plan_provider": { + "name": "meal_plan_provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "meal_plan_model": { + "name": "meal_plan_model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_model_prefs_user_id_users_id_fk": { + "name": "user_model_prefs_user_id_users_id_fk", + "tableFrom": "user_model_prefs", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_model_prefs_user_id_unique": { + "name": "user_model_prefs_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_nutrition_goals": { + "name": "user_nutrition_goals", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "calories_kcal": { + "name": "calories_kcal", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "protein_g": { + "name": "protein_g", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "carbs_g": { + "name": "carbs_g", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "fat_g": { + "name": "fat_g", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_nutrition_goals_user_id_users_id_fk": { + "name": "user_nutrition_goals_user_id_users_id_fk", + "tableFrom": "user_nutrition_goals", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_nutrition_goals_user_id_unique": { + "name": "user_nutrition_goals_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "bio": { + "name": "bio", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "user_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'user'" + }, + "tier": { + "name": "tier", + "type": "tier", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'free'" + }, + "unit_pref": { + "name": "unit_pref", + "type": "unit_pref", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'metric'" + }, + "locale": { + "name": "locale", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'en'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + }, + "users_username_unique": { + "name": "users_username_unique", + "nullsNotDistinct": false, + "columns": [ + "username" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verifications": { + "name": "verifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ingredients": { + "name": "ingredients", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "aliases": { + "name": "aliases", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "known_allergens": { + "name": "known_allergens", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "ingredients_name_unique": { + "name": "ingredients_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.recipe_ingredients": { + "name": "recipe_ingredients", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "recipe_id": { + "name": "recipe_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ingredient_id": { + "name": "ingredient_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "raw_name": { + "name": "raw_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "quantity": { + "name": "quantity", + "type": "numeric(10, 4)", + "primaryKey": false, + "notNull": false + }, + "unit": { + "name": "unit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": { + "recipe_ingredients_recipe_id_recipes_id_fk": { + "name": "recipe_ingredients_recipe_id_recipes_id_fk", + "tableFrom": "recipe_ingredients", + "tableTo": "recipes", + "columnsFrom": [ + "recipe_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "recipe_ingredients_ingredient_id_ingredients_id_fk": { + "name": "recipe_ingredients_ingredient_id_ingredients_id_fk", + "tableFrom": "recipe_ingredients", + "tableTo": "ingredients", + "columnsFrom": [ + "ingredient_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.recipe_notes": { + "name": "recipe_notes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "recipe_id": { + "name": "recipe_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "recipe_notes_recipe_id_recipes_id_fk": { + "name": "recipe_notes_recipe_id_recipes_id_fk", + "tableFrom": "recipe_notes", + "tableTo": "recipes", + "columnsFrom": [ + "recipe_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "recipe_notes_user_id_users_id_fk": { + "name": "recipe_notes_user_id_users_id_fk", + "tableFrom": "recipe_notes", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.recipe_photos": { + "name": "recipe_photos", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "recipe_id": { + "name": "recipe_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_cover": { + "name": "is_cover", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "recipe_photos_recipe_id_recipes_id_fk": { + "name": "recipe_photos_recipe_id_recipes_id_fk", + "tableFrom": "recipe_photos", + "tableTo": "recipes", + "columnsFrom": [ + "recipe_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.recipe_snapshots": { + "name": "recipe_snapshots", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "recipe_id": { + "name": "recipe_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "author_id": { + "name": "author_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "snapshot_data": { + "name": "snapshot_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "recipe_snapshots_recipe_idx": { + "name": "recipe_snapshots_recipe_idx", + "columns": [ + { + "expression": "recipe_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "recipe_snapshots_recipe_id_recipes_id_fk": { + "name": "recipe_snapshots_recipe_id_recipes_id_fk", + "tableFrom": "recipe_snapshots", + "tableTo": "recipes", + "columnsFrom": [ + "recipe_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "recipe_snapshots_author_id_users_id_fk": { + "name": "recipe_snapshots_author_id_users_id_fk", + "tableFrom": "recipe_snapshots", + "tableTo": "users", + "columnsFrom": [ + "author_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.recipe_steps": { + "name": "recipe_steps", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "recipe_id": { + "name": "recipe_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "instruction": { + "name": "instruction", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timer_seconds": { + "name": "timer_seconds", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "photo_url": { + "name": "photo_url", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "recipe_steps_recipe_id_recipes_id_fk": { + "name": "recipe_steps_recipe_id_recipes_id_fk", + "tableFrom": "recipe_steps", + "tableTo": "recipes", + "columnsFrom": [ + "recipe_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.recipe_variations": { + "name": "recipe_variations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "parent_recipe_id": { + "name": "parent_recipe_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_recipe_id": { + "name": "child_recipe_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ai_generated": { + "name": "ai_generated", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "recipe_variations_parent_recipe_id_recipes_id_fk": { + "name": "recipe_variations_parent_recipe_id_recipes_id_fk", + "tableFrom": "recipe_variations", + "tableTo": "recipes", + "columnsFrom": [ + "parent_recipe_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "recipe_variations_child_recipe_id_recipes_id_fk": { + "name": "recipe_variations_child_recipe_id_recipes_id_fk", + "tableFrom": "recipe_variations", + "tableTo": "recipes", + "columnsFrom": [ + "child_recipe_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.recipes": { + "name": "recipes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "author_id": { + "name": "author_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "base_servings": { + "name": "base_servings", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 4 + }, + "visibility": { + "name": "visibility", + "type": "visibility", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'private'" + }, + "source_url": { + "name": "source_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ai_generated": { + "name": "ai_generated", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "ai_model": { + "name": "ai_model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ai_prompt": { + "name": "ai_prompt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "language": { + "name": "language", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "dietary_tags": { + "name": "dietary_tags", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "dietary_verified": { + "name": "dietary_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "nutrition_data": { + "name": "nutrition_data", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "difficulty": { + "name": "difficulty", + "type": "difficulty", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "prep_mins": { + "name": "prep_mins", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "cook_mins": { + "name": "cook_mins", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "tags": { + "name": "tags", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "recipes_author_idx": { + "name": "recipes_author_idx", + "columns": [ + { + "expression": "author_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "recipes_visibility_idx": { + "name": "recipes_visibility_idx", + "columns": [ + { + "expression": "visibility", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "recipes_author_id_users_id_fk": { + "name": "recipes_author_id_users_id_fk", + "tableFrom": "recipes", + "tableTo": "users", + "columnsFrom": [ + "author_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_allergens": { + "name": "user_allergens", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "allergen_tag": { + "name": "allergen_tag", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "user_allergens_user_id_users_id_fk": { + "name": "user_allergens_user_id_users_id_fk", + "tableFrom": "user_allergens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.collection_members": { + "name": "collection_members", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "collection_member_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'viewer'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "collection_members_collection_id_collections_id_fk": { + "name": "collection_members_collection_id_collections_id_fk", + "tableFrom": "collection_members", + "tableTo": "collections", + "columnsFrom": [ + "collection_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "collection_members_user_id_users_id_fk": { + "name": "collection_members_user_id_users_id_fk", + "tableFrom": "collection_members", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.collection_recipes": { + "name": "collection_recipes", + "schema": "", + "columns": { + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "recipe_id": { + "name": "recipe_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "added_at": { + "name": "added_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "collection_recipes_collection_id_collections_id_fk": { + "name": "collection_recipes_collection_id_collections_id_fk", + "tableFrom": "collection_recipes", + "tableTo": "collections", + "columnsFrom": [ + "collection_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "collection_recipes_recipe_id_recipes_id_fk": { + "name": "collection_recipes_recipe_id_recipes_id_fk", + "tableFrom": "collection_recipes", + "tableTo": "recipes", + "columnsFrom": [ + "recipe_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.collections": { + "name": "collections", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_public": { + "name": "is_public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "collections_user_id_users_id_fk": { + "name": "collections_user_id_users_id_fk", + "tableFrom": "collections", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.comment_reactions": { + "name": "comment_reactions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "comment_id": { + "name": "comment_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "comment_reaction_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "comment_reactions_comment_idx": { + "name": "comment_reactions_comment_idx", + "columns": [ + { + "expression": "comment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "comment_reactions_comment_id_comments_id_fk": { + "name": "comment_reactions_comment_id_comments_id_fk", + "tableFrom": "comment_reactions", + "tableTo": "comments", + "columnsFrom": [ + "comment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "comment_reactions_user_id_users_id_fk": { + "name": "comment_reactions_user_id_users_id_fk", + "tableFrom": "comment_reactions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.comments": { + "name": "comments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "recipe_id": { + "name": "recipe_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "comments_recipe_idx": { + "name": "comments_recipe_idx", + "columns": [ + { + "expression": "recipe_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "comments_recipe_id_recipes_id_fk": { + "name": "comments_recipe_id_recipes_id_fk", + "tableFrom": "comments", + "tableTo": "recipes", + "columnsFrom": [ + "recipe_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "comments_user_id_users_id_fk": { + "name": "comments_user_id_users_id_fk", + "tableFrom": "comments", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.cooking_history": { + "name": "cooking_history", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "recipe_id": { + "name": "recipe_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cooked_at": { + "name": "cooked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "servings": { + "name": "servings", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "cooking_history_user_id_users_id_fk": { + "name": "cooking_history_user_id_users_id_fk", + "tableFrom": "cooking_history", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "cooking_history_recipe_id_recipes_id_fk": { + "name": "cooking_history_recipe_id_recipes_id_fk", + "tableFrom": "cooking_history", + "tableTo": "recipes", + "columnsFrom": [ + "recipe_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.favorites": { + "name": "favorites", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "recipe_id": { + "name": "recipe_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "favorites_user_id_users_id_fk": { + "name": "favorites_user_id_users_id_fk", + "tableFrom": "favorites", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "favorites_recipe_id_recipes_id_fk": { + "name": "favorites_recipe_id_recipes_id_fk", + "tableFrom": "favorites", + "tableTo": "recipes", + "columnsFrom": [ + "recipe_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.feed_items": { + "name": "feed_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "feed_item_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subject_id": { + "name": "subject_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "feed_items_user_idx": { + "name": "feed_items_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "feed_items_user_id_users_id_fk": { + "name": "feed_items_user_id_users_id_fk", + "tableFrom": "feed_items", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "feed_items_actor_id_users_id_fk": { + "name": "feed_items_actor_id_users_id_fk", + "tableFrom": "feed_items", + "tableTo": "users", + "columnsFrom": [ + "actor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ratings": { + "name": "ratings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "recipe_id": { + "name": "recipe_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "score": { + "name": "score", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "review_text": { + "name": "review_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "ratings_recipe_id_recipes_id_fk": { + "name": "ratings_recipe_id_recipes_id_fk", + "tableFrom": "ratings", + "tableTo": "recipes", + "columnsFrom": [ + "recipe_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ratings_user_id_users_id_fk": { + "name": "ratings_user_id_users_id_fk", + "tableFrom": "ratings", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.meal_plan_entries": { + "name": "meal_plan_entries", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "meal_plan_id": { + "name": "meal_plan_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "day": { + "name": "day", + "type": "weekday", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "meal_type": { + "name": "meal_type", + "type": "meal_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "recipe_id": { + "name": "recipe_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "servings": { + "name": "servings", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 2 + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "meal_plan_entries_meal_plan_id_meal_plans_id_fk": { + "name": "meal_plan_entries_meal_plan_id_meal_plans_id_fk", + "tableFrom": "meal_plan_entries", + "tableTo": "meal_plans", + "columnsFrom": [ + "meal_plan_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "meal_plan_entries_recipe_id_recipes_id_fk": { + "name": "meal_plan_entries_recipe_id_recipes_id_fk", + "tableFrom": "meal_plan_entries", + "tableTo": "recipes", + "columnsFrom": [ + "recipe_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.meal_plans": { + "name": "meal_plans", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "week_start": { + "name": "week_start", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "meal_plans_user_id_users_id_fk": { + "name": "meal_plans_user_id_users_id_fk", + "tableFrom": "meal_plans", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pantry_items": { + "name": "pantry_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ingredient_id": { + "name": "ingredient_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "raw_name": { + "name": "raw_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "quantity": { + "name": "quantity", + "type": "numeric(10, 4)", + "primaryKey": false, + "notNull": false + }, + "unit": { + "name": "unit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "pantry_items_user_id_users_id_fk": { + "name": "pantry_items_user_id_users_id_fk", + "tableFrom": "pantry_items", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pantry_items_ingredient_id_ingredients_id_fk": { + "name": "pantry_items_ingredient_id_ingredients_id_fk", + "tableFrom": "pantry_items", + "tableTo": "ingredients", + "columnsFrom": [ + "ingredient_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.shopping_list_items": { + "name": "shopping_list_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "list_id": { + "name": "list_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ingredient_id": { + "name": "ingredient_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "raw_name": { + "name": "raw_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "quantity": { + "name": "quantity", + "type": "numeric(10, 4)", + "primaryKey": false, + "notNull": false + }, + "unit": { + "name": "unit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "aisle": { + "name": "aisle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "checked": { + "name": "checked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": { + "shopping_list_items_list_id_shopping_lists_id_fk": { + "name": "shopping_list_items_list_id_shopping_lists_id_fk", + "tableFrom": "shopping_list_items", + "tableTo": "shopping_lists", + "columnsFrom": [ + "list_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shopping_list_items_ingredient_id_ingredients_id_fk": { + "name": "shopping_list_items_ingredient_id_ingredients_id_fk", + "tableFrom": "shopping_list_items", + "tableTo": "ingredients", + "columnsFrom": [ + "ingredient_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.shopping_lists": { + "name": "shopping_lists", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "generated_at": { + "name": "generated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "shopping_lists_user_id_users_id_fk": { + "name": "shopping_lists_user_id_users_id_fk", + "tableFrom": "shopping_lists", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_logs": { + "name": "audit_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_logs_created_idx": { + "name": "audit_logs_created_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audit_logs_user_id_users_id_fk": { + "name": "audit_logs_user_id_users_id_fk", + "tableFrom": "audit_logs", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.site_settings": { + "name": "site_settings", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_secret": { + "name": "is_secret", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_by_id": { + "name": "updated_by_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "site_settings_updated_by_id_users_id_fk": { + "name": "site_settings_updated_by_id_users_id_fk", + "tableFrom": "site_settings", + "tableTo": "users", + "columnsFrom": [ + "updated_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tier_definitions": { + "name": "tier_definitions", + "schema": "", + "columns": { + "tier": { + "name": "tier", + "type": "tier", + "typeSchema": "public", + "primaryKey": true, + "notNull": true + }, + "max_recipes": { + "name": "max_recipes", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "ai_calls_per_month": { + "name": "ai_calls_per_month", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "storage_mb": { + "name": "storage_mb", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "max_public_recipes": { + "name": "max_public_recipes", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_usage": { + "name": "user_usage", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "month": { + "name": "month", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ai_calls_used": { + "name": "ai_calls_used", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "recipe_count": { + "name": "recipe_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "storage_used_mb": { + "name": "storage_used_mb", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": { + "user_usage_user_month_idx": { + "name": "user_usage_user_month_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "month", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_usage_user_id_users_id_fk": { + "name": "user_usage_user_id_users_id_fk", + "tableFrom": "user_usage", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_usage_user_month_uniq": { + "name": "user_usage_user_month_uniq", + "nullsNotDistinct": false, + "columns": [ + "user_id", + "month" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.webhook_deliveries": { + "name": "webhook_deliveries", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "webhook_id": { + "name": "webhook_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event": { + "name": "event", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status_code": { + "name": "status_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "success": { + "name": "success", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "webhook_deliveries_webhook_id_webhooks_id_fk": { + "name": "webhook_deliveries_webhook_id_webhooks_id_fk", + "tableFrom": "webhook_deliveries", + "tableTo": "webhooks", + "columnsFrom": [ + "webhook_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.webhooks": { + "name": "webhooks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "events": { + "name": "events", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "secret": { + "name": "secret", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "webhooks_user_id_users_id_fk": { + "name": "webhooks_user_id_users_id_fk", + "tableFrom": "webhooks", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.tier": { + "name": "tier", + "schema": "public", + "values": [ + "free", + "pro" + ] + }, + "public.unit_pref": { + "name": "unit_pref", + "schema": "public", + "values": [ + "metric", + "imperial" + ] + }, + "public.user_role": { + "name": "user_role", + "schema": "public", + "values": [ + "user", + "moderator", + "admin" + ] + }, + "public.difficulty": { + "name": "difficulty", + "schema": "public", + "values": [ + "easy", + "medium", + "hard" + ] + }, + "public.visibility": { + "name": "visibility", + "schema": "public", + "values": [ + "private", + "unlisted", + "public" + ] + }, + "public.collection_member_role": { + "name": "collection_member_role", + "schema": "public", + "values": [ + "viewer", + "editor" + ] + }, + "public.comment_reaction_type": { + "name": "comment_reaction_type", + "schema": "public", + "values": [ + "like", + "love", + "laugh", + "wow", + "sad", + "fire" + ] + }, + "public.feed_item_type": { + "name": "feed_item_type", + "schema": "public", + "values": [ + "new_recipe", + "new_follow", + "recipe_rated" + ] + }, + "public.meal_type": { + "name": "meal_type", + "schema": "public", + "values": [ + "breakfast", + "lunch", + "dinner", + "snack" + ] + }, + "public.weekday": { + "name": "weekday", + "schema": "public", + "values": [ + "mon", + "tue", + "wed", + "thu", + "fri", + "sat", + "sun" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/db/src/migrations/meta/0011_snapshot.json b/packages/db/src/migrations/meta/0011_snapshot.json new file mode 100644 index 0000000..57099ff --- /dev/null +++ b/packages/db/src/migrations/meta/0011_snapshot.json @@ -0,0 +1,3196 @@ +{ + "id": "718bc694-727f-4942-80b6-fe6db104162b", + "prevId": "ea50008a-55d4-461d-a170-ee1ab60fa013", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.accounts": { + "name": "accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "accounts_user_id_users_id_fk": { + "name": "accounts_user_id_users_id_fk", + "tableFrom": "accounts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.api_keys": { + "name": "api_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "api_keys_user_id_users_id_fk": { + "name": "api_keys_user_id_users_id_fk", + "tableFrom": "api_keys", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "api_keys_key_hash_unique": { + "name": "api_keys_key_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "key_hash" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.push_subscriptions": { + "name": "push_subscriptions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "endpoint": { + "name": "endpoint", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "p256dh": { + "name": "p256dh", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "auth": { + "name": "auth", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "push_subscriptions_user_id_users_id_fk": { + "name": "push_subscriptions_user_id_users_id_fk", + "tableFrom": "push_subscriptions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "push_subscriptions_endpoint_unique": { + "name": "push_subscriptions_endpoint_unique", + "nullsNotDistinct": false, + "columns": [ + "endpoint" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sessions_token_unique": { + "name": "sessions_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_ai_keys": { + "name": "user_ai_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_key": { + "name": "encrypted_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_ai_keys_user_id_users_id_fk": { + "name": "user_ai_keys_user_id_users_id_fk", + "tableFrom": "user_ai_keys", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_follows": { + "name": "user_follows", + "schema": "", + "columns": { + "follower_id": { + "name": "follower_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "following_id": { + "name": "following_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_follows_follower_id_users_id_fk": { + "name": "user_follows_follower_id_users_id_fk", + "tableFrom": "user_follows", + "tableTo": "users", + "columnsFrom": [ + "follower_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_follows_following_id_users_id_fk": { + "name": "user_follows_following_id_users_id_fk", + "tableFrom": "user_follows", + "tableTo": "users", + "columnsFrom": [ + "following_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_model_prefs": { + "name": "user_model_prefs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "text_provider": { + "name": "text_provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "text_model": { + "name": "text_model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "vision_provider": { + "name": "vision_provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "vision_model": { + "name": "vision_model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "meal_plan_provider": { + "name": "meal_plan_provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "meal_plan_model": { + "name": "meal_plan_model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_model_prefs_user_id_users_id_fk": { + "name": "user_model_prefs_user_id_users_id_fk", + "tableFrom": "user_model_prefs", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_model_prefs_user_id_unique": { + "name": "user_model_prefs_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_nutrition_goals": { + "name": "user_nutrition_goals", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "calories_kcal": { + "name": "calories_kcal", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "protein_g": { + "name": "protein_g", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "carbs_g": { + "name": "carbs_g", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "fat_g": { + "name": "fat_g", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_nutrition_goals_user_id_users_id_fk": { + "name": "user_nutrition_goals_user_id_users_id_fk", + "tableFrom": "user_nutrition_goals", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_nutrition_goals_user_id_unique": { + "name": "user_nutrition_goals_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "bio": { + "name": "bio", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "private_bio": { + "name": "private_bio", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "user_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'user'" + }, + "tier": { + "name": "tier", + "type": "tier", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'free'" + }, + "unit_pref": { + "name": "unit_pref", + "type": "unit_pref", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'metric'" + }, + "locale": { + "name": "locale", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'en'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + }, + "users_username_unique": { + "name": "users_username_unique", + "nullsNotDistinct": false, + "columns": [ + "username" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verifications": { + "name": "verifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ingredients": { + "name": "ingredients", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "aliases": { + "name": "aliases", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "known_allergens": { + "name": "known_allergens", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "ingredients_name_unique": { + "name": "ingredients_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.recipe_ingredients": { + "name": "recipe_ingredients", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "recipe_id": { + "name": "recipe_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ingredient_id": { + "name": "ingredient_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "raw_name": { + "name": "raw_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "quantity": { + "name": "quantity", + "type": "numeric(10, 4)", + "primaryKey": false, + "notNull": false + }, + "unit": { + "name": "unit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": { + "recipe_ingredients_recipe_id_recipes_id_fk": { + "name": "recipe_ingredients_recipe_id_recipes_id_fk", + "tableFrom": "recipe_ingredients", + "tableTo": "recipes", + "columnsFrom": [ + "recipe_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "recipe_ingredients_ingredient_id_ingredients_id_fk": { + "name": "recipe_ingredients_ingredient_id_ingredients_id_fk", + "tableFrom": "recipe_ingredients", + "tableTo": "ingredients", + "columnsFrom": [ + "ingredient_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.recipe_notes": { + "name": "recipe_notes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "recipe_id": { + "name": "recipe_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "recipe_notes_recipe_id_recipes_id_fk": { + "name": "recipe_notes_recipe_id_recipes_id_fk", + "tableFrom": "recipe_notes", + "tableTo": "recipes", + "columnsFrom": [ + "recipe_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "recipe_notes_user_id_users_id_fk": { + "name": "recipe_notes_user_id_users_id_fk", + "tableFrom": "recipe_notes", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.recipe_photos": { + "name": "recipe_photos", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "recipe_id": { + "name": "recipe_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_cover": { + "name": "is_cover", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "recipe_photos_recipe_id_recipes_id_fk": { + "name": "recipe_photos_recipe_id_recipes_id_fk", + "tableFrom": "recipe_photos", + "tableTo": "recipes", + "columnsFrom": [ + "recipe_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.recipe_snapshots": { + "name": "recipe_snapshots", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "recipe_id": { + "name": "recipe_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "author_id": { + "name": "author_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "snapshot_data": { + "name": "snapshot_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "recipe_snapshots_recipe_idx": { + "name": "recipe_snapshots_recipe_idx", + "columns": [ + { + "expression": "recipe_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "recipe_snapshots_recipe_id_recipes_id_fk": { + "name": "recipe_snapshots_recipe_id_recipes_id_fk", + "tableFrom": "recipe_snapshots", + "tableTo": "recipes", + "columnsFrom": [ + "recipe_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "recipe_snapshots_author_id_users_id_fk": { + "name": "recipe_snapshots_author_id_users_id_fk", + "tableFrom": "recipe_snapshots", + "tableTo": "users", + "columnsFrom": [ + "author_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.recipe_steps": { + "name": "recipe_steps", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "recipe_id": { + "name": "recipe_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "instruction": { + "name": "instruction", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timer_seconds": { + "name": "timer_seconds", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "photo_url": { + "name": "photo_url", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "recipe_steps_recipe_id_recipes_id_fk": { + "name": "recipe_steps_recipe_id_recipes_id_fk", + "tableFrom": "recipe_steps", + "tableTo": "recipes", + "columnsFrom": [ + "recipe_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.recipe_variations": { + "name": "recipe_variations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "parent_recipe_id": { + "name": "parent_recipe_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_recipe_id": { + "name": "child_recipe_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ai_generated": { + "name": "ai_generated", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "recipe_variations_parent_recipe_id_recipes_id_fk": { + "name": "recipe_variations_parent_recipe_id_recipes_id_fk", + "tableFrom": "recipe_variations", + "tableTo": "recipes", + "columnsFrom": [ + "parent_recipe_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "recipe_variations_child_recipe_id_recipes_id_fk": { + "name": "recipe_variations_child_recipe_id_recipes_id_fk", + "tableFrom": "recipe_variations", + "tableTo": "recipes", + "columnsFrom": [ + "child_recipe_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.recipes": { + "name": "recipes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "author_id": { + "name": "author_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "base_servings": { + "name": "base_servings", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 4 + }, + "visibility": { + "name": "visibility", + "type": "visibility", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'private'" + }, + "source_url": { + "name": "source_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ai_generated": { + "name": "ai_generated", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "ai_model": { + "name": "ai_model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ai_prompt": { + "name": "ai_prompt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "language": { + "name": "language", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "dietary_tags": { + "name": "dietary_tags", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "dietary_verified": { + "name": "dietary_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "nutrition_data": { + "name": "nutrition_data", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "difficulty": { + "name": "difficulty", + "type": "difficulty", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "prep_mins": { + "name": "prep_mins", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "cook_mins": { + "name": "cook_mins", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "tags": { + "name": "tags", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "recipes_author_idx": { + "name": "recipes_author_idx", + "columns": [ + { + "expression": "author_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "recipes_visibility_idx": { + "name": "recipes_visibility_idx", + "columns": [ + { + "expression": "visibility", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "recipes_author_id_users_id_fk": { + "name": "recipes_author_id_users_id_fk", + "tableFrom": "recipes", + "tableTo": "users", + "columnsFrom": [ + "author_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_allergens": { + "name": "user_allergens", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "allergen_tag": { + "name": "allergen_tag", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "user_allergens_user_id_users_id_fk": { + "name": "user_allergens_user_id_users_id_fk", + "tableFrom": "user_allergens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.collection_members": { + "name": "collection_members", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "collection_member_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'viewer'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "collection_members_collection_id_collections_id_fk": { + "name": "collection_members_collection_id_collections_id_fk", + "tableFrom": "collection_members", + "tableTo": "collections", + "columnsFrom": [ + "collection_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "collection_members_user_id_users_id_fk": { + "name": "collection_members_user_id_users_id_fk", + "tableFrom": "collection_members", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.collection_recipes": { + "name": "collection_recipes", + "schema": "", + "columns": { + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "recipe_id": { + "name": "recipe_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "added_at": { + "name": "added_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "collection_recipes_collection_id_collections_id_fk": { + "name": "collection_recipes_collection_id_collections_id_fk", + "tableFrom": "collection_recipes", + "tableTo": "collections", + "columnsFrom": [ + "collection_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "collection_recipes_recipe_id_recipes_id_fk": { + "name": "collection_recipes_recipe_id_recipes_id_fk", + "tableFrom": "collection_recipes", + "tableTo": "recipes", + "columnsFrom": [ + "recipe_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.collections": { + "name": "collections", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_public": { + "name": "is_public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "collections_user_id_users_id_fk": { + "name": "collections_user_id_users_id_fk", + "tableFrom": "collections", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.comment_reactions": { + "name": "comment_reactions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "comment_id": { + "name": "comment_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "comment_reaction_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "comment_reactions_comment_idx": { + "name": "comment_reactions_comment_idx", + "columns": [ + { + "expression": "comment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "comment_reactions_comment_id_comments_id_fk": { + "name": "comment_reactions_comment_id_comments_id_fk", + "tableFrom": "comment_reactions", + "tableTo": "comments", + "columnsFrom": [ + "comment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "comment_reactions_user_id_users_id_fk": { + "name": "comment_reactions_user_id_users_id_fk", + "tableFrom": "comment_reactions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.comments": { + "name": "comments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "recipe_id": { + "name": "recipe_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "comments_recipe_idx": { + "name": "comments_recipe_idx", + "columns": [ + { + "expression": "recipe_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "comments_recipe_id_recipes_id_fk": { + "name": "comments_recipe_id_recipes_id_fk", + "tableFrom": "comments", + "tableTo": "recipes", + "columnsFrom": [ + "recipe_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "comments_user_id_users_id_fk": { + "name": "comments_user_id_users_id_fk", + "tableFrom": "comments", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.cooking_history": { + "name": "cooking_history", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "recipe_id": { + "name": "recipe_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cooked_at": { + "name": "cooked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "servings": { + "name": "servings", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "cooking_history_user_id_users_id_fk": { + "name": "cooking_history_user_id_users_id_fk", + "tableFrom": "cooking_history", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "cooking_history_recipe_id_recipes_id_fk": { + "name": "cooking_history_recipe_id_recipes_id_fk", + "tableFrom": "cooking_history", + "tableTo": "recipes", + "columnsFrom": [ + "recipe_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.favorites": { + "name": "favorites", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "recipe_id": { + "name": "recipe_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "favorites_user_id_users_id_fk": { + "name": "favorites_user_id_users_id_fk", + "tableFrom": "favorites", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "favorites_recipe_id_recipes_id_fk": { + "name": "favorites_recipe_id_recipes_id_fk", + "tableFrom": "favorites", + "tableTo": "recipes", + "columnsFrom": [ + "recipe_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.feed_items": { + "name": "feed_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "feed_item_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subject_id": { + "name": "subject_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "feed_items_user_idx": { + "name": "feed_items_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "feed_items_user_id_users_id_fk": { + "name": "feed_items_user_id_users_id_fk", + "tableFrom": "feed_items", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "feed_items_actor_id_users_id_fk": { + "name": "feed_items_actor_id_users_id_fk", + "tableFrom": "feed_items", + "tableTo": "users", + "columnsFrom": [ + "actor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ratings": { + "name": "ratings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "recipe_id": { + "name": "recipe_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "score": { + "name": "score", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "review_text": { + "name": "review_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "ratings_recipe_id_recipes_id_fk": { + "name": "ratings_recipe_id_recipes_id_fk", + "tableFrom": "ratings", + "tableTo": "recipes", + "columnsFrom": [ + "recipe_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ratings_user_id_users_id_fk": { + "name": "ratings_user_id_users_id_fk", + "tableFrom": "ratings", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.meal_plan_entries": { + "name": "meal_plan_entries", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "meal_plan_id": { + "name": "meal_plan_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "day": { + "name": "day", + "type": "weekday", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "meal_type": { + "name": "meal_type", + "type": "meal_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "recipe_id": { + "name": "recipe_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "servings": { + "name": "servings", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 2 + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "meal_plan_entries_meal_plan_id_meal_plans_id_fk": { + "name": "meal_plan_entries_meal_plan_id_meal_plans_id_fk", + "tableFrom": "meal_plan_entries", + "tableTo": "meal_plans", + "columnsFrom": [ + "meal_plan_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "meal_plan_entries_recipe_id_recipes_id_fk": { + "name": "meal_plan_entries_recipe_id_recipes_id_fk", + "tableFrom": "meal_plan_entries", + "tableTo": "recipes", + "columnsFrom": [ + "recipe_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.meal_plans": { + "name": "meal_plans", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "week_start": { + "name": "week_start", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "meal_plans_user_id_users_id_fk": { + "name": "meal_plans_user_id_users_id_fk", + "tableFrom": "meal_plans", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pantry_items": { + "name": "pantry_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ingredient_id": { + "name": "ingredient_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "raw_name": { + "name": "raw_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "quantity": { + "name": "quantity", + "type": "numeric(10, 4)", + "primaryKey": false, + "notNull": false + }, + "unit": { + "name": "unit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "pantry_items_user_id_users_id_fk": { + "name": "pantry_items_user_id_users_id_fk", + "tableFrom": "pantry_items", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pantry_items_ingredient_id_ingredients_id_fk": { + "name": "pantry_items_ingredient_id_ingredients_id_fk", + "tableFrom": "pantry_items", + "tableTo": "ingredients", + "columnsFrom": [ + "ingredient_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.shopping_list_items": { + "name": "shopping_list_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "list_id": { + "name": "list_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ingredient_id": { + "name": "ingredient_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "raw_name": { + "name": "raw_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "quantity": { + "name": "quantity", + "type": "numeric(10, 4)", + "primaryKey": false, + "notNull": false + }, + "unit": { + "name": "unit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "aisle": { + "name": "aisle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "checked": { + "name": "checked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": { + "shopping_list_items_list_id_shopping_lists_id_fk": { + "name": "shopping_list_items_list_id_shopping_lists_id_fk", + "tableFrom": "shopping_list_items", + "tableTo": "shopping_lists", + "columnsFrom": [ + "list_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shopping_list_items_ingredient_id_ingredients_id_fk": { + "name": "shopping_list_items_ingredient_id_ingredients_id_fk", + "tableFrom": "shopping_list_items", + "tableTo": "ingredients", + "columnsFrom": [ + "ingredient_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.shopping_lists": { + "name": "shopping_lists", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "generated_at": { + "name": "generated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "shopping_lists_user_id_users_id_fk": { + "name": "shopping_lists_user_id_users_id_fk", + "tableFrom": "shopping_lists", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_logs": { + "name": "audit_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_logs_created_idx": { + "name": "audit_logs_created_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audit_logs_user_id_users_id_fk": { + "name": "audit_logs_user_id_users_id_fk", + "tableFrom": "audit_logs", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.site_settings": { + "name": "site_settings", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_secret": { + "name": "is_secret", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_by_id": { + "name": "updated_by_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "site_settings_updated_by_id_users_id_fk": { + "name": "site_settings_updated_by_id_users_id_fk", + "tableFrom": "site_settings", + "tableTo": "users", + "columnsFrom": [ + "updated_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tier_definitions": { + "name": "tier_definitions", + "schema": "", + "columns": { + "tier": { + "name": "tier", + "type": "tier", + "typeSchema": "public", + "primaryKey": true, + "notNull": true + }, + "max_recipes": { + "name": "max_recipes", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "ai_calls_per_month": { + "name": "ai_calls_per_month", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "storage_mb": { + "name": "storage_mb", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "max_public_recipes": { + "name": "max_public_recipes", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_usage": { + "name": "user_usage", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "month": { + "name": "month", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ai_calls_used": { + "name": "ai_calls_used", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "recipe_count": { + "name": "recipe_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "storage_used_mb": { + "name": "storage_used_mb", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": { + "user_usage_user_month_idx": { + "name": "user_usage_user_month_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "month", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_usage_user_id_users_id_fk": { + "name": "user_usage_user_id_users_id_fk", + "tableFrom": "user_usage", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_usage_user_month_uniq": { + "name": "user_usage_user_month_uniq", + "nullsNotDistinct": false, + "columns": [ + "user_id", + "month" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.webhook_deliveries": { + "name": "webhook_deliveries", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "webhook_id": { + "name": "webhook_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event": { + "name": "event", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status_code": { + "name": "status_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "success": { + "name": "success", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "webhook_deliveries_webhook_id_webhooks_id_fk": { + "name": "webhook_deliveries_webhook_id_webhooks_id_fk", + "tableFrom": "webhook_deliveries", + "tableTo": "webhooks", + "columnsFrom": [ + "webhook_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.webhooks": { + "name": "webhooks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "events": { + "name": "events", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "secret": { + "name": "secret", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "webhooks_user_id_users_id_fk": { + "name": "webhooks_user_id_users_id_fk", + "tableFrom": "webhooks", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.tier": { + "name": "tier", + "schema": "public", + "values": [ + "free", + "pro" + ] + }, + "public.unit_pref": { + "name": "unit_pref", + "schema": "public", + "values": [ + "metric", + "imperial" + ] + }, + "public.user_role": { + "name": "user_role", + "schema": "public", + "values": [ + "user", + "moderator", + "admin" + ] + }, + "public.difficulty": { + "name": "difficulty", + "schema": "public", + "values": [ + "easy", + "medium", + "hard" + ] + }, + "public.visibility": { + "name": "visibility", + "schema": "public", + "values": [ + "private", + "unlisted", + "public" + ] + }, + "public.collection_member_role": { + "name": "collection_member_role", + "schema": "public", + "values": [ + "viewer", + "editor" + ] + }, + "public.comment_reaction_type": { + "name": "comment_reaction_type", + "schema": "public", + "values": [ + "like", + "love", + "laugh", + "wow", + "sad", + "fire" + ] + }, + "public.feed_item_type": { + "name": "feed_item_type", + "schema": "public", + "values": [ + "new_recipe", + "new_follow", + "recipe_rated" + ] + }, + "public.meal_type": { + "name": "meal_type", + "schema": "public", + "values": [ + "breakfast", + "lunch", + "dinner", + "snack" + ] + }, + "public.weekday": { + "name": "weekday", + "schema": "public", + "values": [ + "mon", + "tue", + "wed", + "thu", + "fri", + "sat", + "sun" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/db/src/migrations/meta/_journal.json b/packages/db/src/migrations/meta/_journal.json index 248a93d..608a6c6 100644 --- a/packages/db/src/migrations/meta/_journal.json +++ b/packages/db/src/migrations/meta/_journal.json @@ -64,6 +64,27 @@ "when": 1782883184215, "tag": "0008_violet_centennial", "breakpoints": true + }, + { + "idx": 9, + "version": "7", + "when": 1782891535023, + "tag": "0009_abnormal_lady_bullseye", + "breakpoints": true + }, + { + "idx": 10, + "version": "7", + "when": 1782893966205, + "tag": "0010_young_loners", + "breakpoints": true + }, + { + "idx": 11, + "version": "7", + "when": 1782896400709, + "tag": "0011_premium_agent_zero", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/src/schema/recipes.ts b/packages/db/src/schema/recipes.ts index 8d4d53b..c3da88c 100644 --- a/packages/db/src/schema/recipes.ts +++ b/packages/db/src/schema/recipes.ts @@ -36,12 +36,14 @@ export const recipes = pgTable("recipes", { aiGenerated: boolean("ai_generated").notNull().default(false), aiModel: text("ai_model"), aiPrompt: text("ai_prompt"), + language: text("language"), dietaryTags: jsonb("dietary_tags").$type().default({}), dietaryVerified: boolean("dietary_verified").notNull().default(false), nutritionData: jsonb("nutrition_data").$type<{ perServing: { calories: number; proteinG: number; carbsG: number; fatG: number; fiberG: number; sodiumMg: number } }>(), difficulty: difficultyEnum("difficulty"), prepMins: integer("prep_mins"), cookMins: integer("cook_mins"), + tags: text("tags").array().notNull().default([]), createdAt: timestamp("created_at").notNull().defaultNow(), updatedAt: timestamp("updated_at").notNull().defaultNow(), }, (t) => [ diff --git a/packages/db/src/schema/users.ts b/packages/db/src/schema/users.ts index 041a136..bea4319 100644 --- a/packages/db/src/schema/users.ts +++ b/packages/db/src/schema/users.ts @@ -19,6 +19,7 @@ export const users = pgTable("users", { name: text("name").notNull(), avatarUrl: text("avatar_url"), bio: text("bio"), + privateBio: text("private_bio"), username: text("username").unique(), role: userRoleEnum("role").notNull().default("user"), tier: tierEnum("tier").notNull().default("free"), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ec3ff91..28606fd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -46,7 +46,7 @@ importers: version: 6.0.209(zod@3.25.76) better-auth: specifier: ^1.6.20 - version: 1.6.20(@opentelemetry/api@1.9.1)(drizzle-kit@0.31.10)(drizzle-orm@0.44.7(@opentelemetry/api@1.9.1)(kysely@0.29.2)(postgres@3.4.9))(next@16.2.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + version: 1.6.20(@opentelemetry/api@1.9.1)(drizzle-kit@0.31.10)(drizzle-orm@0.44.7(@opentelemetry/api@1.9.1)(kysely@0.29.2)(postgres@3.4.9))(next@16.2.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@4.1.9) class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -108,6 +108,12 @@ importers: '@tailwindcss/postcss': specifier: ^4 version: 4.3.1 + '@testing-library/jest-dom': + specifier: ^6.9.1 + version: 6.9.1 + '@testing-library/react': + specifier: ^16.3.2 + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@types/node': specifier: ^20.19.43 version: 20.19.43 @@ -123,18 +129,30 @@ importers: '@types/web-push': specifier: ^3.6.4 version: 3.6.4 + '@vitejs/plugin-react': + specifier: ^6.0.3 + version: 6.0.3(vite@8.1.0(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + '@vitest/coverage-v8': + specifier: ^4.1.9 + version: 4.1.9(vitest@4.1.9) eslint: specifier: ^9 version: 9.39.4(jiti@2.7.0) eslint-config-next: specifier: 16.2.9 version: 16.2.9(@typescript-eslint/parser@8.62.0(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3) + jsdom: + specifier: ^29.1.1 + version: 29.1.1(@noble/hashes@2.2.0) tailwindcss: specifier: ^4 version: 4.3.1 typescript: specifier: ^5 version: 5.9.3 + vitest: + specifier: ^4.1.9 + version: 4.1.9(@opentelemetry/api@1.9.1)(@types/node@20.19.43)(@vitest/coverage-v8@4.1.9)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.1.0(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) packages/api-types: dependencies: @@ -167,6 +185,9 @@ importers: packages: + '@adobe/css-tools@4.5.0': + resolution: {integrity: sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==} + '@ai-sdk/anthropic@3.0.86': resolution: {integrity: sha512-IVUaTOz46dNniRaALEvphV5WOqPfKTJRZlJhJabz3kaXk1QiwdnzWQhKMKLiprD+872E9YHqmmyFNFFH1MfnBQ==} engines: {node: '>=18'} @@ -199,6 +220,21 @@ packages: resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} engines: {node: '>=10'} + '@asamuzakjp/css-color@5.1.11': + resolution: {integrity: sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/dom-selector@7.1.1': + resolution: {integrity: sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/generational-cache@1.0.1': + resolution: {integrity: sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/nwsapi@2.3.9': + resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==} + '@asteasolutions/zod-to-openapi@8.5.0': resolution: {integrity: sha512-SABbKiObg5dLRiTFnqiW1WWwGcg1BJfmHtT2asIBnBHg6Smy/Ms2KHc650+JI4Hw7lSkdiNebEGXpwoxfben8Q==} peerDependencies: @@ -471,6 +507,10 @@ packages: '@types/react': optional: true + '@bcoe/v8-coverage@1.0.2': + resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} + engines: {node: '>=18'} + '@better-auth/core@1.6.20': resolution: {integrity: sha512-y73I1xNXuNYiHBFduWGRcJ2ro2rNuVDEYkgVMJtIaRXtbosdXHs9gfyQrHecgeHMHKx1SYSBT/CExak0vVMTng==} peerDependencies: @@ -550,6 +590,46 @@ packages: '@better-fetch/fetch@1.3.1': resolution: {integrity: sha512-ABkD1WhyfPZprKRQI3bhATjeiFuNWC9PXhfGWqL+sg/gKrM977oFrYkdb4msM3hgUGonr7KlOsOFT5TU2rht9g==} + '@bramus/specificity@2.4.2': + resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==} + hasBin: true + + '@csstools/color-helpers@6.1.0': + resolution: {integrity: sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==} + engines: {node: '>=20.19.0'} + + '@csstools/css-calc@3.2.1': + resolution: {integrity: sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-color-parser@4.1.9': + resolution: {integrity: sha512-paQcIaOO53Rk5+YrBaBjm/SgrV4INImjo2BT1DtQRYr+XeTRbeAYlS+jxXp9drqvKmtFnWRJKIalDLhZZDu42A==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-parser-algorithms@4.0.0': + resolution: {integrity: sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.6': + resolution: {integrity: sha512-TcJCWFbXLPpJYq6z7bfOyjWYJDiDg2/I4gyUC9pqPNqHFRIey0EB0q0L5cSnQDfWJg8Jd6VadakxdIez/3zkqQ==} + peerDependencies: + css-tree: ^3.2.1 + peerDependenciesMeta: + css-tree: + optional: true + + '@csstools/css-tokenizer@4.0.0': + resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} + engines: {node: '>=20.19.0'} + '@dotenvx/dotenvx@1.75.1': resolution: {integrity: sha512-/BITOC9dmS/edY2zQwZNicQ059O6RKabtQfyEafV0nGtfYRNHYy1DIPiYVcov40+tob9hfmBnbR963dS+EQ1DQ==} hasBin: true @@ -563,6 +643,9 @@ packages: '@emnapi/core@1.10.0': resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} + '@emnapi/core@1.11.1': + resolution: {integrity: sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==} + '@emnapi/runtime@1.10.0': resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==} @@ -572,6 +655,9 @@ packages: '@emnapi/wasi-threads@1.2.1': resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} + '@emnapi/wasi-threads@1.2.2': + resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==} + '@esbuild-kit/core-utils@3.3.2': resolution: {integrity: sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==} deprecated: 'Merged into tsx: https://tsx.is' @@ -1062,6 +1148,15 @@ packages: resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@exodus/bytes@1.15.1': + resolution: {integrity: sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + '@noble/hashes': ^1.8.0 || ^2.0.0 + peerDependenciesMeta: + '@noble/hashes': + optional: true + '@floating-ui/core@1.7.5': resolution: {integrity: sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==} @@ -1308,6 +1403,12 @@ packages: '@emnapi/core': ^1.7.1 '@emnapi/runtime': ^1.7.1 + '@napi-rs/wasm-runtime@1.1.6': + resolution: {integrity: sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==} + peerDependencies: + '@emnapi/core': ^1.7.1 + '@emnapi/runtime': ^1.7.1 + '@next/env@16.2.9': resolution: {integrity: sha512-ki5VxxXfzD/9TDe13wyeTKIjQTAwBVpnr8KhRDUr8ltMUq1/NBpWNT5tiPoxiGl+PHM4X2ahSOiPk6iAimIzPg==} @@ -1398,6 +1499,9 @@ packages: resolution: {integrity: sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA==} engines: {node: '>=14'} + '@oxc-project/types@0.137.0': + resolution: {integrity: sha512-WT+Gb24i8hmvo85AIv2oEYouEXkRlKAlT9WaCa3TfLgNCN+GhrJOGZuIlMouAh38Qe4QOx26eUOVsq70qXrywA==} + '@parcel/watcher-android-arm64@2.5.6': resolution: {integrity: sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A==} engines: {node: '>= 10.0.0'} @@ -1657,6 +1761,104 @@ packages: '@types/react': optional: true + '@rolldown/binding-android-arm64@1.1.3': + resolution: {integrity: sha512-DT6Z3PhvioeHMvxo+xHc3KtqggrI7CCTXCmC2h/5zUlp5jVitv7XEy+9q5/7v8IolhlioawpMo8Kg0EEBy7J0g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.1.3': + resolution: {integrity: sha512-0NwgwsjM7LrsuVnXMK3koTpagBNOhloc/BNjKqZjv4V5zI5r13qx69uVhRx+o5Z0yy4Hzq+lpy7TAgUG/ocvrw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.1.3': + resolution: {integrity: sha512-YtiBp4disu6V560loT6PjMdiRaWmVvDNrUunAalbiFx2ggeJwxdAsgZMcoGP17uyAsTwAj5V1niksxlHnVQ1Sw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.1.3': + resolution: {integrity: sha512-yD3EkEdXk2LypPxnf/kSZHirarsI8gcPzc62SukhR9VJTyvV+F9Q/GxWNuCojc7sXyuVC4DxRGhdDK4X8VSsbw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.1.3': + resolution: {integrity: sha512-c+8vieQbsD7HNAHKIA34w0GJ9FedFFuJGD+7E6vz7Q3uqAIugL5p45fhlsj4UaAsHpcmlqugBWMhA0/j7o0sIg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.1.3': + resolution: {integrity: sha512-50jD0uUwLvur7Zz9LHz17kaAdTPjn5wN93hEgjvmYFRZwiR7ZJYovTd5ipyWJDAnXKvZ+wgc+/Ika6dwSF5OcA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-arm64-musl@1.1.3': + resolution: {integrity: sha512-BO9+oPL8K9poZJBfYPsXNtYjPE5uM3qeehT3aFcW4LITOl+iSqhp0abzjR2nWBUNjIZeKXjAEWBZ64WjNoHd6w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rolldown/binding-linux-ppc64-gnu@1.1.3': + resolution: {integrity: sha512-f3VpLB1vQ0Eo6ecr/6cekLnvYMFF4YBFoVGkfkvPLq1bAkbAwHYQPZKoAmG6OJyTcxxoC+AvezGx/S1obNC0Mw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-s390x-gnu@1.1.3': + resolution: {integrity: sha512-AmurZ26Pqx/RI9N1gzEOCklkKXl927yjfXWUUS0O7Puh8ARM/Ob8qfrD3qnWksScdw6cSrW5PSHE9DyLu7+PtA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-gnu@1.1.3': + resolution: {integrity: sha512-JJpqs8bRGITDOdbkNKnlojzBabbOHrqjSvDr0IVsZObE1lBcPjxItUEY9eWIDbxaJ3cGrXPWGfGkIxFijg/URg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-musl@1.1.3': + resolution: {integrity: sha512-rSJcdjPxzA/by/6/rYs+v+bXU7UjvnbUWz8MJb6kh6+knqB1dCrtHg0uu7C/4haqJvqdkYHQ5IGn+tCH9GLW/g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rolldown/binding-openharmony-arm64@1.1.3': + resolution: {integrity: sha512-hQ3/PYkDJICgevvyNcVrihVeqq7k1Pp3VZ9lY+dauAYUJKO+auqApvANhvR1An9BhmqYKvW2Mu1F9u4DXSMLxQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-wasm32-wasi@1.1.3': + resolution: {integrity: sha512-Elcv/BtML9lXrV6JuKITc/grN2kYV9gjsQpW8Jfw4ioK0TOkjBjye0nnyqQNy9STNaI20lXNaQBRrD5gSgR0Yg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [wasm32] + + '@rolldown/binding-win32-arm64-msvc@1.1.3': + resolution: {integrity: sha512-2DrEfhluH9yhiaFApmsjsjwrSYbNcY1oFTzYSP1a535jDbV98zCFanA/96TBUd0iDFcxGmw9QRExwGCXz3U+/g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.1.3': + resolution: {integrity: sha512-OL4OMk7UPXOeVGGd3qo5zJyPIljf4AFgk5QAkPPS+OoLuOOozhuaQGC18MxVTnw/06q93gShAJzlwnSCY9YtqA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + '@rtsao/scc@1.1.0': resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==} @@ -1900,12 +2102,44 @@ packages: '@tailwindcss/postcss@4.3.1': resolution: {integrity: sha512-dNJuNbdEJT/SWRuXTYP1WSamelsz3ztkUsdtWQPjrexysrTpaEPM40P/71knXiXLYEojqPOEGitVLLpPMS5T6A==} + '@testing-library/dom@10.4.1': + resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} + engines: {node: '>=18'} + + '@testing-library/jest-dom@6.9.1': + resolution: {integrity: sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==} + engines: {node: '>=14', npm: '>=6', yarn: '>=1'} + + '@testing-library/react@16.3.2': + resolution: {integrity: sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==} + engines: {node: '>=18'} + peerDependencies: + '@testing-library/dom': ^10.0.0 + '@types/react': ^18.0.0 || ^19.0.0 + '@types/react-dom': ^18.0.0 || ^19.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@ts-morph/common@0.27.0': resolution: {integrity: sha512-Wf29UqxWDpc+i61k3oIOzcUfQt79PIT9y/MWfAGlrkjg6lBC1hwDECLXPVJAhWjiGbfBCxZd65F/LIZF3+jeJQ==} '@tybys/wasm-util@0.10.3': resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} + '@types/aria-query@5.0.4': + resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + '@types/estree@1.0.9': resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} @@ -2118,6 +2352,57 @@ packages: resolution: {integrity: sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug==} engines: {node: '>= 20'} + '@vitejs/plugin-react@6.0.3': + resolution: {integrity: sha512-vmFvco5/QuC2f9Oj+wTk0+9XeDFkHxSamwZKYc7MxYwKICfvUvlMhqKI0VuICPltGqh1neqBKDvO4kes1ya8vg==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + '@rolldown/plugin-babel': ^0.1.7 || ^0.2.0 + babel-plugin-react-compiler: ^1.0.0 + vite: ^8.0.0 + peerDependenciesMeta: + '@rolldown/plugin-babel': + optional: true + babel-plugin-react-compiler: + optional: true + + '@vitest/coverage-v8@4.1.9': + resolution: {integrity: sha512-G9/lgqibheLVBDRuya45EbsEXTYcWoSG+TLg7i2axuzx0Eq62eXn+aWXyaVdV5vKvFSWd6ywcX8hA7la9Pvu8g==} + peerDependencies: + '@vitest/browser': 4.1.9 + vitest: 4.1.9 + peerDependenciesMeta: + '@vitest/browser': + optional: true + + '@vitest/expect@4.1.9': + resolution: {integrity: sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==} + + '@vitest/mocker@4.1.9': + resolution: {integrity: sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.1.9': + resolution: {integrity: sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==} + + '@vitest/runner@4.1.9': + resolution: {integrity: sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==} + + '@vitest/snapshot@4.1.9': + resolution: {integrity: sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==} + + '@vitest/spy@4.1.9': + resolution: {integrity: sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==} + + '@vitest/utils@4.1.9': + resolution: {integrity: sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==} + accepts@2.0.0: resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} engines: {node: '>= 0.6'} @@ -2180,6 +2465,10 @@ packages: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} @@ -2187,6 +2476,9 @@ packages: resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} engines: {node: '>=10'} + aria-query@5.3.0: + resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} + aria-query@5.3.2: resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} engines: {node: '>= 0.4'} @@ -2226,6 +2518,10 @@ packages: asn1.js@5.4.1: resolution: {integrity: sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==} + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + ast-types-flow@0.0.8: resolution: {integrity: sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==} @@ -2233,6 +2529,9 @@ packages: resolution: {integrity: sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg==} engines: {node: '>=4'} + ast-v8-to-istanbul@1.0.4: + resolution: {integrity: sha512-0bC0/4bTSrnwdhU3IsZDwEdojvuPrSg59OYZfKsLRtJZ0u8VBx9DebfqqG8bRdCC0I7vjgxmPi41P0lpkhJHtA==} + async-function@1.0.0: resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} engines: {node: '>= 0.4'} @@ -2335,6 +2634,9 @@ packages: zod: optional: true + bidi-js@1.0.3: + resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} + bn.js@4.12.4: resolution: {integrity: sha512-njR1b+ixG2ufvL9Zn9JGneW+b5GV6jqpYyPPpg4QVt723b5kJPGUczkUyWEH9BwEA74UakJZ43I4FDLBF7ci0g==} @@ -2394,6 +2696,10 @@ packages: caniuse-lite@1.0.30001799: resolution: {integrity: sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==} + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + chalk@4.1.2: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} @@ -2495,6 +2801,13 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} + css-tree@3.2.1: + resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + + css.escape@1.5.1: + resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} + cssesc@3.0.0: resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} engines: {node: '>=4'} @@ -2510,6 +2823,10 @@ packages: resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==} engines: {node: '>= 12'} + data-urls@7.0.0: + resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + data-view-buffer@1.0.2: resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==} engines: {node: '>= 0.4'} @@ -2543,6 +2860,9 @@ packages: supports-color: optional: true + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + dedent@1.7.2: resolution: {integrity: sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA==} peerDependencies: @@ -2593,6 +2913,10 @@ packages: resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} engines: {node: '>= 0.8'} + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + detect-libc@2.1.2: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} @@ -2608,6 +2932,12 @@ packages: resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} engines: {node: '>=0.10.0'} + dom-accessibility-api@0.5.16: + resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + + dom-accessibility-api@0.6.3: + resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} + dot-prop@6.0.1: resolution: {integrity: sha512-tE7ztYzXHIeyvc7N+hR3oi7FIbf/NIjVP9hmAt3yMXzrQ072/fpjGLx2GxNxGxUl5V73MEqYzioOMoVhGMJ5cA==} engines: {node: '>=10'} @@ -2743,6 +3073,10 @@ packages: resolution: {integrity: sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==} engines: {node: '>=8.6'} + entities@8.0.0: + resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==} + engines: {node: '>=20.19.0'} + env-paths@2.2.1: resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} engines: {node: '>=6'} @@ -2770,6 +3104,9 @@ packages: resolution: {integrity: sha512-0PuBxFi+4uPanB97iDxCLWuHeYud2FALrw5HFZGtAF38UpJDbDC8frwp2cnDyae692CQ0dou60UwWfhgsa4U/g==} engines: {node: '>= 0.4'} + es-module-lexer@2.2.0: + resolution: {integrity: sha512-3lGxdTXCLfe1MYfTz1y2ksAAUM4NAOP6rPEjxGJVKO7TZ5+tvHCaQWGpC4Y3IXvW3ece0Cz1cIP4FWBxOnGCTQ==} + es-object-atoms@1.1.2: resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} engines: {node: '>= 0.4'} @@ -2933,6 +3270,9 @@ packages: resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} engines: {node: '>=4.0'} + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + esutils@2.0.3: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} @@ -2957,6 +3297,10 @@ packages: resolution: {integrity: sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==} engines: {node: ^18.19.0 || >=20.5.0} + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + express-rate-limit@8.5.2: resolution: {integrity: sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==} engines: {node: '>= 16'} @@ -3179,6 +3523,13 @@ packages: resolution: {integrity: sha512-1yrb/+w6HWQJrUCLkJ2IF5jNIPvvFkblV5RNOYl6bV+OA6p9GLcMpHFFGTosSvHvcAUibuUukRqhlYI4z32C7Q==} engines: {node: '>=16.9.0'} + html-encoding-sniffer@6.0.0: + resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + html-escaper@2.0.2: + resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + http-errors@2.0.1: resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} engines: {node: '>= 0.8'} @@ -3222,6 +3573,10 @@ packages: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} + indent-string@4.0.0: + resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} + engines: {node: '>=8'} + inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} @@ -3353,6 +3708,9 @@ packages: resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} engines: {node: '>=12'} + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + is-promise@4.0.0: resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} @@ -3430,6 +3788,18 @@ packages: resolution: {integrity: sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==} engines: {node: '>=18'} + istanbul-lib-coverage@3.2.2: + resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} + engines: {node: '>=8'} + + istanbul-lib-report@3.0.1: + resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} + engines: {node: '>=10'} + + istanbul-reports@3.2.0: + resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} + engines: {node: '>=8'} + iterator.prototype@1.1.5: resolution: {integrity: sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==} engines: {node: '>= 0.4'} @@ -3441,6 +3811,9 @@ packages: jose@6.2.3: resolution: {integrity: sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==} + js-tokens@10.0.0: + resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -3448,6 +3821,15 @@ packages: resolution: {integrity: sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==} hasBin: true + jsdom@29.1.1: + resolution: {integrity: sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24.0.0} + peerDependencies: + canvas: ^3.0.0 + peerDependenciesMeta: + canvas: + optional: true + jsesc@3.1.0: resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} engines: {node: '>=6'} @@ -3621,6 +4003,10 @@ packages: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} hasBin: true + lru-cache@11.5.1: + resolution: {integrity: sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==} + engines: {node: 20 || >=22} + lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} @@ -3629,13 +4015,27 @@ packages: peerDependencies: react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + lz-string@1.5.0: + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} + hasBin: true + magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + magicast@0.5.3: + resolution: {integrity: sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==} + + make-dir@4.0.0: + resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} + engines: {node: '>=10'} + math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} + mdn-data@2.27.1: + resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} + media-typer@1.1.0: resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==} engines: {node: '>= 0.8'} @@ -3675,6 +4075,10 @@ packages: resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} engines: {node: '>=18'} + min-indent@1.0.1: + resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} + engines: {node: '>=4'} + minimalistic-assert@1.0.1: resolution: {integrity: sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==} @@ -3820,6 +4224,10 @@ packages: resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==} engines: {node: '>= 0.4'} + obug@2.1.3: + resolution: {integrity: sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==} + engines: {node: '>=12.20.0'} + on-finished@2.4.1: resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} engines: {node: '>= 0.8'} @@ -3890,6 +4298,9 @@ packages: resolution: {integrity: sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==} engines: {node: '>=18'} + parse5@8.0.1: + resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==} + parseurl@1.3.3: resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} engines: {node: '>= 0.8'} @@ -3919,6 +4330,9 @@ packages: path-to-regexp@8.4.2: resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==} + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -3969,6 +4383,10 @@ packages: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} + pretty-format@27.5.1: + resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + pretty-ms@9.3.0: resolution: {integrity: sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==} engines: {node: '>=18'} @@ -4017,6 +4435,9 @@ packages: react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + react-is@17.0.2: + resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + react-remove-scroll-bar@2.3.8: resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==} engines: {node: '>=10'} @@ -4055,6 +4476,10 @@ packages: resolution: {integrity: sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA==} engines: {node: '>= 4'} + redent@3.0.0: + resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} + engines: {node: '>=8'} + redis-errors@1.2.0: resolution: {integrity: sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==} engines: {node: '>=4'} @@ -4098,6 +4523,11 @@ packages: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + rolldown@1.1.3: + resolution: {integrity: sha512-1F1eEtUBtFvcGm1HQ9TiUIUHPQG7mSAODrhIzjxoUEFuo8OcbrGLiVLkevNgj84TE4lnHvnumwFjhJO5Eu135g==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + rou3@0.7.12: resolution: {integrity: sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg==} @@ -4130,6 +4560,10 @@ packages: safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + scheduler@0.27.0: resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} @@ -4200,6 +4634,9 @@ packages: resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==} engines: {node: '>= 0.4'} + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + signal-exit@3.0.7: resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} @@ -4230,6 +4667,9 @@ packages: stable-hash@0.0.5: resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==} + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + standard-as-callback@2.1.0: resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==} @@ -4237,6 +4677,9 @@ packages: resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} engines: {node: '>= 0.8'} + std-env@4.1.0: + resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} + stdin-discarder@0.2.2: resolution: {integrity: sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ==} engines: {node: '>=18'} @@ -4296,6 +4739,10 @@ packages: resolution: {integrity: sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==} engines: {node: '>=18'} + strip-indent@3.0.0: + resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} + engines: {node: '>=8'} + strip-json-comments@3.1.1: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} @@ -4321,6 +4768,9 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + systeminformation@5.31.9: resolution: {integrity: sha512-aqepyutSy94zJB552q3LGV2nPfUGZV7LoGhUUjLjs36aLzW3ghpKI7BEpEoQ/OOM+0On4RsyVp1+v6dfYQbqdw==} engines: {node: '>=8.0.0'} @@ -4340,10 +4790,28 @@ packages: tiny-invariant@1.3.3: resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@1.2.4: + resolution: {integrity: sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==} + engines: {node: '>=18'} + tinyglobby@0.2.17: resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} engines: {node: '>=12.0.0'} + tinyrainbow@3.1.0: + resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} + engines: {node: '>=14.0.0'} + + tldts-core@7.4.5: + resolution: {integrity: sha512-pGrwzZDvPwKe+7NNUqAunb6rqTfynr0VOUhCMdqbu5xlvNiszsAJygRzwvpVycdzejlbpY+SWJOn+s75Og7FEA==} + + tldts@7.4.5: + resolution: {integrity: sha512-RfEzKWcq5fHUOFq7J3rl3Oz6ylKGtcHqUznzj4EcXsxLSIjJcvpbXAQtWGeJQ0xKnimR5e0Cn+cn9TssfMzm+g==} + hasBin: true + to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} @@ -4352,6 +4820,14 @@ packages: resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} engines: {node: '>=0.6'} + tough-cookie@6.0.1: + resolution: {integrity: sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==} + engines: {node: '>=16'} + + tr46@6.0.0: + resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==} + engines: {node: '>=20'} + ts-api-utils@2.5.0: resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} engines: {node: '>=18.12'} @@ -4491,6 +4967,94 @@ packages: resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} engines: {node: '>= 0.8'} + vite@8.1.0: + resolution: {integrity: sha512-BuJcQK/56NQTWDGn4ABea3q4SSBdNPWwNZKTkkUpcMPnLoquSYH8llRtSUIgoL1KSCpHt5eghLShn50mH36y7Q==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.3.0 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@4.1.9: + resolution: {integrity: sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.9 + '@vitest/browser-preview': 4.1.9 + '@vitest/browser-webdriverio': 4.1.9 + '@vitest/coverage-istanbul': 4.1.9 + '@vitest/coverage-v8': 4.1.9 + '@vitest/ui': 4.1.9 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + web-push@3.6.7: resolution: {integrity: sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==} engines: {node: '>= 16'} @@ -4500,6 +5064,18 @@ packages: resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==} engines: {node: '>= 8'} + webidl-conversions@8.0.1: + resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==} + engines: {node: '>=20'} + + whatwg-mimetype@5.0.0: + resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==} + engines: {node: '>=20'} + + whatwg-url@16.0.1: + resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + which-boxed-primitive@1.1.1: resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==} engines: {node: '>= 0.4'} @@ -4526,6 +5102,11 @@ packages: engines: {node: ^16.13.0 || >=18.0.0} hasBin: true + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + word-wrap@1.2.5: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} @@ -4537,6 +5118,13 @@ packages: resolution: {integrity: sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg==} engines: {node: '>=20'} + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} @@ -4576,6 +5164,8 @@ packages: snapshots: + '@adobe/css-tools@4.5.0': {} + '@ai-sdk/anthropic@3.0.86(zod@3.25.76)': dependencies: '@ai-sdk/provider': 3.0.10 @@ -4608,6 +5198,26 @@ snapshots: '@alloc/quick-lru@5.2.0': {} + '@asamuzakjp/css-color@5.1.11': + dependencies: + '@asamuzakjp/generational-cache': 1.0.1 + '@csstools/css-calc': 3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-color-parser': 4.1.9(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@asamuzakjp/dom-selector@7.1.1': + dependencies: + '@asamuzakjp/generational-cache': 1.0.1 + '@asamuzakjp/nwsapi': 2.3.9 + bidi-js: 1.0.3 + css-tree: 3.2.1 + is-potential-custom-element-name: 1.0.1 + + '@asamuzakjp/generational-cache@1.0.1': {} + + '@asamuzakjp/nwsapi@2.3.9': {} + '@asteasolutions/zod-to-openapi@8.5.0(zod@3.25.76)': dependencies: openapi3-ts: 4.6.0 @@ -5062,6 +5672,8 @@ snapshots: optionalDependencies: '@types/react': 19.2.17 + '@bcoe/v8-coverage@1.0.2': {} + '@better-auth/core@1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0)': dependencies: '@better-auth/utils': 0.4.2 @@ -5117,6 +5729,34 @@ snapshots: '@better-fetch/fetch@1.3.1': {} + '@bramus/specificity@2.4.2': + dependencies: + css-tree: 3.2.1 + + '@csstools/color-helpers@6.1.0': {} + + '@csstools/css-calc@3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-color-parser@4.1.9(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/color-helpers': 6.1.0 + '@csstools/css-calc': 3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.6(css-tree@3.2.1)': + optionalDependencies: + css-tree: 3.2.1 + + '@csstools/css-tokenizer@4.0.0': {} + '@dotenvx/dotenvx@1.75.1': dependencies: '@dotenvx/primitives': 0.8.0 @@ -5146,6 +5786,12 @@ snapshots: tslib: 2.8.1 optional: true + '@emnapi/core@1.11.1': + dependencies: + '@emnapi/wasi-threads': 1.2.2 + tslib: 2.8.1 + optional: true + '@emnapi/runtime@1.10.0': dependencies: tslib: 2.8.1 @@ -5161,6 +5807,11 @@ snapshots: tslib: 2.8.1 optional: true + '@emnapi/wasi-threads@1.2.2': + dependencies: + tslib: 2.8.1 + optional: true + '@esbuild-kit/core-utils@3.3.2': dependencies: esbuild: 0.18.20 @@ -5439,6 +6090,10 @@ snapshots: '@eslint/core': 0.17.0 levn: 0.4.1 + '@exodus/bytes@1.15.1(@noble/hashes@2.2.0)': + optionalDependencies: + '@noble/hashes': 2.2.0 + '@floating-ui/core@1.7.5': dependencies: '@floating-ui/utils': 0.2.11 @@ -5640,6 +6295,13 @@ snapshots: '@tybys/wasm-util': 0.10.3 optional: true + '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': + dependencies: + '@emnapi/core': 1.11.1 + '@emnapi/runtime': 1.11.1 + '@tybys/wasm-util': 0.10.3 + optional: true + '@next/env@16.2.9': {} '@next/eslint-plugin-next@16.2.9': @@ -5692,6 +6354,8 @@ snapshots: '@opentelemetry/semantic-conventions@1.41.1': {} + '@oxc-project/types@0.137.0': {} + '@parcel/watcher-android-arm64@2.5.6': optional: true @@ -5894,6 +6558,57 @@ snapshots: optionalDependencies: '@types/react': 19.2.17 + '@rolldown/binding-android-arm64@1.1.3': + optional: true + + '@rolldown/binding-darwin-arm64@1.1.3': + optional: true + + '@rolldown/binding-darwin-x64@1.1.3': + optional: true + + '@rolldown/binding-freebsd-x64@1.1.3': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.1.3': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.1.3': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.1.3': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.1.3': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.1.3': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.1.3': + optional: true + + '@rolldown/binding-linux-x64-musl@1.1.3': + optional: true + + '@rolldown/binding-openharmony-arm64@1.1.3': + optional: true + + '@rolldown/binding-wasm32-wasi@1.1.3': + dependencies: + '@emnapi/core': 1.11.1 + '@emnapi/runtime': 1.11.1 + '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.1.3': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.1.3': + optional: true + + '@rolldown/pluginutils@1.0.1': {} + '@rtsao/scc@1.1.0': {} '@schummar/icu-type-parser@1.21.5': {} @@ -6087,6 +6802,36 @@ snapshots: postcss: 8.5.15 tailwindcss: 4.3.1 + '@testing-library/dom@10.4.1': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/runtime': 7.29.7 + '@types/aria-query': 5.0.4 + aria-query: 5.3.0 + dom-accessibility-api: 0.5.16 + lz-string: 1.5.0 + picocolors: 1.1.1 + pretty-format: 27.5.1 + + '@testing-library/jest-dom@6.9.1': + dependencies: + '@adobe/css-tools': 4.5.0 + aria-query: 5.3.2 + css.escape: 1.5.1 + dom-accessibility-api: 0.6.3 + picocolors: 1.1.1 + redent: 3.0.0 + + '@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@babel/runtime': 7.29.7 + '@testing-library/dom': 10.4.1 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + '@ts-morph/common@0.27.0': dependencies: fast-glob: 3.3.3 @@ -6098,6 +6843,15 @@ snapshots: tslib: 2.8.1 optional: true + '@types/aria-query@5.0.4': {} + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + '@types/estree@1.0.9': {} '@types/json-schema@7.0.15': {} @@ -6289,6 +7043,66 @@ snapshots: '@vercel/oidc@3.2.0': {} + '@vitejs/plugin-react@6.0.3(vite@8.1.0(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))': + dependencies: + '@rolldown/pluginutils': 1.0.1 + vite: 8.1.0(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0) + + '@vitest/coverage-v8@4.1.9(vitest@4.1.9)': + dependencies: + '@bcoe/v8-coverage': 1.0.2 + '@vitest/utils': 4.1.9 + ast-v8-to-istanbul: 1.0.4 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-report: 3.0.1 + istanbul-reports: 3.2.0 + magicast: 0.5.3 + obug: 2.1.3 + std-env: 4.1.0 + tinyrainbow: 3.1.0 + vitest: 4.1.9(@opentelemetry/api@1.9.1)(@types/node@20.19.43)(@vitest/coverage-v8@4.1.9)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.1.0(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + + '@vitest/expect@4.1.9': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.9 + '@vitest/utils': 4.1.9 + chai: 6.2.2 + tinyrainbow: 3.1.0 + + '@vitest/mocker@4.1.9(vite@8.1.0(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))': + dependencies: + '@vitest/spy': 4.1.9 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 8.1.0(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0) + + '@vitest/pretty-format@4.1.9': + dependencies: + tinyrainbow: 3.1.0 + + '@vitest/runner@4.1.9': + dependencies: + '@vitest/utils': 4.1.9 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.9': + dependencies: + '@vitest/pretty-format': 4.1.9 + '@vitest/utils': 4.1.9 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.1.9': {} + + '@vitest/utils@4.1.9': + dependencies: + '@vitest/pretty-format': 4.1.9 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.0 + accepts@2.0.0: dependencies: mime-types: 3.0.2 @@ -6342,12 +7156,18 @@ snapshots: dependencies: color-convert: 2.0.1 + ansi-styles@5.2.0: {} + argparse@2.0.1: {} aria-hidden@1.2.6: dependencies: tslib: 2.8.1 + aria-query@5.3.0: + dependencies: + dequal: 2.0.3 + aria-query@5.3.2: {} array-buffer-byte-length@1.0.2: @@ -6424,12 +7244,20 @@ snapshots: minimalistic-assert: 1.0.1 safer-buffer: 2.1.2 + assertion-error@2.0.1: {} + ast-types-flow@0.0.8: {} ast-types@0.16.1: dependencies: tslib: 2.8.1 + ast-v8-to-istanbul@1.0.4: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + estree-walker: 3.0.3 + js-tokens: 10.0.0 + async-function@1.0.0: {} atomically@1.7.0: {} @@ -6448,7 +7276,7 @@ snapshots: baseline-browser-mapping@2.10.38: {} - better-auth@1.6.20(@opentelemetry/api@1.9.1)(drizzle-kit@0.31.10)(drizzle-orm@0.44.7(@opentelemetry/api@1.9.1)(kysely@0.29.2)(postgres@3.4.9))(next@16.2.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + better-auth@1.6.20(@opentelemetry/api@1.9.1)(drizzle-kit@0.31.10)(drizzle-orm@0.44.7(@opentelemetry/api@1.9.1)(kysely@0.29.2)(postgres@3.4.9))(next@16.2.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@4.1.9): dependencies: '@better-auth/core': 1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0) '@better-auth/drizzle-adapter': 1.6.20(@better-auth/core@1.6.20(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.2)(drizzle-orm@0.44.7(@opentelemetry/api@1.9.1)(kysely@0.29.2)(postgres@3.4.9)) @@ -6473,6 +7301,7 @@ snapshots: next: 16.2.9(@babel/core@7.29.7)(@opentelemetry/api@1.9.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) react: 19.2.4 react-dom: 19.2.4(react@19.2.4) + vitest: 4.1.9(@opentelemetry/api@1.9.1)(@types/node@20.19.43)(@vitest/coverage-v8@4.1.9)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.1.0(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) transitivePeerDependencies: - '@cloudflare/workers-types' - '@opentelemetry/api' @@ -6486,6 +7315,10 @@ snapshots: optionalDependencies: zod: 4.4.3 + bidi-js@1.0.3: + dependencies: + require-from-string: 2.0.2 + bn.js@4.12.4: {} body-parser@2.3.0: @@ -6556,6 +7389,8 @@ snapshots: caniuse-lite@1.0.30001799: {} + chai@6.2.2: {} + chalk@4.1.2: dependencies: ansi-styles: 4.3.0 @@ -6650,6 +7485,13 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + css-tree@3.2.1: + dependencies: + mdn-data: 2.27.1 + source-map-js: 1.2.1 + + css.escape@1.5.1: {} + cssesc@3.0.0: {} csstype@3.2.3: {} @@ -6658,6 +7500,13 @@ snapshots: data-uri-to-buffer@4.0.1: {} + data-urls@7.0.0(@noble/hashes@2.2.0): + dependencies: + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1(@noble/hashes@2.2.0) + transitivePeerDependencies: + - '@noble/hashes' + data-view-buffer@1.0.2: dependencies: call-bound: 1.0.4 @@ -6688,6 +7537,8 @@ snapshots: dependencies: ms: 2.1.3 + decimal.js@10.6.0: {} + dedent@1.7.2: {} deep-is@0.1.4: {} @@ -6723,6 +7574,8 @@ snapshots: depd@2.0.0: {} + dequal@2.0.3: {} + detect-libc@2.1.2: {} detect-node-es@1.1.0: {} @@ -6733,6 +7586,10 @@ snapshots: dependencies: esutils: 2.0.3 + dom-accessibility-api@0.5.16: {} + + dom-accessibility-api@0.6.3: {} + dot-prop@6.0.1: dependencies: is-obj: 2.0.0 @@ -6782,6 +7639,8 @@ snapshots: ansi-colors: 4.1.3 strip-ansi: 6.0.1 + entities@8.0.0: {} + env-paths@2.2.1: {} error-ex@1.3.4: @@ -6875,6 +7734,8 @@ snapshots: iterator.prototype: 1.1.5 math-intrinsics: 1.1.0 + es-module-lexer@2.2.0: {} + es-object-atoms@1.1.2: dependencies: es-errors: 1.3.0 @@ -7192,6 +8053,10 @@ snapshots: estraverse@5.3.0: {} + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + esutils@2.0.3: {} etag@1.8.1: {} @@ -7229,6 +8094,8 @@ snapshots: strip-final-newline: 4.0.0 yoctocolors: 2.1.2 + expect-type@1.4.0: {} + express-rate-limit@8.5.2(express@5.2.1): dependencies: express: 5.2.1 @@ -7478,6 +8345,14 @@ snapshots: hono@4.12.27: {} + html-encoding-sniffer@6.0.0(@noble/hashes@2.2.0): + dependencies: + '@exodus/bytes': 1.15.1(@noble/hashes@2.2.0) + transitivePeerDependencies: + - '@noble/hashes' + + html-escaper@2.0.2: {} + http-errors@2.0.1: dependencies: depd: 2.0.0 @@ -7518,6 +8393,8 @@ snapshots: imurmurhash@0.1.4: {} + indent-string@4.0.0: {} + inherits@2.0.4: {} internal-slot@1.1.0: @@ -7644,6 +8521,8 @@ snapshots: is-plain-obj@4.1.0: {} + is-potential-custom-element-name@1.0.1: {} + is-promise@4.0.0: {} is-regex@1.2.1: @@ -7709,6 +8588,19 @@ snapshots: isexe@3.1.5: {} + istanbul-lib-coverage@3.2.2: {} + + istanbul-lib-report@3.0.1: + dependencies: + istanbul-lib-coverage: 3.2.2 + make-dir: 4.0.0 + supports-color: 7.2.0 + + istanbul-reports@3.2.0: + dependencies: + html-escaper: 2.0.2 + istanbul-lib-report: 3.0.1 + iterator.prototype@1.1.5: dependencies: define-data-property: 1.1.4 @@ -7722,12 +8614,40 @@ snapshots: jose@6.2.3: {} + js-tokens@10.0.0: {} + js-tokens@4.0.0: {} js-yaml@4.2.0: dependencies: argparse: 2.0.1 + jsdom@29.1.1(@noble/hashes@2.2.0): + dependencies: + '@asamuzakjp/css-color': 5.1.11 + '@asamuzakjp/dom-selector': 7.1.1 + '@bramus/specificity': 2.4.2 + '@csstools/css-syntax-patches-for-csstree': 1.1.6(css-tree@3.2.1) + '@exodus/bytes': 1.15.1(@noble/hashes@2.2.0) + css-tree: 3.2.1 + data-urls: 7.0.0(@noble/hashes@2.2.0) + decimal.js: 10.6.0 + html-encoding-sniffer: 6.0.0(@noble/hashes@2.2.0) + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.5.1 + parse5: 8.0.1 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 6.0.1 + undici: 7.28.0 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 8.0.1 + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1(@noble/hashes@2.2.0) + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - '@noble/hashes' + jsesc@3.1.0: {} json-buffer@3.0.1: {} @@ -7868,6 +8788,8 @@ snapshots: dependencies: js-tokens: 4.0.0 + lru-cache@11.5.1: {} + lru-cache@5.1.1: dependencies: yallist: 3.1.1 @@ -7876,12 +8798,26 @@ snapshots: dependencies: react: 19.2.4 + lz-string@1.5.0: {} + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + magicast@0.5.3: + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + source-map-js: 1.2.1 + + make-dir@4.0.0: + dependencies: + semver: 7.8.5 + math-intrinsics@1.1.0: {} + mdn-data@2.27.1: {} + media-typer@1.1.0: {} merge-descriptors@2.0.0: {} @@ -7907,6 +8843,8 @@ snapshots: mimic-function@5.0.1: {} + min-indent@1.0.1: {} + minimalistic-assert@1.0.1: {} minimatch@10.2.5: @@ -8054,6 +8992,8 @@ snapshots: define-properties: 1.2.1 es-object-atoms: 1.1.2 + obug@2.1.3: {} + on-finished@2.4.1: dependencies: ee-first: 1.1.1 @@ -8147,6 +9087,10 @@ snapshots: parse-ms@4.0.0: {} + parse5@8.0.1: + dependencies: + entities: 8.0.0 + parseurl@1.3.3: {} path-browserify@1.0.1: {} @@ -8163,6 +9107,8 @@ snapshots: path-to-regexp@8.4.2: {} + pathe@2.0.3: {} + picocolors@1.1.1: {} picomatch@2.3.2: {} @@ -8202,6 +9148,12 @@ snapshots: prelude-ls@1.2.1: {} + pretty-format@27.5.1: + dependencies: + ansi-regex: 5.0.1 + ansi-styles: 5.2.0 + react-is: 17.0.2 + pretty-ms@9.3.0: dependencies: parse-ms: 4.0.0 @@ -8250,6 +9202,8 @@ snapshots: react-is@16.13.1: {} + react-is@17.0.2: {} + react-remove-scroll-bar@2.3.8(@types/react@19.2.17)(react@19.2.4): dependencies: react: 19.2.4 @@ -8287,6 +9241,11 @@ snapshots: tiny-invariant: 1.3.3 tslib: 2.8.1 + redent@3.0.0: + dependencies: + indent-string: 4.0.0 + strip-indent: 3.0.0 + redis-errors@1.2.0: {} redis-parser@3.0.0: @@ -8337,6 +9296,27 @@ snapshots: reusify@1.1.0: {} + rolldown@1.1.3: + dependencies: + '@oxc-project/types': 0.137.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.1.3 + '@rolldown/binding-darwin-arm64': 1.1.3 + '@rolldown/binding-darwin-x64': 1.1.3 + '@rolldown/binding-freebsd-x64': 1.1.3 + '@rolldown/binding-linux-arm-gnueabihf': 1.1.3 + '@rolldown/binding-linux-arm64-gnu': 1.1.3 + '@rolldown/binding-linux-arm64-musl': 1.1.3 + '@rolldown/binding-linux-ppc64-gnu': 1.1.3 + '@rolldown/binding-linux-s390x-gnu': 1.1.3 + '@rolldown/binding-linux-x64-gnu': 1.1.3 + '@rolldown/binding-linux-x64-musl': 1.1.3 + '@rolldown/binding-openharmony-arm64': 1.1.3 + '@rolldown/binding-wasm32-wasi': 1.1.3 + '@rolldown/binding-win32-arm64-msvc': 1.1.3 + '@rolldown/binding-win32-x64-msvc': 1.1.3 + rou3@0.7.12: {} router@2.2.0: @@ -8378,6 +9358,10 @@ snapshots: safer-buffer@2.1.2: {} + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + scheduler@0.27.0: {} semver@6.3.1: {} @@ -8542,6 +9526,8 @@ snapshots: side-channel-map: 1.0.1 side-channel-weakmap: 1.0.2 + siginfo@2.0.0: {} + signal-exit@3.0.7: {} signal-exit@4.1.0: {} @@ -8564,10 +9550,14 @@ snapshots: stable-hash@0.0.5: {} + stackback@0.0.2: {} + standard-as-callback@2.1.0: {} statuses@2.0.2: {} + std-env@4.1.0: {} + stdin-discarder@0.2.2: {} stop-iteration-iterator@1.1.0: @@ -8652,6 +9642,10 @@ snapshots: strip-final-newline@4.0.0: {} + strip-indent@3.0.0: + dependencies: + min-indent: 1.0.1 + strip-json-comments@3.1.1: {} styled-jsx@5.1.6(@babel/core@7.29.7)(react@19.2.4): @@ -8667,6 +9661,8 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} + symbol-tree@3.2.4: {} + systeminformation@5.31.9: {} tailwind-merge@3.6.0: {} @@ -8677,17 +9673,37 @@ snapshots: tiny-invariant@1.3.3: {} + tinybench@2.9.0: {} + + tinyexec@1.2.4: {} + tinyglobby@0.2.17: dependencies: fdir: 6.5.0(picomatch@4.0.4) picomatch: 4.0.4 + tinyrainbow@3.1.0: {} + + tldts-core@7.4.5: {} + + tldts@7.4.5: + dependencies: + tldts-core: 7.4.5 + to-regex-range@5.0.1: dependencies: is-number: 7.0.0 toidentifier@1.0.1: {} + tough-cookie@6.0.1: + dependencies: + tldts: 7.4.5 + + tr46@6.0.0: + dependencies: + punycode: 2.3.1 + ts-api-utils@2.5.0(typescript@5.9.3): dependencies: typescript: 5.9.3 @@ -8863,6 +9879,55 @@ snapshots: vary@1.1.2: {} + vite@8.1.0(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0): + dependencies: + lightningcss: 1.32.0 + picomatch: 4.0.4 + postcss: 8.5.15 + rolldown: 1.1.3 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 20.19.43 + esbuild: 0.28.1 + fsevents: 2.3.3 + jiti: 2.7.0 + tsx: 4.22.4 + yaml: 2.9.0 + + vitest@4.1.9(@opentelemetry/api@1.9.1)(@types/node@20.19.43)(@vitest/coverage-v8@4.1.9)(jsdom@29.1.1(@noble/hashes@2.2.0))(vite@8.1.0(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)): + dependencies: + '@vitest/expect': 4.1.9 + '@vitest/mocker': 4.1.9(vite@8.1.0(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + '@vitest/pretty-format': 4.1.9 + '@vitest/runner': 4.1.9 + '@vitest/snapshot': 4.1.9 + '@vitest/spy': 4.1.9 + '@vitest/utils': 4.1.9 + es-module-lexer: 2.2.0 + expect-type: 1.4.0 + magic-string: 0.30.21 + obug: 2.1.3 + pathe: 2.0.3 + picomatch: 4.0.4 + std-env: 4.1.0 + tinybench: 2.9.0 + tinyexec: 1.2.4 + tinyglobby: 0.2.17 + tinyrainbow: 3.1.0 + vite: 8.1.0(@types/node@20.19.43)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@opentelemetry/api': 1.9.1 + '@types/node': 20.19.43 + '@vitest/coverage-v8': 4.1.9(vitest@4.1.9) + jsdom: 29.1.1(@noble/hashes@2.2.0) + transitivePeerDependencies: + - msw + + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + web-push@3.6.7: dependencies: asn1.js: 5.4.1 @@ -8875,6 +9940,18 @@ snapshots: web-streams-polyfill@3.3.3: {} + webidl-conversions@8.0.1: {} + + whatwg-mimetype@5.0.0: {} + + whatwg-url@16.0.1(@noble/hashes@2.2.0): + dependencies: + '@exodus/bytes': 1.15.1(@noble/hashes@2.2.0) + tr46: 6.0.0 + webidl-conversions: 8.0.1 + transitivePeerDependencies: + - '@noble/hashes' + which-boxed-primitive@1.1.1: dependencies: is-bigint: 1.1.0 @@ -8924,6 +10001,11 @@ snapshots: dependencies: isexe: 3.1.5 + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + word-wrap@1.2.5: {} wrappy@1.0.2: {} @@ -8933,6 +10015,10 @@ snapshots: is-wsl: 3.1.1 powershell-utils: 0.1.0 + xml-name-validator@5.0.0: {} + + xmlchars@2.2.0: {} + yallist@3.1.1: {} yaml@2.9.0: {}
No ingredients or steps yet.
- {list.items.length} items{list.generatedAt ? " · Generated from meal plan" : ""} -
+ {list.items.length} items{list.generatedAt ? " · Generated from meal plan" : ""} +
{label}
{items.length} item{items.length !== 1 ? "s" : ""}
No items in pantry.
+ {list.items.filter(i => i.checked).length}/{list.items.length} items checked + {list.generatedAt ? " · From meal plan" : ""} +
- - {t("language")} -
{t("subtitle")}
{items.length} ingredient{items.length !== 1 ? "s" : ""} will be added.
{recipeTitle}
+ Ask anything about this recipe — ingredients, techniques, substitutions, timing… +
Press Enter to add · Backspace to remove last · max 20 tags
Loading versions...
{description}
- No trending recipes yet. Be the first to share one! -
No recipes found for “{query}”
Try different keywords or remove filters
- No public recipes yet. -
Tell the AI what you're in the mood for, or let it surprise you.
{idea.description}
+ No trending recipes yet. Be the first to share one! +
+ No public recipes yet. +
{t("publicBioDescription")}
{bio.length}/500
{t("privateBioDescription")}
{privateBio.length}/2000
{t("languageDescription")}