Compare commits

...

10 Commits

Author SHA1 Message Date
Arnaud 8b57a3fd87 Update features and dependencies 2026-07-01 11:10:37 +02:00
Arnaud 9d9dfb46c6 feat: misc — cooking mode, OpenAPI docs, email, storage, upload, homepage
Cooking mode step-by-step view. OpenAPI 3.1 spec auto-generated from Zod schemas.
Email lib (resend). Redis client. S3 storage helper. Photo upload endpoint.
Landing page. Short recipe URL redirect /r/[id]. API docs page.
2026-07-01 08:11:31 +02:00
Arnaud 3f96d1ea41 feat(webhooks): outbound webhooks with HMAC-SHA256 signing and API key auth
Webhook registration/management. HMAC-signed delivery with retry.
Events: recipe.created/updated, comment.created, follower.new.
REST API key creation for programmatic access.
2026-07-01 08:11:19 +02:00
Arnaud d6032edc00 feat(pwa): service worker, offline page, web push notifications
PWA manifest, sw.js with offline cache. VAPID web-push via lib/push.ts.
Push subscribe button (client). Push fired on recipe comment creation.
VAPID keys configurable via admin site settings.
2026-07-01 08:11:05 +02:00
Arnaud cba5d9c3ac feat(admin): admin panel with overview, users, audit logs, storage, AI config, settings
Sticky sidebar with back-to-app link. Overview: user counts, recipe photos, AI calls/month.
User management with role/tier editing. Audit log (all admin actions).
Storage page with photo breakdown by tier. AI config showing DB vs .env key source.
Site settings page to override .env values at runtime (encrypted in DB).
2026-07-01 08:10:59 +02:00
Arnaud b2d592afe8 feat(settings): sidebar layout with profile, security, AI, notifications, nutrition
Sticky sidebar nav. Sections: Profile (name/language), Security (email/password change),
AI & Models (BYOK keys + per-use-case model prefs), Notifications (push subscribe),
Nutrition goals. Sub-pages: API keys, Webhooks.
2026-07-01 08:10:51 +02:00
Arnaud e179692adf feat(search): full-text recipe search and explore page
Postgres full-text search with dietary/difficulty/time filters.
Search page with real-time results. Explore page with trending and recent recipes.
2026-07-01 08:10:44 +02:00
Arnaud 3636ab27ae feat(meal-plan): weekly planner, pantry, shopping lists, nutrition tracking
AI-generated weekly meal plans with pantry-awareness. Manual entry per slot.
Pantry inventory management. Auto-generated shopping lists from meal plan.
Weekly nutrition bar chart vs daily goals. Nutrition goals settings.
2026-07-01 08:10:39 +02:00
Arnaud 9d02a69250 feat(social): follows, favorites, comments, reactions, collections, public profiles
Follow/unfollow users. Recipe favorites. Threaded comments with emoji reactions.
Collections (public/private) with shared member invite. Activity feed.
Public profile pages at /u/[username].
2026-07-01 08:10:30 +02:00
Arnaud d9d58fd01a feat(ai): Vercel AI SDK provider factory with BYOK and per-use-case model prefs
Provider factory (OpenAI/Anthropic/OpenRouter/Ollama). generateObject for all outputs.
Features: recipe generation, photo-to-recipe vision, variations, drink/meal pairings,
ingredient substitution, recipe adaptation, nutrition analysis, URL import, meal plan gen.
User BYOK keys (AES-256-GCM). Per-use-case model preferences (text/vision/mealPlan).
Site-level key override via admin settings.
2026-07-01 08:10:19 +02:00
215 changed files with 25193 additions and 278 deletions
+64
View File
@@ -0,0 +1,64 @@
# Database
DATABASE_URL=postgresql://epicure:epicure@localhost:5432/epicure
# Redis
REDIS_URL=redis://localhost:6379
# Storage (MinIO / S3-compatible)
STORAGE_ENDPOINT=http://localhost:9000
STORAGE_ACCESS_KEY=minioadmin
STORAGE_SECRET_KEY=minioadmin
STORAGE_BUCKET=epicure-uploads
STORAGE_REGION=us-east-1
# Auth (generate with: openssl rand -base64 32)
BETTER_AUTH_SECRET=
BETTER_AUTH_URL=http://localhost:3000
# 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
SMTP_SECURE=false
SMTP_USER=
SMTP_PASS=
SMTP_FROM=Epicure <noreply@epicure.app>
# Stripe (optional — webhook stub only)
STRIPE_WEBHOOK_SECRET=
# AI Providers (configure at least one)
OPENROUTER_API_KEY=
OPENROUTER_DEFAULT_MODEL=google/gemini-flash-1.5
OPENAI_API_KEY=
ANTHROPIC_API_KEY=
OLLAMA_BASE_URL=http://localhost:11434
+41
View File
@@ -0,0 +1,41 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
+36
View File
@@ -0,0 +1,36 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
@@ -0,0 +1,62 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { headers } from "next/headers";
import { auth } from "@/lib/auth/server";
import { db, collections, eq, and, or } from "@epicure/db";
import { RecipeCard } from "@/components/recipe/recipe-card";
import { ForkCollectionButton } from "@/components/collections/fork-collection-button";
import { ShareCollectionButton } from "@/components/collections/share-collection-button";
type Params = { params: Promise<{ id: string }> };
export const metadata: Metadata = { title: "Collection" };
export default async function CollectionPage({ params }: Params) {
const { id } = await params;
const session = await auth.api.getSession({ headers: await headers() });
if (!session) return null;
const col = await db.query.collections.findFirst({
where: and(
eq(collections.id, id),
or(eq(collections.userId, session.user.id), eq(collections.isPublic, true))
),
with: { recipes: { with: { recipe: { with: { photos: true } } } } },
});
if (!col) notFound();
const isOwner = col.userId === session.user.id;
return (
<div className="space-y-6">
<div className="flex items-start justify-between gap-4">
<div>
<h1 className="text-3xl font-bold tracking-tight">{col.name}</h1>
{col.description && <p className="text-muted-foreground mt-1">{col.description}</p>}
<p className="text-sm text-muted-foreground mt-1">
{col.recipes.length} recipe{col.recipes.length !== 1 ? "s" : ""} · {col.isPublic ? "Public" : "Private"}
</p>
</div>
<div className="flex items-center gap-2">
{isOwner && <ShareCollectionButton collectionId={id} />}
{!isOwner && col.isPublic && (
<ForkCollectionButton collectionId={id} />
)}
</div>
</div>
{col.recipes.length === 0 ? (
<div className="flex items-center justify-center h-48 border-2 border-dashed rounded-xl">
<p className="text-muted-foreground text-sm">No recipes in this collection yet.</p>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
{col.recipes.map(({ recipe }) => (
recipe && <RecipeCard key={recipe.id} recipe={recipe} />
))}
</div>
)}
</div>
);
}
+30
View File
@@ -0,0 +1,30 @@
import type { Metadata } from "next";
import { headers } from "next/headers";
import { auth } from "@/lib/auth/server";
import { db, collections, eq, desc } from "@epicure/db";
import { CollectionsPageContent } from "@/components/collections/collections-page-content";
export const metadata: Metadata = { title: "Collections" };
export default async function CollectionsPage() {
const session = await auth.api.getSession({ headers: await headers() });
if (!session) return null;
const userCollections = await db.query.collections.findMany({
where: eq(collections.userId, session.user.id),
orderBy: desc(collections.updatedAt),
with: { recipes: { limit: 1 } },
});
return (
<CollectionsPageContent
collections={userCollections.map((col) => ({
id: col.id,
name: col.name,
description: col.description,
isPublic: col.isPublic,
recipeCount: col.recipes.length,
}))}
/>
);
}
+80
View File
@@ -0,0 +1,80 @@
import type { Metadata } from "next";
import {
db,
recipes,
users,
favorites,
eq,
desc,
sql,
and,
gte,
count,
} from "@epicure/db";
import { ExplorePageContent } from "@/components/search/explore-page-content";
export const metadata: Metadata = { title: "Explore — Epicure" };
export type RecipeResult = {
id: string;
title: string;
authorName: string | null;
difficulty: string | null;
prepMins: number | null;
cookMins: number | null;
};
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
const trendingRows = await db
.select({
id: recipes.id,
title: recipes.title,
authorName: users.name,
difficulty: recipes.difficulty,
prepMins: recipes.prepMins,
cookMins: recipes.cookMins,
favoriteCount: count(favorites.recipeId),
})
.from(recipes)
.innerJoin(users, eq(recipes.authorId, users.id))
.leftJoin(
favorites,
and(
eq(favorites.recipeId, recipes.id),
gte(favorites.createdAt, sevenDaysAgo)
)
)
.where(eq(recipes.visibility, "public"))
.groupBy(recipes.id, users.id)
.orderBy(desc(sql`count(${favorites.recipeId})`))
.limit(12);
// Recent: public recipes ordered by createdAt desc
const recentRows = await db
.select({
id: recipes.id,
title: recipes.title,
authorName: users.name,
difficulty: recipes.difficulty,
prepMins: recipes.prepMins,
cookMins: recipes.cookMins,
})
.from(recipes)
.innerJoin(users, eq(recipes.authorId, users.id))
.where(eq(recipes.visibility, "public"))
.orderBy(desc(recipes.createdAt))
.limit(12);
const trending: RecipeResult[] = trendingRows.map(({ favoriteCount: _fc, ...r }) => r);
const recent: RecipeResult[] = recentRows;
return <ExplorePageContent trending={trending} recent={recent} initialQuery={q ?? ""} />;
}
+54
View File
@@ -0,0 +1,54 @@
import type { Metadata } from "next";
import { headers } from "next/headers";
import { auth } from "@/lib/auth/server";
import { db, recipes, users, userFollows, eq, desc, inArray } from "@epicure/db";
import { getPublicUrl } from "@/lib/storage";
import { FeedPageContent } from "@/components/feed/feed-page-content";
export const metadata: Metadata = { title: "Feed" };
export default async function FeedPage() {
const session = await auth.api.getSession({ headers: await headers() });
if (!session) return null;
const followedRows = await db
.select({ followingId: userFollows.followingId })
.from(userFollows)
.where(eq(userFollows.followerId, session.user.id));
const followedIds = followedRows.map((r) => r.followingId);
if (followedIds.length === 0) {
return <FeedPageContent followedCount={0} feedRecipes={[]} />;
}
const feedRecipes = await db
.select({
id: recipes.id,
title: recipes.title,
description: recipes.description,
baseServings: recipes.baseServings,
prepMins: recipes.prepMins,
cookMins: recipes.cookMins,
difficulty: recipes.difficulty,
visibility: recipes.visibility,
aiGenerated: recipes.aiGenerated,
createdAt: recipes.createdAt,
authorId: recipes.authorId,
authorName: users.name,
authorUsername: users.username,
authorAvatarUrl: users.avatarUrl,
})
.from(recipes)
.innerJoin(users, eq(recipes.authorId, users.id))
.where(inArray(recipes.authorId, followedIds))
.orderBy(desc(recipes.createdAt))
.limit(40);
return (
<FeedPageContent
followedCount={followedIds.length}
feedRecipes={feedRecipes.map((r) => ({ ...r, createdAt: r.createdAt.toISOString() }))}
/>
);
}
+106
View File
@@ -0,0 +1,106 @@
import type { Metadata } from "next";
import { headers } from "next/headers";
import Link from "next/link";
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";
import { MealPlanner } from "@/components/meal-plan/meal-planner";
import { WeeklyNutritionBar } from "@/components/nutrition/weekly-nutrition-bar";
import { cn } from "@/lib/utils";
export const metadata: Metadata = { title: "Meal Plan" };
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;
}
function toDateStr(d: Date): string {
return d.toISOString().slice(0, 10);
}
function addWeeks(d: Date, n: number): Date {
const copy = new Date(d);
copy.setDate(copy.getDate() + n * 7);
return copy;
}
export default async function MealPlanPage({
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 = toDateStr(monday);
const prevWeek = toDateStr(addWeeks(monday, -1));
const nextWeek = toDateStr(addWeeks(monday, 1));
const sunday = addWeeks(monday, 1);
sunday.setDate(sunday.getDate() - 1);
const [plan, userRecipes] = await Promise.all([
db.query.mealPlans.findFirst({
where: and(eq(mealPlans.userId, session.user.id), eq(mealPlans.weekStart, weekStart)),
with: {
entries: {
with: { recipe: { with: { photos: true } } },
},
},
}),
db.query.recipes.findMany({
where: eq(recipes.authorId, session.user.id),
orderBy: desc(recipes.updatedAt),
columns: { id: true, title: true },
}),
]);
const entries = (plan?.entries ?? []).map((e) => ({
id: e.id,
day: e.day,
mealType: e.mealType,
servings: e.servings,
note: e.note,
recipe: e.recipe ? { id: e.recipe.id, title: e.recipe.title } : null,
}));
const label = `${monday.toLocaleDateString("en-US", { month: "short", day: "numeric" })} ${sunday.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })}`;
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight">Meal Plan</h1>
<p className="text-muted-foreground mt-1">{label}</p>
</div>
<div className="flex items-center gap-2">
<Link href="/shopping-lists" className={cn(buttonVariants({ variant: "outline", size: "sm" }))}>
<ShoppingCart className="h-4 w-4" />
Shopping lists
</Link>
<Link href={`/print/meal-plan?week=${weekStart}`} target="_blank" className={cn(buttonVariants({ variant: "outline", size: "sm" }))}>
<Printer className="h-4 w-4" />
Print
</Link>
<Link href={`/meal-plan?week=${prevWeek}`} className={cn(buttonVariants({ variant: "outline", size: "sm" }))}>
<ChevronLeft className="h-4 w-4" />
</Link>
<Link href={`/meal-plan?week=${nextWeek}`} className={cn(buttonVariants({ variant: "outline", size: "sm" }))}>
<ChevronRight className="h-4 w-4" />
</Link>
</div>
</div>
<WeeklyNutritionBar weekStart={weekStart} />
<MealPlanner weekStart={weekStart} initialEntries={entries} userRecipes={userRecipes} />
</div>
);
}
+31
View File
@@ -0,0 +1,31 @@
import type { Metadata } from "next";
import { headers } from "next/headers";
import { auth } from "@/lib/auth/server";
import { db, pantryItems, eq, asc } from "@epicure/db";
import { PantryManager } from "@/components/meal-plan/pantry-manager";
import { PantryPageHeader } from "@/components/pantry/pantry-page-header";
export const metadata: Metadata = { title: "Pantry" };
export default async function PantryPage() {
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),
});
return (
<div className="space-y-6">
<PantryPageHeader />
<PantryManager initialItems={items.map((i) => ({
id: i.id,
rawName: i.rawName,
quantity: i.quantity,
unit: i.unit,
expiresAt: i.expiresAt?.toISOString() ?? null,
}))} />
</div>
);
}
@@ -35,6 +35,7 @@ export default async function EditRecipePage({ params }: Params) {
difficulty: recipe.difficulty,
prepMins: recipe.prepMins,
cookMins: recipe.cookMins,
tags: recipe.tags ?? [],
dietaryTags: (recipe.dietaryTags as Record<string, boolean | undefined>) ?? {},
ingredients: recipe.ingredients.map((ing) => ({
id: ing.id,
+54 -14
View File
@@ -13,18 +13,21 @@ import { PrintButton } from "@/components/recipe/print-button";
import { VersionHistoryButton } from "@/components/recipe/version-history-button";
import { DeleteRecipeButton } from "@/components/recipe/delete-recipe-button";
import { NutritionPanel } from "@/components/recipe/nutrition-panel";
import { GenerateContentButton } from "@/components/recipe/generate-content-button";
import { auth } from "@/lib/auth/server";
import { db, recipes, ratings, favorites, avg } from "@epicure/db";
import { and, eq, count } from "@epicure/db";
import { Badge } from "@/components/ui/badge";
import { buttonVariants } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { ServingScaler } from "@/components/recipe/serving-scaler";
import { FavoriteButton } from "@/components/social/favorite-button";
import { RatingStars } from "@/components/social/rating-stars";
import { CommentsSection } from "@/components/social/comments-section";
import { getPublicUrl } from "@/lib/storage";
import { cn } from "@/lib/utils";
import { RecipeChatPanel } from "@/components/recipe/recipe-chat-panel";
type Params = { params: Promise<{ id: string }> };
@@ -86,14 +89,30 @@ export default async function RecipePage({ params }: Params) {
{/* Header */}
<div className="space-y-4">
<h1 className="text-3xl font-bold tracking-tight">{recipe.title}</h1>
<TooltipProvider>
<div className="flex items-center gap-2 overflow-x-auto pb-0.5 [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden">
{recipe.steps.length > 0 && (
<Tooltip>
<TooltipTrigger render={
<Link href={`/recipes/${id}/cook`} className={cn(buttonVariants({ variant: "ghost", size: "icon" }))}>
<Play className="h-4 w-4" />
</Link>
} />
<TooltipContent>Cook</TooltipContent>
</Tooltip>
)}
<FavoriteButton recipeId={id} initialFavorited={isFavorited} />
<MealPairingButton recipeId={id} />
<DrinkPairingButton recipeId={id} />
{recipe.visibility === "public" && (
<Link href={`/r/${id}`} target="_blank" className={cn(buttonVariants({ variant: "ghost", size: "sm" }))}>
<ExternalLink className="h-4 w-4" />
</Link>
<Tooltip>
<TooltipTrigger render={
<Link href={`/r/${id}`} target="_blank" className={cn(buttonVariants({ variant: "ghost", size: "icon" }))}>
<ExternalLink className="h-4 w-4" />
</Link>
} />
<TooltipContent>View publicly</TooltipContent>
</Tooltip>
)}
{recipe.ingredients.length > 0 && (
<AddToShoppingListButton
@@ -107,7 +126,9 @@ export default async function RecipePage({ params }: Params) {
}))}
/>
)}
<TranslateButton recipeId={id} />
{(!recipe.language || recipe.language !== (session.user as { locale?: string }).locale) && (
<TranslateButton recipeId={id} />
)}
{recipe.ingredients.length > 0 && (
<AdaptRecipeButton
recipeId={id}
@@ -133,20 +154,19 @@ export default async function RecipePage({ params }: Params) {
order: s.order,
}))}
/>
{recipe.steps.length > 0 && (
<Link href={`/recipes/${id}/cook`} className={cn(buttonVariants({ variant: "default", size: "sm" }))}>
<Play className="h-4 w-4" />
Cook
</Link>
)}
<PrintButton recipeId={id} />
<VersionHistoryButton recipeId={id} />
<Link href={`/recipes/${id}/edit`} className={cn(buttonVariants({ variant: "outline", size: "sm" }))}>
<Pencil className="h-4 w-4" />
Edit
</Link>
<Tooltip>
<TooltipTrigger render={
<Link href={`/recipes/${id}/edit`} className={cn(buttonVariants({ variant: "ghost", size: "icon" }))}>
<Pencil className="h-4 w-4" />
</Link>
} />
<TooltipContent>Edit</TooltipContent>
</Tooltip>
<DeleteRecipeButton recipeId={id} />
</div>
</TooltipProvider>
{avgScore !== null && (
<div className="flex items-center gap-2">
@@ -210,6 +230,24 @@ export default async function RecipePage({ params }: Params) {
<Separator />
{/* Empty state */}
{recipe.ingredients.length === 0 && recipe.steps.length === 0 && (
<div className="flex flex-col items-center gap-4 py-12 text-center">
<p className="text-muted-foreground">No ingredients or steps yet.</p>
<div className="flex items-center gap-2">
<GenerateContentButton
recipeId={id}
title={recipe.title}
description={recipe.description}
/>
<Link href={`/recipes/${id}/edit`} className={cn(buttonVariants({ variant: "outline", size: "sm" }))}>
<Pencil className="h-4 w-4" />
Edit manually
</Link>
</div>
</div>
)}
{/* Serving scaler + ingredients */}
{recipe.ingredients.length > 0 && (
<div className="space-y-4">
@@ -285,6 +323,8 @@ export default async function RecipePage({ params }: Params) {
<CommentsSection recipeId={id} currentUserId={session.user.id} />
</>
)}
<RecipeChatPanel recipeId={id} recipeTitle={recipe.title} />
</div>
);
}
+49 -22
View File
@@ -1,48 +1,75 @@
import type { Metadata } from "next";
import { headers } from "next/headers";
import { auth } from "@/lib/auth/server";
import { db, recipes, recipeIngredients } from "@epicure/db";
import { eq, desc, and, ilike, or, sql } from "@epicure/db";
import { db, recipes, sql } from "@epicure/db";
import { eq, desc, asc, and, ilike, or } from "@epicure/db";
import { RecipesHeader } from "@/components/recipe/recipes-header";
import { RecipesEmptyState } from "@/components/recipe/recipes-empty-state";
import { RecipesGrid } from "@/components/recipe/recipes-grid";
export const metadata: Metadata = { title: "Recipes" };
type SearchParams = Promise<{ q?: string }>;
type SearchParams = Promise<{
q?: string;
sort?: string;
visibility?: string;
difficulty?: string;
tag?: string;
}>;
const SORT_MAP = {
updated_desc: desc(recipes.updatedAt),
updated_asc: asc(recipes.updatedAt),
created_desc: desc(recipes.createdAt),
created_asc: asc(recipes.createdAt),
title_asc: asc(recipes.title),
title_desc: desc(recipes.title),
} as const;
type SortKey = keyof typeof SORT_MAP;
export default async function RecipesPage({ searchParams }: { searchParams: SearchParams }) {
const session = await auth.api.getSession({ headers: await headers() });
if (!session) return null;
const { q } = await searchParams;
const query = q?.trim() ?? "";
const { q, sort, visibility, difficulty, tag } = await searchParams;
const query = (q ?? "").trim().slice(0, 200);
const sortKey: SortKey = (sort && sort in SORT_MAP ? sort : "updated_desc") as SortKey;
const tagFilter = tag?.trim().slice(0, 50) || undefined;
const whereClause = query
? and(
eq(recipes.authorId, session.user.id),
or(
ilike(recipes.title, `%${query}%`),
ilike(recipes.description, `%${query}%`)
)
)
: eq(recipes.authorId, session.user.id);
const visibilityFilter = visibility && ["private", "unlisted", "public"].includes(visibility)
? (visibility as "private" | "unlisted" | "public")
: undefined;
const difficultyFilter = difficulty && ["easy", "medium", "hard"].includes(difficulty)
? (difficulty as "easy" | "medium" | "hard")
: undefined;
const userRecipes = await db.query.recipes.findMany({
where: whereClause,
orderBy: desc(recipes.updatedAt),
where: and(
eq(recipes.authorId, session.user.id),
query
? or(ilike(recipes.title, `%${query}%`), ilike(recipes.description, `%${query}%`))
: undefined,
visibilityFilter ? eq(recipes.visibility, visibilityFilter) : undefined,
difficultyFilter ? eq(recipes.difficulty, difficultyFilter) : undefined,
tagFilter ? sql`${recipes.tags} @> ARRAY[${tagFilter}]::text[]` : undefined,
),
orderBy: SORT_MAP[sortKey],
with: { photos: true },
});
const totalCount = query ? undefined : userRecipes.length;
return (
<div className="space-y-6">
<RecipesHeader count={totalCount ?? userRecipes.length} initialQuery={query} />
<RecipesHeader
count={userRecipes.length}
initialQuery={query}
initialSort={sortKey}
initialVisibility={visibilityFilter ?? ""}
initialDifficulty={difficultyFilter ?? ""}
initialTag={tagFilter ?? ""}
/>
<RecipesEmptyState query={query} count={userRecipes.length} />
<RecipesGrid recipes={userRecipes} />
<RecipesGrid key={`${query}-${sortKey}-${visibilityFilter}-${difficultyFilter}-${tagFilter}`} recipes={userRecipes} />
</div>
);
}
+8
View File
@@ -0,0 +1,8 @@
import { SearchPageContent } from "@/components/search/search-page-content";
type Params = { searchParams: Promise<{ q?: string; difficulty?: string; dietary?: string }> };
export default async function SearchPage({ searchParams }: Params) {
const { q } = await searchParams;
return <SearchPageContent initialQuery={q ?? ""} />;
}
+47
View File
@@ -0,0 +1,47 @@
import type { Metadata } from "next";
import { headers } from "next/headers";
import { auth } from "@/lib/auth/server";
import { db, userAiKeys, userModelPrefs, eq } from "@epicure/db";
import { ByokManager } from "@/components/settings/byok-manager";
import { ModelPrefsForm } from "@/components/settings/model-prefs-form";
export const metadata: Metadata = { title: "AI & Models Settings" };
export default async function AiSettingsPage() {
const session = await auth.api.getSession({ headers: await headers() });
if (!session) return null;
const [aiKeys, modelPrefs] = await Promise.all([
db.query.userAiKeys.findMany({
where: eq(userAiKeys.userId, session.user.id),
columns: { provider: true },
}),
db.query.userModelPrefs.findFirst({
where: eq(userModelPrefs.userId, session.user.id),
}),
]);
return (
<div className="space-y-8">
<section className="rounded-xl border p-6 space-y-4">
<div>
<h2 className="font-semibold text-lg">Your API Keys (BYOK)</h2>
<p className="text-sm text-muted-foreground mt-1">
Use your own API keys instead of the app&apos;s shared quota. Keys are encrypted at rest.
</p>
</div>
<ByokManager initialKeys={aiKeys.map((k) => k.provider)} />
</section>
<section className="rounded-xl border p-6 space-y-4">
<div>
<h2 className="font-semibold text-lg">Model Preferences</h2>
<p className="text-sm text-muted-foreground mt-1">
Choose which model to use for each task. Defaults to your first configured provider.
</p>
</div>
<ModelPrefsForm initialPrefs={modelPrefs ?? null} />
</section>
</div>
);
}
@@ -0,0 +1,43 @@
import type { Metadata } from "next";
import { headers } from "next/headers";
import { auth } from "@/lib/auth/server";
import { db, apiKeys, eq } from "@epicure/db";
import { ApiKeysManager } from "@/components/settings/api-keys-manager";
export const metadata: Metadata = { title: "API Keys" };
export default async function ApiKeysPage() {
const session = await auth.api.getSession({ headers: await headers() });
if (!session) return null;
const keys = await db
.select({
id: apiKeys.id,
name: apiKeys.name,
lastUsedAt: apiKeys.lastUsedAt,
createdAt: apiKeys.createdAt,
})
.from(apiKeys)
.where(eq(apiKeys.userId, session.user.id));
return (
<div className="space-y-8">
<section className="rounded-xl border p-6 space-y-4">
<div>
<h2 className="font-semibold text-lg">API Keys</h2>
<p className="text-sm text-muted-foreground mt-1">
Manage API keys for programmatic access to the Epicure API.
</p>
</div>
<ApiKeysManager
initialKeys={keys.map((k) => ({
id: k.id,
name: k.name,
lastUsedAt: k.lastUsedAt ? k.lastUsedAt.toISOString() : null,
createdAt: k.createdAt.toISOString(),
}))}
/>
</section>
</div>
);
}
+16
View File
@@ -0,0 +1,16 @@
import { SettingsSidebar } from "@/components/settings/settings-sidebar";
export default function SettingsLayout({ children }: { children: React.ReactNode }) {
return (
<div className="max-w-5xl mx-auto">
<div className="mb-8">
<h1 className="text-3xl font-bold tracking-tight">Settings</h1>
<p className="text-muted-foreground mt-1">Manage your account, preferences, and integrations.</p>
</div>
<div className="flex gap-8 items-start">
<SettingsSidebar />
<main className="flex-1 min-w-0 space-y-6">{children}</main>
</div>
</div>
);
}
@@ -0,0 +1,20 @@
import type { Metadata } from "next";
import { PushSubscribeButton } from "@/components/pwa/push-subscribe-button";
export const metadata: Metadata = { title: "Notifications Settings" };
export default function NotificationsPage() {
return (
<div className="space-y-8">
<section className="rounded-xl border p-6 space-y-4">
<div>
<h2 className="font-semibold text-lg">Push Notifications</h2>
<p className="text-sm text-muted-foreground mt-1">
Get notified when someone comments on your recipes or likes your content.
</p>
</div>
<PushSubscribeButton />
</section>
</div>
);
}
@@ -0,0 +1,41 @@
import type { Metadata } from "next";
import { headers } from "next/headers";
import { auth } from "@/lib/auth/server";
import { db, userNutritionGoals, eq } from "@epicure/db";
import { NutritionGoalsForm } from "@/components/nutrition/nutrition-goals-form";
export const metadata: Metadata = { title: "Nutrition Settings" };
export default async function NutritionPage() {
const session = await auth.api.getSession({ headers: await headers() });
if (!session) return null;
const goals = await db.query.userNutritionGoals.findFirst({
where: eq(userNutritionGoals.userId, session.user.id),
});
return (
<div className="space-y-8">
<section className="rounded-xl border p-6 space-y-4">
<div>
<h2 className="font-semibold text-lg">Daily Nutrition Goals</h2>
<p className="text-sm text-muted-foreground mt-1">
Set your daily targets. These are shown as progress bars on your meal plan.
</p>
</div>
<NutritionGoalsForm
initialGoals={
goals
? {
caloriesKcal: goals.caloriesKcal,
proteinG: goals.proteinG,
carbsG: goals.carbsG,
fatG: goals.fatG,
}
: null
}
/>
</section>
</div>
);
}
+30
View File
@@ -0,0 +1,30 @@
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" };
export default async function SettingsPage() {
const session = await auth.api.getSession({ headers: await headers() });
if (!session) return null;
const dbUser = await db.query.users.findFirst({
where: eq(users.id, session.user.id),
columns: { bio: true, privateBio: true },
});
return (
<SettingsForm
user={{
name: session.user.name,
email: session.user.email,
image: session.user.image ?? null,
locale: (session.user as { locale?: string }).locale ?? "en",
bio: dbUser?.bio ?? null,
privateBio: dbUser?.privateBio ?? null,
}}
/>
);
}
@@ -0,0 +1,13 @@
import type { Metadata } from "next";
import { headers } from "next/headers";
import { auth } from "@/lib/auth/server";
import { SecurityForm } from "@/components/settings/security-form";
export const metadata: Metadata = { title: "Security Settings" };
export default async function SecurityPage() {
const session = await auth.api.getSession({ headers: await headers() });
if (!session) return null;
return <SecurityForm currentEmail={session.user.email} />;
}
@@ -0,0 +1,142 @@
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Webhook Docs — Epicure",
};
export default function WebhookDocsPage() {
return (
<div className="max-w-3xl mx-auto space-y-10 py-8">
<div>
<h1 className="text-2xl font-bold mb-2">Webhook Documentation</h1>
<p className="text-muted-foreground">
Learn how to integrate Epicure webhooks with your own tools, Zapier, and Make.
</p>
</div>
{/* Section 1: Webhook Events */}
<section>
<h2 className="text-xl font-semibold mb-4">Webhook Events</h2>
<div className="overflow-x-auto">
<table className="w-full border-collapse">
<thead>
<tr className="bg-muted">
<th className="text-left p-3 border border-border font-medium">Event</th>
<th className="text-left p-3 border border-border font-medium">Description</th>
<th className="text-left p-3 border border-border font-medium">Payload Fields</th>
</tr>
</thead>
<tbody>
<tr>
<td className="p-3 border border-border font-mono text-sm">recipe.created</td>
<td className="p-3 border border-border text-sm">Fired when a recipe is created</td>
<td className="p-3 border border-border font-mono text-sm text-muted-foreground">id, title, authorId</td>
</tr>
<tr>
<td className="p-3 border border-border font-mono text-sm">recipe.updated</td>
<td className="p-3 border border-border text-sm">Fired when a recipe is updated</td>
<td className="p-3 border border-border font-mono text-sm text-muted-foreground">id, title, authorId</td>
</tr>
<tr>
<td className="p-3 border border-border font-mono text-sm">recipe.published</td>
<td className="p-3 border border-border text-sm">Fired when recipe visibility becomes public</td>
<td className="p-3 border border-border font-mono text-sm text-muted-foreground">id, title, authorId</td>
</tr>
<tr>
<td className="p-3 border border-border font-mono text-sm">recipe.deleted</td>
<td className="p-3 border border-border text-sm">Fired when a recipe is deleted</td>
<td className="p-3 border border-border font-mono text-sm text-muted-foreground">id, title</td>
</tr>
<tr>
<td className="p-3 border border-border font-mono text-sm">meal_plan.updated</td>
<td className="p-3 border border-border text-sm">Fired when a meal plan entry changes</td>
<td className="p-3 border border-border font-mono text-sm text-muted-foreground">weekStart, day, mealType</td>
</tr>
<tr>
<td className="p-3 border border-border font-mono text-sm">shopping_list.completed</td>
<td className="p-3 border border-border text-sm">Fired when shopping list marked complete</td>
<td className="p-3 border border-border font-mono text-sm text-muted-foreground">id, name</td>
</tr>
<tr>
<td className="p-3 border border-border font-mono text-sm">comment.added</td>
<td className="p-3 border border-border text-sm">Fired when a comment is added to your recipe</td>
<td className="p-3 border border-border font-mono text-sm text-muted-foreground">commentId, recipeId, recipeTitle</td>
</tr>
</tbody>
</table>
</div>
</section>
{/* Section 2: Payload Format */}
<section>
<h2 className="text-xl font-semibold mb-4">Payload Format</h2>
<p className="text-sm text-muted-foreground mb-3">
Every webhook request is a POST with a JSON body in the following shape:
</p>
<pre className="bg-muted rounded-lg p-4 overflow-x-auto font-mono text-sm whitespace-pre">{`{
"event": "recipe.created",
"payload": { "id": "...", "title": "Pasta Carbonara", "authorId": "..." },
"timestamp": "2024-01-15T10:30:00.000Z"
}`}</pre>
</section>
{/* Section 3: Signature Verification */}
<section>
<h2 className="text-xl font-semibold mb-4">Signature Verification</h2>
<p className="text-sm text-muted-foreground mb-4">
Every request includes an{" "}
<code className="font-mono bg-muted px-1.5 py-0.5 rounded text-xs">X-Epicure-Signature</code>{" "}
header containing an HMAC-SHA256 digest of the raw request body, signed with your webhook
secret. Always verify this signature before processing the payload.
</p>
<p className="text-sm font-medium mb-2">TypeScript / Node.js</p>
<pre className="bg-muted rounded-lg p-4 overflow-x-auto font-mono text-sm whitespace-pre mb-4">{`import crypto from "crypto";
function verify(body: string, secret: string, signature: string): boolean {
const expected = crypto.createHmac("sha256", secret).update(body).digest("hex");
return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
}`}</pre>
<p className="text-sm font-medium mb-2">Python</p>
<pre className="bg-muted rounded-lg p-4 overflow-x-auto font-mono text-sm whitespace-pre">{`import hmac, hashlib
def verify(body: bytes, secret: str, signature: str) -> bool:
expected = hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
return hmac.compare_digest(signature, expected)`}</pre>
</section>
{/* Section 4: Zapier Integration */}
<section>
<h2 className="text-xl font-semibold mb-4">Zapier Integration</h2>
<ol className="list-decimal pl-6 space-y-2 text-sm mb-6">
<li>Go to <span className="font-medium">zapier.com</span> and create a new Zap.</li>
<li>Choose <span className="font-medium">Webhooks by Zapier</span> as the trigger and select <span className="font-medium">Catch Hook</span>.</li>
<li>Copy the webhook URL provided by Zapier.</li>
<li>Go to <span className="font-medium">Epicure Settings Webhooks</span> and add that URL.</li>
<li>Test the trigger in Zapier to confirm it receives the payload.</li>
</ol>
<h3 className="text-base font-semibold mb-3">Example workflows</h3>
<ul className="list-disc pl-6 space-y-2 text-sm text-muted-foreground">
<li><span className="font-medium text-foreground">New Recipe Published</span> Add row to Notion database</li>
<li><span className="font-medium text-foreground">Shopping List Completed</span> Send Slack message to #groceries</li>
<li><span className="font-medium text-foreground">Comment Added</span> Send email notification via Gmail</li>
</ul>
</section>
{/* Section 5: Make (Integromat) */}
<section>
<h2 className="text-xl font-semibold mb-4">Make (Integromat)</h2>
<ol className="list-decimal pl-6 space-y-2 text-sm">
<li>Go to <span className="font-medium">make.com</span> and create a new scenario.</li>
<li>Add an <span className="font-medium">HTTP &gt; Watch for Incoming Webhooks</span> module as the trigger.</li>
<li>Copy the generated webhook URL from the module settings.</li>
<li>Go to <span className="font-medium">Epicure Settings Webhooks</span> and paste that URL.</li>
<li>Run the scenario and trigger a test event in Epicure to verify the connection.</li>
<li>Add downstream modules (Notion, Slack, Gmail, etc.) to act on the incoming data.</li>
</ol>
</section>
</div>
);
}
@@ -0,0 +1,45 @@
import type { Metadata } from "next";
import { headers } from "next/headers";
import { auth } from "@/lib/auth/server";
import { db, webhooks, eq } from "@epicure/db";
import { WebhooksManager } from "@/components/settings/webhooks-manager";
export const metadata: Metadata = { title: "Webhooks" };
export default async function WebhooksPage() {
const session = await auth.api.getSession({ headers: await headers() });
if (!session) return null;
const rows = await db
.select({
id: webhooks.id,
url: webhooks.url,
events: webhooks.events,
active: webhooks.active,
createdAt: webhooks.createdAt,
})
.from(webhooks)
.where(eq(webhooks.userId, session.user.id));
return (
<div className="space-y-8">
<section className="rounded-xl border p-6 space-y-4">
<div>
<h2 className="font-semibold text-lg">Webhooks</h2>
<p className="text-sm text-muted-foreground mt-1">
Receive HTTP callbacks when events happen in your Epicure account.
</p>
</div>
<WebhooksManager
initialWebhooks={rows.map((w) => ({
id: w.id,
url: w.url,
events: w.events,
active: w.active,
createdAt: w.createdAt.toISOString(),
}))}
/>
</section>
</div>
);
}
@@ -0,0 +1,55 @@
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 }> };
export const metadata: Metadata = { title: "Shopping List" };
export default async function ShoppingListPage({ 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();
return (
<div className="space-y-6 max-w-lg">
<div className="flex items-start justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight">{list.name}</h1>
<p className="text-muted-foreground mt-1">
{list.items.length} items{list.generatedAt ? " · Generated from meal plan" : ""}
</p>
</div>
<Link href={`/print/shopping-list/${id}`} target="_blank" className={cn(buttonVariants({ variant: "outline", size: "sm" }))}>
<Printer className="h-4 w-4" />
Print
</Link>
</div>
<ShoppingListView
listId={id}
initialItems={list.items.map((i) => ({
id: i.id,
rawName: i.rawName,
quantity: i.quantity,
unit: i.unit,
aisle: i.aisle,
checked: i.checked,
}))}
/>
</div>
);
}
@@ -0,0 +1,30 @@
import type { Metadata } from "next";
import { headers } from "next/headers";
import { auth } from "@/lib/auth/server";
import { db, shoppingLists, eq, desc } from "@epicure/db";
import { ShoppingListsPageContent } from "@/components/shopping-lists/shopping-lists-page-content";
export const metadata: Metadata = { title: "Shopping Lists" };
export default async function ShoppingListsPage() {
const session = await auth.api.getSession({ headers: await headers() });
if (!session) return null;
const lists = await db.query.shoppingLists.findMany({
where: eq(shoppingLists.userId, session.user.id),
orderBy: desc(shoppingLists.createdAt),
with: { items: { columns: { id: true, checked: true } } },
});
return (
<ShoppingListsPageContent
lists={lists.map((list) => ({
id: list.id,
name: list.name,
generatedAt: list.generatedAt ? list.generatedAt.toISOString() : null,
totalItems: list.items.length,
checkedItems: list.items.filter((i) => i.checked).length,
}))}
/>
);
}
+165
View File
@@ -0,0 +1,165 @@
import { notFound } from "next/navigation";
import { headers } from "next/headers";
import Link from "next/link";
import { auth } from "@/lib/auth/server";
import {
db,
users,
recipes,
userFollows,
eq,
and,
desc,
count,
} from "@epicure/db";
import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { FollowButton } from "@/components/social/follow-button";
import { getPublicUrl } from "@/lib/storage";
type Params = { params: Promise<{ username: string }> };
export async function generateMetadata({ params }: Params) {
const { username } = await params;
const user = await db.query.users.findFirst({ where: eq(users.username, username) });
return { title: user ? `${user.name} (@${user.username})` : "Profile" };
}
export default async function UserProfilePage({ params }: Params) {
const { username } = await params;
const session = await auth.api.getSession({ headers: await headers() });
const user = await db.query.users.findFirst({ where: eq(users.username, username) });
if (!user) notFound();
const [followerCountRow, followingCountRow, recipeCountRow, publicRecipes] = await Promise.all([
db
.select({ count: count() })
.from(userFollows)
.where(eq(userFollows.followingId, user.id)),
db
.select({ count: count() })
.from(userFollows)
.where(eq(userFollows.followerId, user.id)),
db
.select({ count: count() })
.from(recipes)
.where(and(eq(recipes.authorId, user.id), eq(recipes.visibility, "public"))),
db.query.recipes.findMany({
where: and(eq(recipes.authorId, user.id), eq(recipes.visibility, "public")),
orderBy: desc(recipes.createdAt),
limit: 24,
with: {
photos: { orderBy: (t, { asc }) => asc(t.order), limit: 1 },
},
}),
]);
const followerCount = followerCountRow[0]?.count ?? 0;
const followingCount = followingCountRow[0]?.count ?? 0;
const recipeCount = recipeCountRow[0]?.count ?? 0;
let isFollowing = false;
const isOwnProfile = session?.user.id === user.id;
if (session && !isOwnProfile) {
const followRow = await db.query.userFollows.findFirst({
where: and(
eq(userFollows.followerId, session.user.id),
eq(userFollows.followingId, user.id),
),
});
isFollowing = !!followRow;
}
const initials = user.name
.split(" ")
.slice(0, 2)
.map((w) => w[0])
.join("")
.toUpperCase();
return (
<div className="max-w-4xl mx-auto space-y-10">
{/* Profile header */}
<div className="flex flex-col sm:flex-row gap-6 items-start sm:items-center">
<Avatar className="h-24 w-24 shrink-0">
{user.avatarUrl && <AvatarImage src={user.avatarUrl} alt={user.name} />}
<AvatarFallback className="text-2xl">{initials}</AvatarFallback>
</Avatar>
<div className="flex-1 space-y-3">
<div className="flex flex-wrap items-center gap-3">
<div>
<h1 className="text-2xl font-bold leading-tight">{user.name}</h1>
<p className="text-muted-foreground text-sm">@{user.username}</p>
</div>
{!isOwnProfile && session && (
<FollowButton
targetUserId={user.id}
targetUsername={user.username!}
initialFollowing={isFollowing}
/>
)}
</div>
{user.bio && (
<p className="text-sm leading-relaxed max-w-prose">{user.bio}</p>
)}
<div className="flex flex-wrap gap-2">
<Badge variant="secondary">
<span className="font-semibold mr-1">{recipeCount}</span> recipes
</Badge>
<Badge variant="secondary">
<span className="font-semibold mr-1">{followerCount}</span> followers
</Badge>
<Badge variant="secondary">
<span className="font-semibold mr-1">{followingCount}</span> following
</Badge>
</div>
</div>
</div>
{/* Recipe grid */}
{publicRecipes.length > 0 ? (
<div className="space-y-4">
<h2 className="text-xl font-semibold">Recipes</h2>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-4">
{publicRecipes.map((recipe) => {
const cover = recipe.photos[0];
return (
<Link
key={recipe.id}
href={`/r/${recipe.id}`}
className="group block rounded-xl overflow-hidden border bg-card hover:shadow-md transition-shadow"
>
<div className="aspect-square bg-muted overflow-hidden">
{cover ? (
<img
src={getPublicUrl(cover.storageKey)}
alt={recipe.title}
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-200"
/>
) : (
<div className="w-full h-full flex items-center justify-center text-muted-foreground text-3xl">
🍽
</div>
)}
</div>
<div className="p-2">
<p className="text-sm font-medium leading-tight line-clamp-2">{recipe.title}</p>
</div>
</Link>
);
})}
</div>
</div>
) : (
<div className="text-center py-16 text-muted-foreground">
<p className="text-lg">No public recipes yet.</p>
</div>
)}
</div>
);
}
+65 -7
View File
@@ -53,10 +53,6 @@ export default function LoginPage() {
}
}
async function handleGoogle() {
await authClient.signIn.social({ provider: "google", callbackURL: "/recipes" });
}
return (
<Card>
<CardHeader className="space-y-1">
@@ -65,9 +61,15 @@ export default function LoginPage() {
</CardHeader>
<form onSubmit={handleSubmit}>
<CardContent className="space-y-4">
<Button variant="outline" className="w-full" type="button" onClick={handleGoogle}>
{t("continueWithGoogle")}
</Button>
<div className="space-y-2">
<Button variant="outline" className="w-full gap-2" type="button" onClick={() => authClient.signIn.social({ provider: "google", callbackURL: "/recipes" })}>
<GoogleIcon />
{t("continueWithGoogle")}
</Button>
<SocialButton provider="github" label={t("continueWithGithub")} icon={<GithubIcon />} />
<SocialButton provider="discord" label={t("continueWithDiscord")} icon={<DiscordIcon />} />
<AuthentikButton label={t("continueWithAuthentik")} />
</div>
<div className="flex items-center gap-2">
<Separator className="flex-1" />
<span className="text-xs text-muted-foreground">{t("or")}</span>
@@ -118,3 +120,59 @@ export default function LoginPage() {
</Card>
);
}
function SocialButton({ provider, label, icon }: { provider: "github" | "discord"; label: string; icon: React.ReactNode }) {
const envKey = provider === "github" ? process.env["NEXT_PUBLIC_GITHUB_ENABLED"] : process.env["NEXT_PUBLIC_DISCORD_ENABLED"];
if (!envKey) return null;
return (
<Button variant="outline" className="w-full gap-2" type="button" onClick={() => authClient.signIn.social({ provider, callbackURL: "/recipes" })}>
{icon}
{label}
</Button>
);
}
function AuthentikButton({ label }: { label: string }) {
if (!process.env["NEXT_PUBLIC_AUTHENTIK_ENABLED"]) return null;
return (
<Button variant="outline" className="w-full gap-2" type="button" onClick={() => (authClient as { signIn: { genericOAuth: (opts: { providerId: string; callbackURL: string }) => Promise<void> } }).signIn.genericOAuth({ providerId: "authentik", callbackURL: "/recipes" })}>
<AuthentikIcon />
{label}
</Button>
);
}
function GoogleIcon() {
return (
<svg viewBox="0 0 24 24" className="h-4 w-4" aria-hidden="true">
<path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" fill="#4285F4"/>
<path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853"/>
<path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" fill="#FBBC05"/>
<path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335"/>
</svg>
);
}
function GithubIcon() {
return (
<svg viewBox="0 0 24 24" className="h-4 w-4 fill-current" aria-hidden="true">
<path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0 0 24 12c0-6.63-5.37-12-12-12z" />
</svg>
);
}
function DiscordIcon() {
return (
<svg viewBox="0 0 24 24" className="h-4 w-4 fill-current" aria-hidden="true">
<path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057.1 18.08.11 18.1.128 18.11a19.9 19.9 0 0 0 5.993 3.03.077.077 0 0 0 .084-.028 14.09 14.09 0 0 0 1.226-1.994.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03z" />
</svg>
);
}
function AuthentikIcon() {
return (
<svg viewBox="0 0 24 24" className="h-4 w-4" stroke="currentColor" strokeWidth="2" fill="none" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5" />
</svg>
);
}
+108
View File
@@ -0,0 +1,108 @@
import type { Metadata } from "next";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { CheckCircle, XCircle, Database, Cpu } from "lucide-react";
import Link from "next/link";
import { getAllSiteSettings } from "@/lib/site-settings";
export const metadata: Metadata = { title: "AI Config" };
function resolveProvider(settings: Record<string, { value: string | null }>) {
if (settings["OPENROUTER_API_KEY"]?.value) return { provider: "OpenRouter", description: "Routes to many models via openrouter.ai" };
if (settings["OPENAI_API_KEY"]?.value) return { provider: "OpenAI", description: "Direct OpenAI API (GPT-4 etc.)" };
if (settings["ANTHROPIC_API_KEY"]?.value) return { provider: "Anthropic", description: "Direct Anthropic API (Claude models)" };
const url = settings["OLLAMA_BASE_URL"]?.value || "http://localhost:11434";
return { provider: "Ollama", description: `Local inference at ${url}` };
}
export default async function AdminAiConfigPage() {
const settings = await getAllSiteSettings();
const active = resolveProvider(settings);
const keyRows = [
{ key: "OPENROUTER_API_KEY", label: "OPENROUTER_API_KEY" },
{ key: "OPENAI_API_KEY", label: "OPENAI_API_KEY" },
{ key: "ANTHROPIC_API_KEY", label: "ANTHROPIC_API_KEY" },
];
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold tracking-tight">AI Configuration</h1>
<p className="text-muted-foreground text-sm mt-1">
Current AI provider settings. Override values in{" "}
<Link href="/admin/settings" className="text-primary underline-offset-4 hover:underline">
Site Settings
</Link>
.
</p>
</div>
<Card>
<CardHeader>
<CardTitle className="text-base">Active Provider</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center gap-3">
<Badge className="text-sm px-3 py-1">{active.provider}</Badge>
<span className="text-muted-foreground text-sm">{active.description}</span>
</div>
<p className="text-xs text-muted-foreground mt-3">
Priority: OpenRouter OpenAI Anthropic Ollama (first configured key wins).
</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">API Keys</CardTitle>
</CardHeader>
<CardContent>
{keyRows.map(({ key, label }) => {
const meta = settings[key];
const present = !!meta?.value;
return (
<div key={key} className="flex items-center justify-between py-2 border-b last:border-0">
<div className="flex items-center gap-2">
{present ? (
<CheckCircle className="h-4 w-4 text-green-500" />
) : (
<XCircle className="h-4 w-4 text-muted-foreground" />
)}
<span className="font-mono text-sm">{label}</span>
</div>
<div className="flex items-center gap-2">
{present && meta?.fromDb && (
<span className="flex items-center gap-1 text-xs text-muted-foreground">
<Database className="h-3 w-3" /> DB override
</span>
)}
{present && !meta?.fromDb && (
<span className="flex items-center gap-1 text-xs text-muted-foreground">
<Cpu className="h-3 w-3" /> from .env
</span>
)}
<Badge variant={present ? "default" : "secondary"}>
{present ? "Configured" : "Not set"}
</Badge>
</div>
</div>
);
})}
<div className="flex items-center justify-between py-2 border-b">
<span className="font-mono text-sm text-muted-foreground">OLLAMA_BASE_URL</span>
<span className="text-sm text-muted-foreground font-mono">
{settings["OLLAMA_BASE_URL"]?.value || "http://localhost:11434 (default)"}
</span>
</div>
<div className="flex items-center justify-between py-2">
<span className="font-mono text-sm text-muted-foreground">OPENROUTER_DEFAULT_MODEL</span>
<span className="text-sm text-muted-foreground font-mono">
{settings["OPENROUTER_DEFAULT_MODEL"]?.value || "google/gemini-flash-1.5 (default)"}
</span>
</div>
</CardContent>
</Card>
</div>
);
}
+114
View File
@@ -0,0 +1,114 @@
import type { Metadata } from "next";
import { db, auditLogs, users, eq, desc } from "@epicure/db";
export const metadata: Metadata = { title: "Audit Logs" };
const PAGE_SIZE = 50;
interface PageProps {
searchParams: Promise<{ page?: string }>;
}
export default async function AdminAuditLogsPage({ searchParams }: PageProps) {
const { page: pageParam } = await searchParams;
const page = Math.max(1, parseInt(pageParam ?? "1", 10));
const offset = (page - 1) * PAGE_SIZE;
const rows = await db
.select({
id: auditLogs.id,
action: auditLogs.action,
targetType: auditLogs.targetType,
targetId: auditLogs.targetId,
metadata: auditLogs.metadata,
createdAt: auditLogs.createdAt,
actorName: users.name,
actorEmail: users.email,
})
.from(auditLogs)
.leftJoin(users, eq(auditLogs.userId, users.id))
.orderBy(desc(auditLogs.createdAt))
.limit(PAGE_SIZE)
.offset(offset);
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold tracking-tight">Audit Logs</h1>
<p className="text-muted-foreground text-sm mt-1">
Admin actions recorded for accountability. Page {page}.
</p>
</div>
<div className="rounded-md border overflow-x-auto">
<table className="w-full text-sm">
<thead className="border-b bg-muted/50">
<tr>
<th className="px-4 py-3 text-left font-medium whitespace-nowrap">Actor</th>
<th className="px-4 py-3 text-left font-medium whitespace-nowrap">Action</th>
<th className="px-4 py-3 text-left font-medium whitespace-nowrap">Target</th>
<th className="px-4 py-3 text-left font-medium whitespace-nowrap">Metadata</th>
<th className="px-4 py-3 text-left font-medium whitespace-nowrap">Timestamp</th>
</tr>
</thead>
<tbody>
{rows.length === 0 ? (
<tr>
<td colSpan={5} className="px-4 py-8 text-center text-muted-foreground">
No audit log entries yet.
</td>
</tr>
) : (
rows.map((row) => (
<tr key={row.id} className="border-b last:border-0 hover:bg-muted/30">
<td className="px-4 py-3">
<div className="font-medium">{row.actorName ?? "System"}</div>
{row.actorEmail && (
<div className="text-xs text-muted-foreground">{row.actorEmail}</div>
)}
</td>
<td className="px-4 py-3 font-mono text-xs">{row.action}</td>
<td className="px-4 py-3 text-muted-foreground text-xs">
{row.targetType ? (
<span>
{row.targetType}
{row.targetId ? `: ${row.targetId.slice(0, 8)}` : ""}
</span>
) : (
"—"
)}
</td>
<td className="px-4 py-3 font-mono text-xs text-muted-foreground max-w-xs truncate">
{row.metadata ?? "—"}
</td>
<td className="px-4 py-3 text-muted-foreground whitespace-nowrap">
{row.createdAt.toLocaleString()}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
<div className="flex gap-2">
{page > 1 && (
<a
href={`/admin/audit-logs?page=${page - 1}`}
className="rounded-md border px-3 py-1 text-sm hover:bg-accent"
>
Previous
</a>
)}
{rows.length === PAGE_SIZE && (
<a
href={`/admin/audit-logs?page=${page + 1}`}
className="rounded-md border px-3 py-1 text-sm hover:bg-accent"
>
Next
</a>
)}
</div>
</div>
);
}
+61
View File
@@ -0,0 +1,61 @@
import { redirect } from "next/navigation";
import { headers } from "next/headers";
import { auth } from "@/lib/auth/server";
import { db, users, eq } from "@epicure/db";
import Link from "next/link";
import { Shield, Users, BookOpen, Settings, BarChart3, ClipboardList, HardDrive, Bot, ArrowLeft } from "lucide-react";
import { cn } from "@/lib/utils";
const adminNav = [
{ href: "/admin", label: "Overview", icon: BarChart3 },
{ href: "/admin/users", label: "Users", icon: Users },
{ href: "/admin/recipes", label: "Recipes", icon: BookOpen },
{ href: "/admin/audit-logs", label: "Audit Logs", icon: ClipboardList },
{ href: "/admin/storage", label: "Storage", icon: HardDrive },
{ href: "/admin/ai-config", label: "AI Config", icon: Bot },
{ href: "/admin/settings", label: "Settings", icon: Settings },
];
export default async function AdminLayout({ children }: { children: React.ReactNode }) {
const session = await auth.api.getSession({ headers: await headers() });
if (!session) redirect("/login");
const [dbUser] = await db.select({ role: users.role }).from(users).where(eq(users.id, session.user.id));
if (dbUser?.role !== "admin") redirect("/recipes");
return (
<div className="flex min-h-screen">
<aside className="w-56 border-r bg-muted/30 flex flex-col sticky top-0 h-screen">
<div className="flex items-center gap-2 p-4 border-b font-semibold">
<Shield className="h-4 w-4 text-destructive" />
Admin
</div>
<nav className="flex flex-col gap-1 p-2 flex-1">
{adminNav.map(({ href, label, icon: Icon }) => (
<Link
key={href}
href={href}
className={cn(
"flex items-center gap-2 rounded-md px-3 py-2 text-sm transition-colors",
"hover:bg-accent hover:text-accent-foreground"
)}
>
<Icon className="h-4 w-4" />
{label}
</Link>
))}
</nav>
<div className="p-2 border-t">
<Link
href="/recipes"
className="flex items-center gap-2 rounded-md px-3 py-2 text-sm text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
>
<ArrowLeft className="h-4 w-4" />
Back to app
</Link>
</div>
</aside>
<main className="flex-1 p-8 overflow-auto">{children}</main>
</div>
);
}
+51
View File
@@ -0,0 +1,51 @@
import type { Metadata } from "next";
import { db } from "@epicure/db";
import { users, recipes, userUsage, recipePhotos } from "@epicure/db";
import { count, eq, sql } from "@epicure/db";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Users, BookOpen, Sparkles, Image } from "lucide-react";
export const metadata: Metadata = { title: "Admin Overview" };
export default async function AdminPage() {
const currentMonth = new Date().toISOString().slice(0, 7);
const [userCount, recipeCount, proUserCount, aiUsage, imageCount] = await Promise.all([
db.select({ count: count() }).from(users).then((r) => r[0]),
db.select({ count: count() }).from(recipes).then((r) => r[0]),
db.select({ count: count() }).from(users).where(eq(users.tier, "pro")).then((r) => r[0]),
db
.select({ total: sql<string>`coalesce(sum(${userUsage.aiCallsUsed}), 0)` })
.from(userUsage)
.where(eq(userUsage.month, currentMonth))
.then((r) => r[0]),
db.select({ count: count() }).from(recipePhotos).then((r) => r[0]),
]);
const stats = [
{ label: "Total Users", value: userCount?.count ?? 0, sub: `${proUserCount?.count ?? 0} pro`, icon: Users },
{ label: "Total Recipes", value: recipeCount?.count ?? 0, icon: BookOpen },
{ label: "AI Calls This Month", value: Number(aiUsage?.total ?? 0), icon: Sparkles },
{ label: "Recipe Photos", value: imageCount?.count ?? 0, icon: Image },
];
return (
<div className="space-y-6">
<h1 className="text-2xl font-bold tracking-tight">Overview</h1>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
{stats.map(({ label, value, sub, icon: Icon }) => (
<Card key={label}>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">{label}</CardTitle>
<Icon className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{value.toLocaleString()}</div>
{sub && <p className="text-xs text-muted-foreground mt-1">{sub}</p>}
</CardContent>
</Card>
))}
</div>
</div>
);
}
+99
View File
@@ -0,0 +1,99 @@
import type { Metadata } from "next";
import { db, recipes, users, eq, desc } from "@epicure/db";
import { Badge } from "@/components/ui/badge";
import Link from "next/link";
export const metadata: Metadata = { title: "Recipe Moderation" };
const VISIBILITY_COLORS = {
public: "default",
unlisted: "outline",
private: "secondary",
} as const;
export default async function AdminRecipesPage() {
const publicRecipes = await db
.select({
id: recipes.id,
title: recipes.title,
visibility: recipes.visibility,
createdAt: recipes.createdAt,
authorName: users.name,
authorId: users.id,
})
.from(recipes)
.leftJoin(users, eq(recipes.authorId, users.id))
.where(eq(recipes.visibility, "public"))
.orderBy(desc(recipes.createdAt))
.limit(200);
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold tracking-tight">Recipe Moderation</h1>
<p className="text-muted-foreground text-sm mt-1">
Public recipes sorted by newest. Review and decide manually.
</p>
</div>
<div className="rounded-md border">
<table className="w-full text-sm">
<thead className="border-b bg-muted/50">
<tr>
<th className="px-4 py-3 text-left font-medium">Title</th>
<th className="px-4 py-3 text-left font-medium">Author</th>
<th className="px-4 py-3 text-left font-medium">Visibility</th>
<th className="px-4 py-3 text-left font-medium">Created</th>
<th className="px-4 py-3 text-left font-medium">Link</th>
</tr>
</thead>
<tbody>
{publicRecipes.length === 0 ? (
<tr>
<td colSpan={5} className="px-4 py-8 text-center text-muted-foreground">
No public recipes yet.
</td>
</tr>
) : (
publicRecipes.map((recipe) => (
<tr key={recipe.id} className="border-b last:border-0 hover:bg-muted/30">
<td className="px-4 py-3 font-medium">{recipe.title}</td>
<td className="px-4 py-3 text-muted-foreground">
{recipe.authorId ? (
<Link
href={`/admin/users/${recipe.authorId}`}
className="hover:underline underline-offset-4"
>
{recipe.authorName ?? "Unknown"}
</Link>
) : (
<span>{recipe.authorName ?? "Unknown"}</span>
)}
</td>
<td className="px-4 py-3">
<Badge variant={VISIBILITY_COLORS[recipe.visibility]}>
{recipe.visibility}
</Badge>
</td>
<td className="px-4 py-3 text-muted-foreground">
{recipe.createdAt.toLocaleDateString()}
</td>
<td className="px-4 py-3">
<Link
href={`/r/${recipe.id}`}
className="text-primary underline-offset-4 hover:underline text-xs"
target="_blank"
rel="noopener noreferrer"
>
View
</Link>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
);
}
+43
View File
@@ -0,0 +1,43 @@
import type { Metadata } from "next";
import { getAllSiteSettings } from "@/lib/site-settings";
import { AdminSettingsForm } from "@/components/admin/admin-settings-form";
export const metadata: Metadata = { title: "Site Settings Admin" };
const SETTING_GROUPS = [
{
title: "AI Provider Keys",
description: "Override the environment variable API keys at runtime. Values are encrypted at rest.",
keys: ["OPENAI_API_KEY", "ANTHROPIC_API_KEY", "OPENROUTER_API_KEY"] as const,
},
{
title: "AI Configuration",
description: "Provider routing and model defaults.",
keys: ["OPENROUTER_DEFAULT_MODEL", "OLLAMA_BASE_URL"] as const,
},
{
title: "Push Notifications (VAPID)",
description: "Keys for web push notifications. Generate with: npx web-push generate-vapid-keys",
keys: ["NEXT_PUBLIC_VAPID_PUBLIC_KEY", "VAPID_PRIVATE_KEY"] as const,
},
];
export default async function AdminSettingsPage() {
const settings = await getAllSiteSettings();
return (
<div className="space-y-8">
<div>
<h1 className="text-2xl font-bold tracking-tight">Site Settings</h1>
<p className="text-muted-foreground text-sm mt-1">
Override .env values at runtime. DB values take precedence over environment variables.
Clear a value to fall back to the environment variable.
</p>
</div>
{SETTING_GROUPS.map((group) => (
<AdminSettingsForm key={group.title} group={group} settings={settings} />
))}
</div>
);
}
+131
View File
@@ -0,0 +1,131 @@
import type { Metadata } from "next";
import { db, users, recipePhotos, recipes, eq, desc, sql, count } from "@epicure/db";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { HardDrive } from "lucide-react";
export const metadata: Metadata = { title: "Storage Usage" };
export default async function AdminStoragePage() {
const [totalPhotos, recipesWithPhotos, photosByTier, topUsers] = await Promise.all([
db.select({ count: count() }).from(recipePhotos).then((r) => r[0]),
db
.select({ count: count() })
.from(recipePhotos)
.where(eq(recipePhotos.isCover, true))
.then((r) => r[0]),
db
.select({
tier: users.tier,
photoCount: sql<string>`count(${recipePhotos.id})`,
})
.from(recipePhotos)
.innerJoin(recipes, eq(recipePhotos.recipeId, recipes.id))
.innerJoin(users, eq(recipes.authorId, users.id))
.groupBy(users.tier),
db
.select({
userId: users.id,
name: users.name,
email: users.email,
tier: users.tier,
photoCount: sql<string>`count(${recipePhotos.id})`,
})
.from(recipePhotos)
.innerJoin(recipes, eq(recipePhotos.recipeId, recipes.id))
.innerJoin(users, eq(recipes.authorId, users.id))
.groupBy(users.id, users.name, users.email, users.tier)
.orderBy(desc(sql`count(${recipePhotos.id})`))
.limit(10),
]);
const freePhotos = Number(photosByTier.find((r) => r.tier === "free")?.photoCount ?? 0);
const proPhotos = Number(photosByTier.find((r) => r.tier === "pro")?.photoCount ?? 0);
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold tracking-tight">Storage Usage</h1>
<p className="text-muted-foreground text-sm mt-1">
Photo storage breakdown by tier and user.
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Total Photos</CardTitle>
<HardDrive className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{(totalPhotos?.count ?? 0).toLocaleString()}</div>
<p className="text-xs text-muted-foreground mt-1">{recipesWithPhotos?.count ?? 0} cover photos</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Free Tier Photos</CardTitle>
<HardDrive className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{freePhotos.toLocaleString()}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Pro Tier Photos</CardTitle>
<HardDrive className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{proPhotos.toLocaleString()}</div>
</CardContent>
</Card>
</div>
<div>
<h2 className="text-lg font-semibold mb-3">Top 10 Users by Photo Count</h2>
<div className="rounded-md border">
<table className="w-full text-sm">
<thead className="border-b bg-muted/50">
<tr>
<th className="px-4 py-3 text-left font-medium">User</th>
<th className="px-4 py-3 text-left font-medium">Tier</th>
<th className="px-4 py-3 text-right font-medium">Photos</th>
</tr>
</thead>
<tbody>
{topUsers.length === 0 ? (
<tr>
<td colSpan={3} className="px-4 py-8 text-center text-muted-foreground">
No photos uploaded yet.
</td>
</tr>
) : (
topUsers.map((row) => (
<tr key={row.userId} className="border-b last:border-0 hover:bg-muted/30">
<td className="px-4 py-3">
<div className="font-medium">{row.name ?? "Unknown"}</div>
{row.email && (
<div className="text-xs text-muted-foreground">{row.email}</div>
)}
</td>
<td className="px-4 py-3">
<Badge variant={row.tier === "pro" ? "default" : "secondary"}>
{row.tier ?? "—"}
</Badge>
</td>
<td className="px-4 py-3 text-right font-mono">
{Number(row.photoCount).toLocaleString()}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
</div>
);
}
+167
View File
@@ -0,0 +1,167 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { db, users, recipes, userUsage, eq, desc, count, and, sql } from "@epicure/db";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Separator } from "@/components/ui/separator";
import { UserEditor } from "@/components/admin/user-editor";
import Link from "next/link";
import { ArrowLeft } from "lucide-react";
export const metadata: Metadata = { title: "User Detail" };
interface PageProps {
params: Promise<{ id: string }>;
}
const ROLE_COLORS = {
user: "secondary",
moderator: "outline",
admin: "destructive",
} as const;
const TIER_COLORS = {
free: "secondary",
pro: "default",
} as const;
export default async function AdminUserDetailPage({ params }: PageProps) {
const { id } = await params;
const [user] = await db.select().from(users).where(eq(users.id, id)).limit(1);
if (!user) notFound();
const currentMonth = new Date().toISOString().slice(0, 7);
const [usage] = await db
.select()
.from(userUsage)
.where(and(eq(userUsage.userId, id), eq(userUsage.month, currentMonth)))
.limit(1);
const [recipeCountRow] = await db
.select({ count: count() })
.from(recipes)
.where(eq(recipes.authorId, id));
const recentRecipes = await db
.select({
id: recipes.id,
title: recipes.title,
visibility: recipes.visibility,
createdAt: recipes.createdAt,
})
.from(recipes)
.where(eq(recipes.authorId, id))
.orderBy(desc(recipes.createdAt))
.limit(10);
return (
<div className="space-y-6">
<div className="flex items-center gap-3">
<Link
href="/admin/users"
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground"
>
<ArrowLeft className="h-4 w-4" />
Back to Users
</Link>
</div>
<div className="flex items-center gap-4">
<Avatar className="h-16 w-16">
<AvatarImage src={user.avatarUrl ?? ""} />
<AvatarFallback className="text-lg">
{user.name.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<div>
<h1 className="text-2xl font-bold tracking-tight">{user.name}</h1>
<p className="text-muted-foreground">{user.email}</p>
<p className="text-xs text-muted-foreground mt-1">
Joined {user.createdAt.toLocaleDateString()}
</p>
</div>
<div className="ml-auto flex gap-2">
<Badge variant={ROLE_COLORS[user.role]}>{user.role}</Badge>
<Badge variant={TIER_COLORS[user.tier]}>{user.tier}</Badge>
</div>
</div>
<Separator />
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<Card>
<CardHeader>
<CardTitle className="text-base">Edit Role &amp; Tier</CardTitle>
</CardHeader>
<CardContent>
<UserEditor userId={user.id} currentRole={user.role} currentTier={user.tier} />
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">Usage This Month ({currentMonth})</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">AI Calls Used</span>
<span className="font-medium">{usage?.aiCallsUsed ?? 0}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Total Recipes</span>
<span className="font-medium">{recipeCountRow?.count ?? 0}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Storage Used</span>
<span className="font-medium">{usage?.storageUsedMb ?? 0} MB</span>
</div>
</CardContent>
</Card>
</div>
<div>
<h2 className="text-lg font-semibold mb-3">Recent Recipes</h2>
{recentRecipes.length === 0 ? (
<p className="text-muted-foreground text-sm">No recipes yet.</p>
) : (
<div className="rounded-md border">
<table className="w-full text-sm">
<thead className="border-b bg-muted/50">
<tr>
<th className="px-4 py-3 text-left font-medium">Title</th>
<th className="px-4 py-3 text-left font-medium">Visibility</th>
<th className="px-4 py-3 text-left font-medium">Created</th>
<th className="px-4 py-3 text-left font-medium">Link</th>
</tr>
</thead>
<tbody>
{recentRecipes.map((recipe) => (
<tr key={recipe.id} className="border-b last:border-0 hover:bg-muted/30">
<td className="px-4 py-3 font-medium">{recipe.title}</td>
<td className="px-4 py-3">
<Badge variant="outline">{recipe.visibility}</Badge>
</td>
<td className="px-4 py-3 text-muted-foreground">
{recipe.createdAt.toLocaleDateString()}
</td>
<td className="px-4 py-3">
<Link
href={`/r/${recipe.id}`}
className="text-primary underline-offset-4 hover:underline text-xs"
>
View
</Link>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
);
}
+75
View File
@@ -0,0 +1,75 @@
import type { Metadata } from "next";
import { db } from "@epicure/db";
import { users } from "@epicure/db";
import { desc } from "@epicure/db";
import { Badge } from "@/components/ui/badge";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import Link from "next/link";
export const metadata: Metadata = { title: "User Management" };
const ROLE_COLORS = {
user: "secondary",
moderator: "outline",
admin: "destructive",
} as const;
const TIER_COLORS = {
free: "secondary",
pro: "default",
} as const;
export default async function AdminUsersPage() {
const allUsers = await db
.select()
.from(users)
.orderBy(desc(users.createdAt))
.limit(100);
return (
<div className="space-y-6">
<h1 className="text-2xl font-bold tracking-tight">Users</h1>
<div className="rounded-md border">
<table className="w-full text-sm">
<thead className="border-b bg-muted/50">
<tr>
<th className="px-4 py-3 text-left font-medium">User</th>
<th className="px-4 py-3 text-left font-medium">Role</th>
<th className="px-4 py-3 text-left font-medium">Tier</th>
<th className="px-4 py-3 text-left font-medium">Joined</th>
</tr>
</thead>
<tbody>
{allUsers.map((user) => (
<tr key={user.id} className="border-b last:border-0 hover:bg-muted/30">
<td className="px-4 py-3">
<Link href={`/admin/users/${user.id}`} className="flex items-center gap-3 group">
<Avatar className="h-8 w-8">
<AvatarImage src={user.avatarUrl ?? ""} />
<AvatarFallback className="text-xs">
{user.name.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<div>
<div className="font-medium group-hover:underline underline-offset-4">{user.name}</div>
<div className="text-muted-foreground text-xs">{user.email}</div>
</div>
</Link>
</td>
<td className="px-4 py-3">
<Badge variant={ROLE_COLORS[user.role]}>{user.role}</Badge>
</td>
<td className="px-4 py-3">
<Badge variant={TIER_COLORS[user.tier]}>{user.tier}</Badge>
</td>
<td className="px-4 py-3 text-muted-foreground">
{user.createdAt.toLocaleDateString()}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
+37
View File
@@ -0,0 +1,37 @@
import { NextResponse } from "next/server";
const HTML = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Epicure API Docs</title>
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5/swagger-ui.css" />
<style>
body { margin: 0; }
.swagger-ui .topbar { background-color: #18181b; }
.swagger-ui .topbar .download-url-wrapper .select-label select { border-color: #52525b; }
</style>
</head>
<body>
<div id="swagger-ui"></div>
<script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js"></script>
<script>
SwaggerUIBundle({
url: "/api/v1/openapi.json",
dom_id: "#swagger-ui",
presets: [SwaggerUIBundle.presets.apis, SwaggerUIBundle.SwaggerUIStandalonePreset],
layout: "BaseLayout",
deepLinking: true,
tryItOutEnabled: true,
persistAuthorization: true,
});
</script>
</body>
</html>`;
export async function GET() {
return new NextResponse(HTML, {
headers: { "Content-Type": "text/html; charset=utf-8" },
});
}
@@ -0,0 +1,51 @@
import { type NextRequest, NextResponse } from "next/server";
import { headers } from "next/headers";
import { auth } from "@/lib/auth/server";
import { db, users, auditLogs, eq } from "@epicure/db";
import { setSiteSetting, type SiteSettingKey } from "@/lib/site-settings";
import { randomUUID } from "crypto";
const ALLOWED_KEYS: SiteSettingKey[] = [
"OPENAI_API_KEY",
"ANTHROPIC_API_KEY",
"OPENROUTER_API_KEY",
"OPENROUTER_DEFAULT_MODEL",
"OLLAMA_BASE_URL",
"NEXT_PUBLIC_VAPID_PUBLIC_KEY",
"VAPID_PRIVATE_KEY",
];
async function requireAdmin() {
const session = await auth.api.getSession({ headers: await headers() });
if (!session) return null;
const [dbUser] = await db.select({ role: users.role }).from(users).where(eq(users.id, session.user.id));
if (dbUser?.role !== "admin") return null;
return session;
}
export async function PUT(req: NextRequest) {
const session = await requireAdmin();
if (!session) return NextResponse.json({ error: "Forbidden" }, { status: 403 });
const body = (await req.json()) as Record<string, string | null>;
const changed: string[] = [];
for (const [key, value] of Object.entries(body)) {
if (!ALLOWED_KEYS.includes(key as SiteSettingKey)) continue;
await setSiteSetting(key as SiteSettingKey, value, session.user.id);
changed.push(key);
}
if (changed.length > 0) {
await db.insert(auditLogs).values({
id: randomUUID(),
userId: session.user.id,
action: "admin.settings.update",
targetType: "site_settings",
metadata: JSON.stringify({ keys: changed }),
createdAt: new Date(),
});
}
return NextResponse.json({ ok: true });
}
@@ -0,0 +1,22 @@
import { NextRequest, NextResponse } from "next/server";
import { requireAdmin } from "@/lib/api-auth";
import { sendEmail, verifyEmailHtml } from "@/lib/email";
export async function POST(req: NextRequest) {
const { response } = await requireAdmin();
if (response) return response;
const { to } = await req.json() as { to: string };
if (!to) return NextResponse.json({ error: "Missing 'to'" }, { status: 400 });
try {
await sendEmail({
to,
subject: "Epicure — test email",
html: verifyEmailHtml(`${process.env["BETTER_AUTH_URL"] ?? "http://localhost:3001"}/verify-email?token=test`),
});
return NextResponse.json({ ok: true });
} catch (err) {
return NextResponse.json({ error: String(err) }, { status: 500 });
}
}
@@ -0,0 +1,56 @@
import { NextRequest, NextResponse } from "next/server";
import { requireAdmin } from "@/lib/api-auth";
import { db, users, auditLogs, eq } from "@epicure/db";
import { randomUUID } from "crypto";
interface RouteContext {
params: Promise<{ id: string }>;
}
export async function PATCH(req: NextRequest, { params }: RouteContext) {
const { session, response } = await requireAdmin();
if (response) return response;
const { id } = await params;
const body = await req.json() as { role?: string; tier?: string };
const { role, tier } = body;
const validRoles = ["user", "moderator", "admin"] as const;
const validTiers = ["free", "pro"] as const;
if (role !== undefined && !validRoles.includes(role as typeof validRoles[number])) {
return NextResponse.json({ error: "Invalid role" }, { status: 400 });
}
if (tier !== undefined && !validTiers.includes(tier as typeof validTiers[number])) {
return NextResponse.json({ error: "Invalid tier" }, { status: 400 });
}
const updateData: Partial<{ role: "user" | "moderator" | "admin"; tier: "free" | "pro"; updatedAt: Date }> = {
updatedAt: new Date(),
};
if (role) updateData.role = role as "user" | "moderator" | "admin";
if (tier) updateData.tier = tier as "free" | "pro";
const [updated] = await db
.update(users)
.set(updateData)
.where(eq(users.id, id))
.returning({ id: users.id, role: users.role, tier: users.tier });
if (!updated) {
return NextResponse.json({ error: "User not found" }, { status: 404 });
}
// Write audit log
await db.insert(auditLogs).values({
id: randomUUID(),
userId: session!.user.id,
action: "admin.user.update",
targetType: "user",
targetId: id,
metadata: JSON.stringify({ role, tier }),
createdAt: new Date(),
});
return NextResponse.json({ user: updated });
}
@@ -0,0 +1,18 @@
import { NextResponse } from "next/server";
import { db, userAiKeys, eq, and } from "@epicure/db";
import { requireSession } from "@/lib/api-auth";
type Params = { params: Promise<{ provider: string }> };
export async function DELETE(_req: Request, { params }: Params) {
const { session, response } = await requireSession();
if (response) return response;
const { provider } = await params;
await db.delete(userAiKeys).where(
and(eq(userAiKeys.userId, session!.user.id), eq(userAiKeys.provider, provider))
);
return NextResponse.json({ ok: true });
}
+56
View File
@@ -0,0 +1,56 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { db, userAiKeys, eq, and } from "@epicure/db";
import { requireSession } from "@/lib/api-auth";
import { encrypt } from "@/lib/encrypt";
const VALID_PROVIDERS = ["openai", "anthropic", "openrouter", "ollama"] as const;
const PostSchema = z.object({
provider: z.enum(VALID_PROVIDERS),
apiKey: z.string().min(1).max(500),
});
export async function GET() {
const { session, response } = await requireSession();
if (response) return response;
const keys = await db.query.userAiKeys.findMany({
where: eq(userAiKeys.userId, session!.user.id),
columns: { provider: true, createdAt: true },
});
return NextResponse.json({ keys });
}
export async function POST(req: Request) {
const { session, response } = await requireSession();
if (response) return response;
const body = PostSchema.safeParse(await req.json());
if (!body.success) return NextResponse.json({ error: body.error.flatten() }, { status: 400 });
const { provider, apiKey } = body.data;
const userId = session!.user.id;
const existing = await db.query.userAiKeys.findFirst({
where: and(eq(userAiKeys.userId, userId), eq(userAiKeys.provider, provider)),
});
const encryptedKey = encrypt(apiKey);
if (existing) {
await db.update(userAiKeys)
.set({ encryptedKey })
.where(and(eq(userAiKeys.userId, userId), eq(userAiKeys.provider, provider)));
} else {
await db.insert(userAiKeys).values({
id: crypto.randomUUID(),
userId,
provider,
encryptedKey,
});
}
return NextResponse.json({ ok: true });
}
+116
View File
@@ -0,0 +1,116 @@
import { NextRequest, NextResponse } from "next/server";
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 { 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([]),
extraConstraints: z.string().max(500).optional(),
provider: z.enum(["openai", "anthropic", "openrouter", "ollama"]).optional(),
model: z.string().optional(),
});
type Params = { params: Promise<{ id: string }> };
export async function POST(req: NextRequest, { params }: Params) {
const { session, response } = await requireSession();
if (response) return response;
const { id } = await params;
const userId = session!.user.id;
const recipe = await db.query.recipes.findFirst({
where: and(eq(recipes.id, id)),
with: {
ingredients: { orderBy: (t, { asc }) => asc(t.order) },
steps: { orderBy: (t, { asc }) => asc(t.order) },
},
});
if (!recipe || (recipe.visibility === "private" && recipe.authorId !== userId)) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
const body = await req.json() as unknown;
const parsed = Schema.safeParse(body);
if (!parsed.success) return NextResponse.json({ error: "Validation error" }, { status: 400 });
if (parsed.data.excludeIngredients.length === 0 && !parsed.data.extraConstraints?.trim()) {
return NextResponse.json({ error: "Provide at least one constraint" }, { status: 400 });
}
await checkAndIncrementTierLimit(userId, session!.user.tier as "free" | "pro", "aiCall");
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,
description: recipe.description,
baseServings: recipe.baseServings,
ingredients: recipe.ingredients,
steps: recipe.steps,
},
{
excludeIngredients: parsed.data.excludeIngredients,
extraConstraints: parsed.data.extraConstraints,
},
{ ...aiConfig, userContext: privateBio ?? undefined },
(session!.user as { locale?: string }).locale ?? "en"
);
const newId = crypto.randomUUID();
const now = new Date();
await db.transaction(async (tx) => {
await tx.insert(recipes).values({
id: newId,
authorId: userId,
title: adapted.title,
description: adapted.description,
baseServings: adapted.baseServings,
visibility: "private",
difficulty: adapted.difficulty,
prepMins: adapted.prepMins,
cookMins: adapted.cookMins,
dietaryTags: adapted.dietaryTags ?? {},
aiGenerated: true,
createdAt: now,
updatedAt: now,
});
if (adapted.ingredients.length > 0) {
await tx.insert(recipeIngredients).values(
adapted.ingredients.map((ing, i) => ({
id: crypto.randomUUID(),
recipeId: newId,
rawName: ing.rawName,
quantity: ing.quantity !== undefined ? String(ing.quantity) : undefined,
unit: ing.unit,
note: ing.note,
order: i,
}))
);
}
if (adapted.steps.length > 0) {
await tx.insert(recipeSteps).values(
adapted.steps.map((step, i) => ({
id: crypto.randomUUID(),
recipeId: newId,
instruction: step.instruction,
timerSeconds: step.timerSeconds,
order: i,
}))
);
}
});
return NextResponse.json({ id: newId, adaptationNotes: adapted.adaptationNotes });
}
@@ -0,0 +1,57 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { and, eq } from "@epicure/db";
import { db, recipes } from "@epicure/db";
import { requireSession } from "@/lib/api-auth";
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),
provider: z.enum(["openai", "anthropic", "openrouter", "ollama"]).optional(),
model: z.string().optional(),
});
type Params = { params: Promise<{ id: string }> };
export async function POST(req: NextRequest, { params }: Params) {
const { session, response } = await requireSession();
if (response) return response;
const { id } = await params;
const recipe = await db.query.recipes.findFirst({
where: and(eq(recipes.id, id)),
with: { ingredients: true },
});
if (!recipe || (recipe.visibility === "private" && recipe.authorId !== session!.user.id)) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
const body = await req.json() as unknown;
const parsed = Schema.safeParse(body ?? {});
if (!parsed.success) return NextResponse.json({ error: "Validation error" }, { status: 400 });
await checkAndIncrementTierLimit(session!.user.id, session!.user.tier as "free" | "pro", "aiCall");
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,
description: recipe.description,
difficulty: recipe.difficulty,
dietaryTags: recipe.dietaryTags as Record<string, boolean> | null,
ingredients: recipe.ingredients,
},
parsed.data.count,
{ ...aiConfig, userContext: privateBio ?? undefined },
(session!.user as { locale?: string }).locale ?? "en"
);
return NextResponse.json({ drinks });
}
@@ -0,0 +1,85 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { requireSession } from "@/lib/api-auth";
import { applyRateLimit } from "@/lib/rate-limit";
import { checkAndIncrementTierLimit } from "@/lib/tiers";
import { generateRecipe } from "@/lib/ai/features/generate-recipe";
import { getUserPrivateBio } from "@/lib/ai/user-bio";
import { db, recipes, recipeIngredients, recipeSteps } from "@epicure/db";
import { parseQuantity } from "@/lib/parse-quantity";
const Schema = z.object({
title: z.string().min(1).max(200),
provider: z.enum(["openai", "anthropic", "openrouter", "ollama"]).optional(),
model: z.string().optional(),
});
export async function POST(req: NextRequest) {
const { session, response } = await requireSession();
if (response) return response;
const body = await req.json() as unknown;
const parsed = Schema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: "Validation error" }, { status: 400 });
}
const limited = await applyRateLimit(`rl:ai:${session!.user.id}`, 10, 60);
if (limited) return limited;
await checkAndIncrementTierLimit(session!.user.id, session!.user.tier as "free" | "pro", "aiCall");
const privateBio = await getUserPrivateBio(session!.user.id);
const recipe = await generateRecipe(parsed.data.title, {
provider: parsed.data.provider,
model: parsed.data.model,
userContext: privateBio ?? undefined,
});
const recipeId = crypto.randomUUID();
await db.insert(recipes).values({
id: recipeId,
authorId: session!.user.id,
title: recipe.title,
description: recipe.description ?? null,
baseServings: recipe.baseServings,
prepMins: recipe.prepMins ?? null,
cookMins: recipe.cookMins ?? null,
difficulty: recipe.difficulty ?? null,
visibility: "private",
aiGenerated: true,
language: "en",
dietaryTags: recipe.dietaryTags ?? {},
tags: [],
});
if (recipe.ingredients?.length) {
await db.insert(recipeIngredients).values(
recipe.ingredients.map((ing, i) => ({
id: crypto.randomUUID(),
recipeId,
rawName: ing.rawName,
quantity: parseQuantity(ing.quantity) ?? null,
unit: ing.unit ?? null,
note: ing.note ?? null,
order: i,
}))
);
}
if (recipe.steps?.length) {
await db.insert(recipeSteps).values(
recipe.steps.map((step, i) => ({
id: crypto.randomUUID(),
recipeId,
instruction: step.instruction,
timerSeconds: step.timerSeconds ?? null,
order: i,
}))
);
}
return NextResponse.json({ id: recipeId });
}
+43
View File
@@ -0,0 +1,43 @@
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";
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(),
});
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", issues: parsed.error.issues }, { 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.prompt, {
provider: parsed.data.provider,
model: parsed.data.model,
language: parsed.data.language,
difficulty: parsed.data.difficulty,
userContext: privateBio ?? undefined,
});
return NextResponse.json(recipe);
}
@@ -0,0 +1,104 @@
import { NextRequest, NextResponse } from "next/server";
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 { checkAndIncrementTierLimit, TierLimitError } from "@/lib/tiers";
import { importFromPhoto } from "@/lib/ai/features/import-photo";
import { getModelConfigForUseCase } from "@/lib/ai/resolve-user-key";
const Schema = z.object({
imageBase64: z.string().max(14_000_000),
mimeType: z.enum(["image/jpeg", "image/png", "image/webp"]),
});
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", issues: parsed.error.issues }, { status: 400 });
}
const limited = await applyRateLimit(`rl:ai:${session!.user.id}`, 5, 60);
if (limited) return limited;
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
if (!aiConfig.model) {
if (aiConfig.provider === "openai") aiConfig.model = "gpt-4o";
else if (aiConfig.provider === "anthropic") aiConfig.model = "claude-sonnet-4-6";
}
let recipe;
try {
recipe = await importFromPhoto(parsed.data.imageBase64, parsed.data.mimeType, aiConfig, locale);
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : "AI analysis failed";
return NextResponse.json({ error: msg }, { status: 502 });
}
const newRecipeId = crypto.randomUUID();
const now = new Date();
await db.transaction(async (tx) => {
await tx.insert(recipes).values({
id: newRecipeId,
authorId: userId,
title: recipe.title,
description: recipe.description,
baseServings: recipe.baseServings ?? 4,
visibility: "private",
difficulty: recipe.difficulty,
prepMins: recipe.prepMins,
cookMins: recipe.cookMins,
dietaryTags: recipe.dietaryTags ?? {},
aiGenerated: true,
createdAt: now,
updatedAt: now,
});
if (recipe.ingredients.length > 0) {
await tx.insert(recipeIngredients).values(
recipe.ingredients.map((ing, i) => ({
id: crypto.randomUUID(),
recipeId: newRecipeId,
rawName: ing.rawName,
quantity: ing.quantity !== undefined ? String(ing.quantity) : undefined,
unit: ing.unit,
note: ing.note,
order: i,
}))
);
}
if (recipe.steps.length > 0) {
await tx.insert(recipeSteps).values(
recipe.steps.map((step, i) => ({
id: crypto.randomUUID(),
recipeId: newRecipeId,
instruction: step.instruction,
timerSeconds: step.timerSeconds,
order: i,
}))
);
}
});
return NextResponse.json({ id: newRecipeId });
}
@@ -0,0 +1,41 @@
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 { importFromUrl } from "@/lib/ai/features/import-url";
import { validateWebhookUrl } from "@/lib/validate-webhook-url";
const Schema = z.object({
url: z.string().url(),
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", 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 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,
});
return NextResponse.json(recipe);
}
@@ -0,0 +1,148 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { db, recipes, recipeIngredients, recipeSteps, mealPlans, mealPlanEntries, pantryItems, eq, and } from "@epicure/db";
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;
const Schema = z.object({
weekStart: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
dietaryPrefs: z.string().max(200).optional(),
servings: z.number().int().min(1).max(20).default(2),
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) {
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", issues: parsed.error.issues }, { status: 400 });
}
const limited = await applyRateLimit(`rl:ai:${session!.user.id}`, 3, 60);
if (limited) return limited;
const userId = session!.user.id;
const locale = (session!.user as { locale?: string }).locale ?? "en";
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;
// Optionally fetch pantry items
let pantryItemNames: string[] = [];
if (effectiveUsePantry) {
const pantry = await db
.select({ rawName: pantryItems.rawName })
.from(pantryItems)
.where(eq(pantryItems.userId, userId));
pantryItemNames = pantry.map((p) => p.rawName);
}
const plan = await generateMealPlan(
{
dietaryPrefs: parsed.data.dietaryPrefs,
servings: parsed.data.servings,
pantryItems: pantryItemNames,
days: parsed.data.days,
pantryMode: parsed.data.pantryMode,
difficulty: parsed.data.difficulty,
},
{ ...config, userContext: privateBio ?? undefined },
locale
);
// Ensure meal plan row exists for the week
let mealPlan = await db.query.mealPlans.findFirst({
where: and(eq(mealPlans.userId, userId), eq(mealPlans.weekStart, parsed.data.weekStart)),
});
if (!mealPlan) {
const planId = crypto.randomUUID();
await db.insert(mealPlans).values({ id: planId, userId, weekStart: parsed.data.weekStart });
mealPlan = { id: planId, userId, weekStart: parsed.data.weekStart, createdAt: new Date() };
}
const createdEntries: Array<{ id: string; day: string; mealType: string; recipeId: string; recipeTitle: string }> = [];
for (const entry of plan.entries) {
// Create draft recipe
const recipeId = crypto.randomUUID();
await db.insert(recipes).values({
id: recipeId,
authorId: userId,
title: entry.recipe.title,
description: entry.recipe.description,
baseServings: entry.servings,
visibility: "private",
aiGenerated: true,
difficulty: entry.recipe.difficulty ?? null,
prepMins: entry.recipe.prepMins ?? null,
cookMins: entry.recipe.cookMins ?? null,
});
if (entry.recipe.ingredients.length > 0) {
await db.insert(recipeIngredients).values(
entry.recipe.ingredients.map((ing, i) => ({
id: crypto.randomUUID(),
recipeId,
rawName: ing.rawName,
quantity: ing.quantity != null ? String(ing.quantity) : null,
unit: ing.unit ?? null,
order: i,
}))
);
}
if (entry.recipe.steps.length > 0) {
await db.insert(recipeSteps).values(
entry.recipe.steps.map((step, i) => ({
id: crypto.randomUUID(),
recipeId,
instruction: step.instruction,
order: i,
}))
);
}
// Remove any existing entry for this day+mealType, then insert new
const existingEntry = await db.query.mealPlanEntries.findFirst({
where: and(
eq(mealPlanEntries.mealPlanId, mealPlan!.id),
eq(mealPlanEntries.day, entry.day),
eq(mealPlanEntries.mealType, entry.mealType)
),
});
if (existingEntry) {
await db.delete(mealPlanEntries).where(eq(mealPlanEntries.id, existingEntry.id));
}
const entryId = crypto.randomUUID();
await db.insert(mealPlanEntries).values({
id: entryId,
mealPlanId: mealPlan!.id,
day: entry.day,
mealType: entry.mealType,
recipeId,
servings: entry.servings,
});
createdEntries.push({ id: entryId, day: entry.day, mealType: entry.mealType, recipeId, recipeTitle: entry.recipe.title });
}
return NextResponse.json({ weekStart: parsed.data.weekStart, entries: createdEntries });
}
@@ -0,0 +1,58 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { and, eq } from "@epicure/db";
import { db, recipes } from "@epicure/db";
import { requireSession } from "@/lib/api-auth";
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),
provider: z.enum(["openai", "anthropic", "openrouter", "ollama"]).optional(),
model: z.string().optional(),
});
type Params = { params: Promise<{ id: string }> };
export async function POST(req: NextRequest, { params }: Params) {
const { session, response } = await requireSession();
if (response) return response;
const { id } = await params;
// Allow pairings for own recipes or public recipes
const recipe = await db.query.recipes.findFirst({
where: and(eq(recipes.id, id)),
with: { ingredients: true },
});
if (!recipe || (recipe.visibility === "private" && recipe.authorId !== session!.user.id)) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
const body = await req.json() as unknown;
const parsed = Schema.safeParse(body ?? {});
if (!parsed.success) return NextResponse.json({ error: "Validation error" }, { status: 400 });
await checkAndIncrementTierLimit(session!.user.id, session!.user.tier as "free" | "pro", "aiCall");
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,
description: recipe.description,
difficulty: recipe.difficulty,
dietaryTags: recipe.dietaryTags as Record<string, boolean> | null,
ingredients: recipe.ingredients,
},
parsed.data.count,
{ ...aiConfig, userContext: privateBio ?? undefined },
(session!.user as { locale?: string }).locale ?? "en"
);
return NextResponse.json({ pairings });
}
@@ -0,0 +1,78 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { generateText } from "ai";
import { requireSession } from "@/lib/api-auth";
import { applyRateLimit } from "@/lib/rate-limit";
import { getModelConfigForUseCase } from "@/lib/ai/resolve-user-key";
import { resolveModel } from "@/lib/ai/factory";
import { db, recipes, eq, and } from "@epicure/db";
import { getUserPrivateBio, buildUserBioContext } from "@/lib/ai/user-bio";
const Schema = z.object({
recipeId: z.string().uuid(),
question: z.string().min(1).max(500),
});
export async function POST(req: NextRequest) {
const { session, response } = await requireSession();
if (response) return response;
const body = await req.json() as unknown;
const parsed = Schema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: "Validation error" }, { status: 400 });
}
const limited = await applyRateLimit(`rl:ai:${session!.user.id}`, 30, 60);
if (limited) return limited;
const recipe = await db.query.recipes.findFirst({
where: and(eq(recipes.id, parsed.data.recipeId), eq(recipes.authorId, session!.user.id)),
with: {
ingredients: { orderBy: (t, { asc }) => asc(t.order) },
steps: { orderBy: (t, { asc }) => asc(t.order) },
},
});
if (!recipe) {
return NextResponse.json({ error: "Recipe not found" }, { status: 404 });
}
const ingredientList = recipe.ingredients
.map((ing) => [ing.quantity, ing.unit, ing.rawName, ing.note ? `(${ing.note})` : ""].filter(Boolean).join(" "))
.join("\n");
const stepList = recipe.steps
.map((s, i) => `${i + 1}. ${s.instruction}`)
.join("\n");
const recipeContext = `
Recipe: ${recipe.title}
${recipe.description ? `Description: ${recipe.description}` : ""}
Difficulty: ${recipe.difficulty ?? "unknown"}
Prep: ${recipe.prepMins ?? 0}min | Cook: ${recipe.cookMins ?? 0}min | Servings: ${recipe.baseServings}
INGREDIENTS:
${ingredientList || "None listed"}
STEPS:
${stepList || "None listed"}
`.trim();
const [config, privateBio] = await Promise.all([
getModelConfigForUseCase(session!.user.id, "text"),
getUserPrivateBio(session!.user.id),
]);
const model = resolveModel(config);
const bioContext = buildUserBioContext(privateBio);
const { text } = await generateText({
model,
system: `You are a helpful culinary assistant. Answer questions about the following recipe concisely and accurately. If a question is not related to the recipe or cooking, politely redirect. Keep answers under 200 words.
${recipeContext}${bioContext}`,
prompt: parsed.data.question,
});
return NextResponse.json({ answer: text });
}
@@ -0,0 +1,59 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { generateObject } from "ai";
import { requireSession } from "@/lib/api-auth";
import { applyRateLimit } from "@/lib/rate-limit";
import { getModelConfigForUseCase } from "@/lib/ai/resolve-user-key";
import { resolveModel } from "@/lib/ai/factory";
import { getUserPrivateBio, buildUserBioContext } from "@/lib/ai/user-bio";
const InputSchema = z.object({
prompt: z.string().max(300).optional(),
});
const IdeasSchema = z.object({
ideas: z.array(z.object({
title: z.string(),
description: z.string(),
tags: z.array(z.string()).max(4),
difficulty: z.enum(["easy", "medium", "hard"]),
totalMins: z.number().int().optional(),
})).length(6),
});
export async function POST(req: NextRequest) {
const { session, response } = await requireSession();
if (response) return response;
const body = await req.json() as unknown;
const parsed = InputSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: "Validation error" }, { status: 400 });
}
const limited = await applyRateLimit(`rl:ai:${session!.user.id}`, 10, 60);
if (limited) return limited;
const [config, privateBio] = await Promise.all([
getModelConfigForUseCase(session!.user.id, "text"),
getUserPrivateBio(session!.user.id),
]);
const model = resolveModel(config);
const bioContext = buildUserBioContext(privateBio);
const userContext = bioContext
? `Consider the following user preferences when suggesting ideas:${bioContext}\n\n`
: "";
const prompt = parsed.data.prompt?.trim()
? `${userContext}Generate 6 diverse recipe ideas based on: "${parsed.data.prompt}". Include a mix of difficulty levels.`
: `${userContext}Generate 6 diverse, creative recipe ideas. Include different cuisines, difficulty levels, and meal types.`;
const { object } = await generateObject({
model,
schema: IdeasSchema,
prompt,
});
return NextResponse.json(object.ideas);
}
+60
View File
@@ -0,0 +1,60 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
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 { checkAndIncrementTierLimit } from "@/lib/tiers";
import { scaleRecipe } from "@/lib/ai/features/scale-recipe";
const Schema = z.object({
recipeId: z.string(),
targetServings: z.number().int().min(1).max(100),
});
export async function POST(req: NextRequest) {
const { session, response } = await requireSession();
if (response) return response;
const limited = await applyRateLimit(`rl:ai:scale:${session!.user.id}`, 20, 60);
if (limited) return limited;
const body = await req.json() as unknown;
const parsed = Schema.safeParse(body);
if (!parsed.success) return NextResponse.json({ error: "Validation error" }, { status: 400 });
const { recipeId, targetServings } = parsed.data;
const recipe = await db.query.recipes.findFirst({
where: and(
eq(recipes.id, recipeId),
or(
eq(recipes.authorId, session!.user.id),
eq(recipes.visibility, "public")
)
),
with: { ingredients: true },
});
if (!recipe) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
await checkAndIncrementTierLimit(session!.user.id, session!.user.tier as "free" | "pro", "aiCall");
const aiConfig = await getDefaultProviderWithKey(session!.user.id);
const scaledIngredients = await scaleRecipe(
{
title: recipe.title,
baseServings: recipe.baseServings,
ingredients: recipe.ingredients,
},
targetServings,
recipe.baseServings,
aiConfig,
(session!.user as { locale?: string }).locale ?? "en"
);
return NextResponse.json({ ingredients: scaledIngredients });
}
@@ -0,0 +1,46 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { requireSession } from "@/lib/api-auth";
import { checkAndIncrementTierLimit, TierLimitError } from "@/lib/tiers";
import { applyRateLimit } from "@/lib/rate-limit";
import { substituteIngredient } from "@/lib/ai/features/substitute-ingredient";
const Schema = z.object({
ingredient: z.string().min(1).max(200),
recipeTitle: z.string().max(200).optional(),
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 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}"`
: "a general recipe";
const substitutions = await substituteIngredient(
parsed.data.ingredient,
context,
{ provider: parsed.data.provider, model: parsed.data.model }
);
return NextResponse.json({ substitutions });
}
@@ -0,0 +1,100 @@
import { NextRequest, NextResponse } from "next/server";
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 { checkAndIncrementTierLimit } from "@/lib/tiers";
import { translateRecipe } from "@/lib/ai/features/translate-recipe";
const Schema = z.object({
targetLanguage: z.string().min(2).max(50),
provider: z.enum(["openai", "anthropic", "openrouter", "ollama"]).optional(),
model: z.string().optional(),
});
type Params = { params: Promise<{ id: string }> };
export async function POST(req: NextRequest, { params }: Params) {
const { session, response } = await requireSession();
if (response) return response;
const { id } = await params;
const recipe = await db.query.recipes.findFirst({
where: and(eq(recipes.id, id), eq(recipes.authorId, session!.user.id)),
with: {
ingredients: { orderBy: (t, { asc }) => asc(t.order) },
steps: { orderBy: (t, { asc }) => asc(t.order) },
},
});
if (!recipe) return NextResponse.json({ error: "Not found" }, { status: 404 });
const body = await req.json() as unknown;
const parsed = Schema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: "Validation error", issues: parsed.error.issues }, { status: 400 });
}
await checkAndIncrementTierLimit(session!.user.id, session!.user.tier as "free" | "pro", "aiCall");
const translation = await translateRecipe(
{
title: recipe.title,
description: recipe.description,
ingredients: recipe.ingredients,
steps: recipe.steps,
},
parsed.data.targetLanguage,
{ provider: parsed.data.provider, model: parsed.data.model }
);
// Save as new draft recipe
const newId = crypto.randomUUID();
const now = new Date();
await db.transaction(async (tx) => {
await tx.insert(recipes).values({
id: newId,
authorId: session!.user.id,
title: translation.title,
description: translation.description,
baseServings: recipe.baseServings,
visibility: "private",
difficulty: recipe.difficulty,
prepMins: recipe.prepMins,
cookMins: recipe.cookMins,
dietaryTags: recipe.dietaryTags ?? {},
aiGenerated: true,
createdAt: now,
updatedAt: now,
});
if (translation.ingredients.length > 0) {
await tx.insert(recipeIngredients).values(
translation.ingredients.map((ing, i) => ({
id: crypto.randomUUID(),
recipeId: newId,
rawName: ing.rawName,
quantity: recipe.ingredients[i]?.quantity ?? undefined,
unit: recipe.ingredients[i]?.unit ?? undefined,
note: ing.note ?? undefined,
order: i,
}))
);
}
if (translation.steps.length > 0) {
await tx.insert(recipeSteps).values(
translation.steps.map((step, i) => ({
id: crypto.randomUUID(),
recipeId: newId,
instruction: step.instruction,
timerSeconds: recipe.steps[i]?.timerSeconds ?? undefined,
order: i,
}))
);
}
});
return NextResponse.json({ id: newId });
}
@@ -0,0 +1,61 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { and, eq } from "@epicure/db";
import { db, recipes } from "@epicure/db";
import { requireSession } from "@/lib/api-auth";
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),
directions: z.string().max(500).optional(),
provider: z.enum(["openai", "anthropic", "openrouter", "ollama"]).optional(),
model: z.string().optional(),
});
type Params = { params: Promise<{ id: string }> };
export async function POST(req: NextRequest, { params }: Params) {
const { session, response } = await requireSession();
if (response) return response;
const { id } = await params;
const recipe = await db.query.recipes.findFirst({
where: and(eq(recipes.id, id), eq(recipes.authorId, session!.user.id)),
with: {
ingredients: { orderBy: (t, { asc }) => asc(t.order) },
steps: { orderBy: (t, { asc }) => asc(t.order) },
},
});
if (!recipe) return NextResponse.json({ error: "Not found" }, { status: 404 });
const body = await req.json() as unknown;
const parsed = Schema.safeParse(body ?? {});
if (!parsed.success) {
return NextResponse.json({ error: "Validation error", issues: parsed.error.issues }, { status: 400 });
}
await checkAndIncrementTierLimit(session!.user.id, session!.user.tier as "free" | "pro", "aiCall");
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,
description: recipe.description,
ingredients: recipe.ingredients,
steps: recipe.steps,
},
parsed.data.count,
{ ...aiConfig, userContext: privateBio ?? undefined },
parsed.data.directions,
(session!.user as { locale?: string }).locale ?? "en"
);
return NextResponse.json({ variations });
}
@@ -0,0 +1,29 @@
import { NextRequest, NextResponse } from "next/server";
import { db, apiKeys, eq, and } from "@epicure/db";
import { requireSession } from "@/lib/api-auth";
export async function DELETE(
_req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { session, response } = await requireSession();
if (response) return response;
const { id } = await params;
const existing = await db
.select({ id: apiKeys.id })
.from(apiKeys)
.where(and(eq(apiKeys.id, id), eq(apiKeys.userId, session!.user.id)))
.limit(1);
if (existing.length === 0) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
await db
.delete(apiKeys)
.where(and(eq(apiKeys.id, id), eq(apiKeys.userId, session!.user.id)));
return new NextResponse(null, { status: 204 });
}
+66
View File
@@ -0,0 +1,66 @@
import { NextRequest, NextResponse } from "next/server";
import crypto from "node:crypto";
import { z } from "zod";
import { db, apiKeys, eq, sql } from "@epicure/db";
import { requireSession } from "@/lib/api-auth";
const CreateApiKeyBody = z.object({
name: z.string().min(1).max(100),
});
export async function GET() {
const { session, response } = await requireSession();
if (response) return response;
const rows = await db
.select({
id: apiKeys.id,
name: apiKeys.name,
lastUsedAt: apiKeys.lastUsedAt,
createdAt: apiKeys.createdAt,
})
.from(apiKeys)
.where(eq(apiKeys.userId, session!.user.id));
return NextResponse.json(rows);
}
export async function POST(req: NextRequest) {
const { session, response } = await requireSession();
if (response) return response;
const body = await req.json() as unknown;
const parsed = CreateApiKeyBody.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: "Validation error", issues: parsed.error.issues },
{ status: 400 }
);
}
const [row] = await db
.select({ count: sql<number>`count(*)::int` })
.from(apiKeys)
.where(eq(apiKeys.userId, session!.user.id));
if ((row?.count ?? 0) >= 10) {
return NextResponse.json({ error: "API key limit reached (max 10)" }, { status: 403 });
}
const rawKey = "ek_" + crypto.randomBytes(32).toString("hex");
const keyHash = crypto.createHash("sha256").update(rawKey).digest("hex");
const id = crypto.randomUUID();
const now = new Date();
await db.insert(apiKeys).values({
id,
userId: session!.user.id,
name: parsed.data.name,
keyHash,
createdAt: now,
});
return NextResponse.json(
{ id, name: parsed.data.name, key: rawKey, createdAt: now.toISOString() },
{ status: 201 }
);
}
@@ -0,0 +1,41 @@
import { NextRequest, NextResponse } from "next/server";
import { db, collections, collectionRecipes, eq, and, or } from "@epicure/db";
import { requireSession } from "@/lib/api-auth";
type Params = { params: Promise<{ id: string }> };
export async function POST(_req: NextRequest, { params }: Params) {
const { session, response } = await requireSession();
if (response) return response;
const { id } = await params;
// Allow forking public collections or own collections
const source = await db.query.collections.findFirst({
where: and(
eq(collections.id, id),
or(eq(collections.isPublic, true), eq(collections.userId, session!.user.id))
),
with: { recipes: { columns: { recipeId: true } } },
});
if (!source) return NextResponse.json({ error: "Not found" }, { status: 404 });
const newId = crypto.randomUUID();
await db.insert(collections).values({
id: newId,
userId: session!.user.id,
name: `${source.name} (fork)`,
description: source.description,
isPublic: false,
createdAt: new Date(),
updatedAt: new Date(),
});
if (source.recipes.length > 0) {
await db.insert(collectionRecipes).values(
source.recipes.map((r) => ({ collectionId: newId, recipeId: r.recipeId }))
);
}
return NextResponse.json({ id: newId }, { status: 201 });
}
@@ -0,0 +1,134 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { db, collections, collectionMembers, users, eq, and } from "@epicure/db";
import { requireSession } from "@/lib/api-auth";
type Params = { params: Promise<{ id: string }> };
// ─── GET /api/v1/collections/[id]/members ────────────────────────────────────
// Owner only — returns members joined with basic user info.
export async function GET(_req: NextRequest, { params }: Params) {
const { session, response } = await requireSession();
if (response) return response;
const { id } = await params;
// Verify ownership
const col = await db.query.collections.findFirst({
where: and(eq(collections.id, id), eq(collections.userId, session!.user.id)),
});
if (!col) return NextResponse.json({ error: "Not found" }, { status: 404 });
const members = await db.query.collectionMembers.findMany({
where: eq(collectionMembers.collectionId, id),
with: { user: true },
});
const result = members.map((m) => ({
id: m.id,
userId: m.userId,
role: m.role,
createdAt: m.createdAt,
user: {
name: m.user.name,
username: m.user.username,
avatarUrl: m.user.avatarUrl,
},
}));
return NextResponse.json(result);
}
// ─── POST /api/v1/collections/[id]/members ───────────────────────────────────
// Owner only — invite by email or userId.
const InviteSchema = z
.object({
email: z.string().email().optional(),
userId: z.string().optional(),
role: z.enum(["viewer", "editor"]),
})
.refine((d) => d.email !== undefined || d.userId !== undefined, {
message: "Provide either email or userId",
});
export async function POST(req: NextRequest, { params }: Params) {
const { session, response } = await requireSession();
if (response) return response;
const { id } = await params;
// Verify ownership
const col = await db.query.collections.findFirst({
where: and(eq(collections.id, id), eq(collections.userId, session!.user.id)),
});
if (!col) return NextResponse.json({ error: "Not found" }, { status: 404 });
const body = await req.json() as unknown;
const parsed = InviteSchema.safeParse(body);
if (!parsed.success) return NextResponse.json({ error: "Validation error" }, { status: 400 });
const { email, userId, role } = parsed.data;
// Resolve target user
const targetUser = await db.query.users.findFirst({
where: email ? eq(users.email, email) : eq(users.id, userId!),
});
if (!targetUser) return NextResponse.json({ error: "User not found" }, { status: 404 });
// Prevent owner adding themselves
if (targetUser.id === session!.user.id) {
return NextResponse.json({ error: "Cannot invite yourself" }, { status: 400 });
}
// Check not already a member
const existing = await db.query.collectionMembers.findFirst({
where: and(
eq(collectionMembers.collectionId, id),
eq(collectionMembers.userId, targetUser.id),
),
});
if (existing) return NextResponse.json({ error: "Already a member" }, { status: 409 });
const memberId = crypto.randomUUID();
await db.insert(collectionMembers).values({
id: memberId,
collectionId: id,
userId: targetUser.id,
role,
});
return NextResponse.json({ id: memberId }, { status: 201 });
}
// ─── DELETE /api/v1/collections/[id]/members?memberId=… ──────────────────────
// Owner OR the member themselves can remove.
export async function DELETE(req: NextRequest, { params }: Params) {
const { session, response } = await requireSession();
if (response) return response;
const { id } = await params;
const memberId = req.nextUrl.searchParams.get("memberId");
if (!memberId) return NextResponse.json({ error: "memberId required" }, { status: 400 });
// Load member record
const member = await db.query.collectionMembers.findFirst({
where: and(eq(collectionMembers.id, memberId), eq(collectionMembers.collectionId, id)),
});
if (!member) return NextResponse.json({ error: "Not found" }, { status: 404 });
// Load collection to check owner
const col = await db.query.collections.findFirst({
where: eq(collections.id, id),
});
const isOwner = col?.userId === session!.user.id;
const isSelf = member.userId === session!.user.id;
if (!isOwner && !isSelf) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
await db.delete(collectionMembers).where(eq(collectionMembers.id, memberId));
return new NextResponse(null, { status: 204 });
}
@@ -0,0 +1,85 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { db, collections, collectionRecipes, recipes, eq, and, or, ne } from "@epicure/db";
import { requireSession } from "@/lib/api-auth";
type Params = { params: Promise<{ id: string }> };
export async function GET(_req: NextRequest, { params }: Params) {
const { session, response } = await requireSession();
if (response) return response;
const { id } = await params;
const col = await db.query.collections.findFirst({
where: and(eq(collections.id, id), eq(collections.userId, session!.user.id)),
with: { recipes: { with: { recipe: { with: { photos: true } } } } },
});
if (!col) return NextResponse.json({ error: "Not found" }, { status: 404 });
return NextResponse.json(col);
}
export async function PUT(req: NextRequest, { params }: Params) {
const { session, response } = await requireSession();
if (response) return response;
const { id } = await params;
const existing = await db.query.collections.findFirst({
where: and(eq(collections.id, id), eq(collections.userId, session!.user.id)),
});
if (!existing) return NextResponse.json({ error: "Not found" }, { status: 404 });
const body = await req.json() as unknown;
const parsed = z.object({
name: z.string().min(1).max(100).optional(),
description: z.string().max(500).optional(),
isPublic: z.boolean().optional(),
addRecipeId: z.string().optional(),
removeRecipeId: z.string().optional(),
}).safeParse(body);
if (!parsed.success) return NextResponse.json({ error: "Validation error" }, { status: 400 });
const data = parsed.data;
if (data.name || data.description !== undefined || data.isPublic !== undefined) {
await db.update(collections).set({
...(data.name && { name: data.name }),
...(data.description !== undefined && { description: data.description }),
...(data.isPublic !== undefined && { isPublic: data.isPublic }),
updatedAt: new Date(),
}).where(eq(collections.id, id));
}
if (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();
}
}
if (data.removeRecipeId) {
await db.delete(collectionRecipes).where(
and(eq(collectionRecipes.collectionId, id), eq(collectionRecipes.recipeId, data.removeRecipeId))
);
}
return NextResponse.json({ updated: true });
}
export async function DELETE(_req: NextRequest, { params }: Params) {
const { session, response } = await requireSession();
if (response) return response;
const { id } = await params;
const existing = await db.query.collections.findFirst({
where: and(eq(collections.id, id), eq(collections.userId, session!.user.id)),
});
if (!existing) return NextResponse.json({ error: "Not found" }, { status: 404 });
await db.delete(collections).where(eq(collections.id, id));
return new NextResponse(null, { status: 204 });
}
+43
View File
@@ -0,0 +1,43 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { db, collections, eq, desc } from "@epicure/db";
import { requireSession } from "@/lib/api-auth";
const Schema = z.object({
name: z.string().min(1).max(100),
description: z.string().max(500).optional(),
isPublic: z.boolean().default(false),
});
export async function GET(_req: NextRequest) {
const { session, response } = await requireSession();
if (response) return response;
const rows = await db.query.collections.findMany({
where: eq(collections.userId, session!.user.id),
orderBy: desc(collections.updatedAt),
with: { recipes: { limit: 4, with: { recipe: { with: { photos: true } } } } },
});
return NextResponse.json(rows);
}
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 id = crypto.randomUUID();
await db.insert(collections).values({
id,
userId: session!.user.id,
name: parsed.data.name,
description: parsed.data.description,
isPublic: parsed.data.isPublic,
});
return NextResponse.json({ id }, { status: 201 });
}
@@ -0,0 +1,39 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { db, comments, eq, and } from "@epicure/db";
import { requireSession } from "@/lib/api-auth";
type Params = { params: Promise<{ id: string }> };
export async function PUT(req: NextRequest, { params }: Params) {
const { session, response } = await requireSession();
if (response) return response;
const { id } = await params;
const comment = await db.query.comments.findFirst({ where: eq(comments.id, id) });
if (!comment || comment.userId !== session!.user.id) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
const body = await req.json() as unknown;
const parsed = z.object({ content: z.string().min(1).max(5000) }).safeParse(body);
if (!parsed.success) return NextResponse.json({ error: "Validation error" }, { status: 400 });
await db.update(comments).set({ content: parsed.data.content, updatedAt: new Date() }).where(eq(comments.id, id));
return NextResponse.json({ updated: true });
}
export async function DELETE(_req: NextRequest, { params }: Params) {
const { session, response } = await requireSession();
if (response) return response;
const { id } = await params;
const comment = await db.query.comments.findFirst({ where: eq(comments.id, id) });
if (!comment) return NextResponse.json({ error: "Not found" }, { status: 404 });
if (comment.userId !== session!.user.id && session!.user.role === "user") {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
await db.delete(comments).where(eq(comments.id, id));
return new NextResponse(null, { status: 204 });
}
+51
View File
@@ -0,0 +1,51 @@
import { NextRequest, NextResponse } from "next/server";
import { db, recipes, users, userFollows, eq, and, ne } from "@epicure/db";
import { requireSession } from "@/lib/api-auth";
import { desc, inArray } from "@epicure/db";
export async function GET(req: NextRequest) {
const { session, response } = await requireSession();
if (response) return response;
const { searchParams } = new URL(req.url);
const limit = Math.min(parseInt(searchParams.get("limit") ?? "20"), 50);
const offset = parseInt(searchParams.get("offset") ?? "0");
// Get IDs of users the current user follows
const followedRows = await db
.select({ followingId: userFollows.followingId })
.from(userFollows)
.where(eq(userFollows.followerId, session!.user.id));
const followedIds = followedRows.map((r) => r.followingId);
if (followedIds.length === 0) {
return NextResponse.json({ data: [], limit, offset, message: "Follow some users to see their recipes here." });
}
const feedRecipes = await db
.select({
id: recipes.id,
title: recipes.title,
description: recipes.description,
baseServings: recipes.baseServings,
prepMins: recipes.prepMins,
cookMins: recipes.cookMins,
difficulty: recipes.difficulty,
visibility: recipes.visibility,
createdAt: recipes.createdAt,
updatedAt: recipes.updatedAt,
authorId: recipes.authorId,
authorName: users.name,
authorUsername: users.username,
authorAvatarUrl: users.avatarUrl,
})
.from(recipes)
.innerJoin(users, eq(recipes.authorId, users.id))
.where((t) => and(inArray(t.authorId, followedIds), ne(recipes.visibility, "private")))
.orderBy(desc(recipes.createdAt))
.limit(limit)
.offset(offset);
return NextResponse.json({ data: feedRecipes, limit, offset });
}
@@ -0,0 +1,42 @@
import { NextRequest, NextResponse } from "next/server";
import { db, recipes, users, favorites, eq, desc, sql, and, gte } from "@epicure/db";
export async function GET(req: NextRequest) {
const { searchParams } = new URL(req.url);
const limit = Math.min(parseInt(searchParams.get("limit") ?? "20"), 50);
const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
const trending = await db
.select({
id: recipes.id,
title: recipes.title,
description: recipes.description,
baseServings: recipes.baseServings,
prepMins: recipes.prepMins,
cookMins: recipes.cookMins,
difficulty: recipes.difficulty,
visibility: recipes.visibility,
aiGenerated: recipes.aiGenerated,
createdAt: recipes.createdAt,
authorId: recipes.authorId,
authorName: users.name,
authorUsername: users.username,
authorAvatarUrl: users.avatarUrl,
favoriteCount: sql<number>`cast(count(${favorites.recipeId}) as int)`,
})
.from(recipes)
.innerJoin(users, eq(recipes.authorId, users.id))
.leftJoin(
favorites,
and(eq(favorites.recipeId, recipes.id), gte(favorites.createdAt, sevenDaysAgo))
)
.where(eq(recipes.visibility, "public"))
.groupBy(recipes.id, users.id)
.orderBy(desc(sql`count(${favorites.recipeId})`), desc(recipes.createdAt))
.limit(limit);
return NextResponse.json({
data: trending.map((r) => ({ ...r, createdAt: r.createdAt.toISOString() })),
});
}
@@ -0,0 +1,22 @@
import { NextRequest, NextResponse } from "next/server";
import { db, mealPlans, mealPlanEntries, eq, and } from "@epicure/db";
import { requireSession } from "@/lib/api-auth";
type Params = { params: Promise<{ weekStart: string; entryId: string }> };
export async function DELETE(_req: NextRequest, { params }: Params) {
const { session, response } = await requireSession();
if (response) return response;
const { weekStart, entryId } = await params;
const plan = await db.query.mealPlans.findFirst({
where: and(eq(mealPlans.userId, session!.user.id), eq(mealPlans.weekStart, weekStart)),
});
if (!plan) return NextResponse.json({ error: "Not found" }, { status: 404 });
await db.delete(mealPlanEntries).where(
and(eq(mealPlanEntries.id, entryId), eq(mealPlanEntries.mealPlanId, plan.id))
);
return new NextResponse(null, { status: 204 });
}
@@ -0,0 +1,61 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { db, mealPlans, mealPlanEntries, eq, and } from "@epicure/db";
import { requireSession } from "@/lib/api-auth";
import { dispatchWebhook } from "@/lib/webhooks";
type Params = { params: Promise<{ weekStart: string }> };
const Schema = z.object({
day: z.enum(["mon", "tue", "wed", "thu", "fri", "sat", "sun"]),
mealType: z.enum(["breakfast", "lunch", "dinner", "snack"]),
recipeId: z.string().optional(),
servings: z.number().int().min(1).max(100).default(2),
note: z.string().max(500).optional(),
});
async function getOrCreatePlan(userId: string, weekStart: string) {
const existing = await db.query.mealPlans.findFirst({
where: and(eq(mealPlans.userId, userId), eq(mealPlans.weekStart, weekStart)),
});
if (existing) return existing;
const id = crypto.randomUUID();
await db.insert(mealPlans).values({ id, userId, weekStart });
return { id, userId, weekStart };
}
export async function POST(req: NextRequest, { params }: Params) {
const { session, response } = await requireSession();
if (response) return response;
const { weekStart } = await params;
const body = await req.json() as unknown;
const parsed = Schema.safeParse(body);
if (!parsed.success) return NextResponse.json({ error: "Validation error" }, { status: 400 });
const plan = await getOrCreatePlan(session!.user.id, weekStart);
// Remove existing entry for same day+mealType before inserting
await db.delete(mealPlanEntries).where(
and(
eq(mealPlanEntries.mealPlanId, plan.id),
eq(mealPlanEntries.day, parsed.data.day),
eq(mealPlanEntries.mealType, parsed.data.mealType)
)
);
const entryId = crypto.randomUUID();
await db.insert(mealPlanEntries).values({
id: entryId,
mealPlanId: plan.id,
day: parsed.data.day,
mealType: parsed.data.mealType,
recipeId: parsed.data.recipeId,
servings: parsed.data.servings,
note: parsed.data.note,
});
void dispatchWebhook(session!.user.id, "meal_plan.updated", { weekStart, day: parsed.data.day, mealType: parsed.data.mealType });
return NextResponse.json({ id: entryId }, { status: 201 });
}
@@ -0,0 +1,85 @@
import { NextRequest, NextResponse } from "next/server";
import { db, mealPlans, mealPlanEntries, recipes, userNutritionGoals, eq, and } from "@epicure/db";
import { requireSession } from "@/lib/api-auth";
type Params = { params: Promise<{ weekStart: string }> };
export async function GET(_req: NextRequest, { params }: Params) {
const { session, response } = await requireSession();
if (response) return response;
const { weekStart } = await params;
const userId = session!.user.id;
// Find the meal plan for this week
const plan = await db.query.mealPlans.findFirst({
where: and(eq(mealPlans.userId, userId), eq(mealPlans.weekStart, weekStart)),
});
const totals = { calories: 0, protein: 0, carbs: 0, fat: 0 };
if (plan) {
// Fetch all entries with their recipes
const entries = await db.query.mealPlanEntries.findMany({
where: eq(mealPlanEntries.mealPlanId, plan.id),
with: {
recipe: {
columns: {
id: true,
baseServings: true,
nutritionData: true,
},
},
},
});
for (const entry of entries) {
const recipe = entry.recipe;
if (!recipe || !recipe.nutritionData?.perServing) continue;
const { calories, proteinG, carbsG, fatG } = recipe.nutritionData.perServing;
const servings = entry.servings ?? recipe.baseServings;
totals.calories += Math.round(calories * servings);
totals.protein += Math.round(proteinG * servings);
totals.carbs += Math.round(carbsG * servings);
totals.fat += Math.round(fatG * servings);
}
}
// Fetch user's nutrition goals
const goals = await db.query.userNutritionGoals.findFirst({
where: eq(userNutritionGoals.userId, userId),
});
const goalsData = goals
? {
caloriesKcal: goals.caloriesKcal,
proteinG: goals.proteinG,
carbsG: goals.carbsG,
fatG: goals.fatG,
}
: null;
// Calculate coverage percentages
const coverage = {
calories:
goalsData?.caloriesKcal
? Math.round((totals.calories / goalsData.caloriesKcal) * 100)
: 0,
protein:
goalsData?.proteinG
? Math.round((totals.protein / goalsData.proteinG) * 100)
: 0,
carbs:
goalsData?.carbsG
? Math.round((totals.carbs / goalsData.carbsG) * 100)
: 0,
fat:
goalsData?.fatG
? Math.round((totals.fat / goalsData.fatG) * 100)
: 0,
};
return NextResponse.json({ totals, goals: goalsData, coverage });
}
@@ -0,0 +1,43 @@
import { NextRequest, NextResponse } from "next/server";
import { db, mealPlans, eq, and } from "@epicure/db";
import { requireSession } from "@/lib/api-auth";
type Params = { params: Promise<{ weekStart: string }> };
export async function GET(_req: NextRequest, { params }: Params) {
const { session, response } = await requireSession();
if (response) return response;
const { weekStart } = await params;
const plan = await db.query.mealPlans.findFirst({
where: and(eq(mealPlans.userId, session!.user.id), eq(mealPlans.weekStart, weekStart)),
with: {
entries: {
with: {
recipe: {
with: { photos: true },
},
},
},
},
});
if (!plan) return NextResponse.json({ weekStart, entries: [] });
return NextResponse.json(plan);
}
export async function POST(_req: NextRequest, { params }: Params) {
const { session, response } = await requireSession();
if (response) return response;
const { weekStart } = await params;
const existing = await db.query.mealPlans.findFirst({
where: and(eq(mealPlans.userId, session!.user.id), eq(mealPlans.weekStart, weekStart)),
});
if (existing) return NextResponse.json(existing);
const id = crypto.randomUUID();
await db.insert(mealPlans).values({ id, userId: session!.user.id, weekStart });
return NextResponse.json({ id, weekStart }, { status: 201 });
}
@@ -0,0 +1,8 @@
import { generateOpenApiSpec } from "@/lib/openapi";
export async function GET() {
const spec = generateOpenApiSpec();
return Response.json(spec, {
headers: { "Access-Control-Allow-Origin": "*" },
});
}
+43
View File
@@ -0,0 +1,43 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { db, pantryItems, eq, and } from "@epicure/db";
import { requireSession } from "@/lib/api-auth";
type Params = { params: Promise<{ id: string }> };
export async function PUT(req: NextRequest, { params }: Params) {
const { session, response } = await requireSession();
if (response) return response;
const { id } = await params;
const item = await db.query.pantryItems.findFirst({ where: and(eq(pantryItems.id, id), eq(pantryItems.userId, session!.user.id)) });
if (!item) return NextResponse.json({ error: "Not found" }, { status: 404 });
const body = await req.json() as unknown;
const parsed = z.object({
rawName: z.string().min(1).max(200).optional(),
quantity: z.string().nullable().optional(),
unit: z.string().nullable().optional(),
expiresAt: z.string().datetime().nullable().optional(),
}).safeParse(body);
if (!parsed.success) return NextResponse.json({ error: "Validation error" }, { status: 400 });
const data = parsed.data;
await db.update(pantryItems).set({
...(data.rawName && { rawName: data.rawName }),
...(data.quantity !== undefined && { quantity: data.quantity ?? undefined }),
...(data.unit !== undefined && { unit: data.unit ?? undefined }),
...(data.expiresAt !== undefined && { expiresAt: data.expiresAt ? new Date(data.expiresAt) : undefined }),
}).where(eq(pantryItems.id, id));
return NextResponse.json({ updated: true });
}
export async function DELETE(_req: NextRequest, { params }: Params) {
const { session, response } = await requireSession();
if (response) return response;
const { id } = await params;
await db.delete(pantryItems).where(and(eq(pantryItems.id, id), eq(pantryItems.userId, session!.user.id)));
return new NextResponse(null, { status: 204 });
}
+55
View File
@@ -0,0 +1,55 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { db, pantryItems, eq } from "@epicure/db";
import { requireSession } from "@/lib/api-auth";
const Schema = z.object({
items: z.array(z.object({
rawName: z.string().min(1).max(200),
quantity: z.string().optional(),
unit: z.string().max(50).optional(),
})).min(1).max(100),
});
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 userId = session!.user.id;
const existing = await db.query.pantryItems.findMany({
where: eq(pantryItems.userId, userId),
});
for (const incoming of parsed.data.items) {
const key = incoming.rawName.toLowerCase();
const match = existing.find(
(e) => e.rawName.toLowerCase() === key && (e.unit ?? "") === (incoming.unit ?? "")
);
if (match) {
const existingQty = match.quantity ? parseFloat(match.quantity) : null;
const incomingQty = incoming.quantity ? parseFloat(incoming.quantity) : null;
if (existingQty !== null && incomingQty !== null && !isNaN(existingQty) && !isNaN(incomingQty)) {
const merged = existingQty + incomingQty;
await db.update(pantryItems)
.set({ quantity: String(merged) })
.where(eq(pantryItems.id, match.id));
}
// if quantities aren't numeric, leave as-is (item already exists)
} else {
await db.insert(pantryItems).values({
id: crypto.randomUUID(),
userId,
rawName: incoming.rawName,
quantity: incoming.quantity,
unit: incoming.unit,
});
}
}
return NextResponse.json({ ok: true });
}
+44
View File
@@ -0,0 +1,44 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { db, pantryItems, eq, desc } from "@epicure/db";
import { requireSession } from "@/lib/api-auth";
const Schema = z.object({
rawName: z.string().min(1).max(200),
quantity: z.string().optional(),
unit: z.string().optional(),
expiresAt: z.string().datetime().optional(),
});
export async function GET(_req: NextRequest) {
const { session, response } = await requireSession();
if (response) return response;
const items = await db.query.pantryItems.findMany({
where: eq(pantryItems.userId, session!.user.id),
orderBy: desc(pantryItems.createdAt),
});
return NextResponse.json(items);
}
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 id = crypto.randomUUID();
await db.insert(pantryItems).values({
id,
userId: session!.user.id,
rawName: parsed.data.rawName,
quantity: parsed.data.quantity,
unit: parsed.data.unit,
expiresAt: parsed.data.expiresAt ? new Date(parsed.data.expiresAt) : undefined,
});
return NextResponse.json({ id }, { status: 201 });
}
@@ -0,0 +1,71 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { db, pushSubscriptions, eq, and } from "@epicure/db";
import { requireSession } from "@/lib/api-auth";
const subscribeSchema = z.object({
endpoint: z.string().url(),
keys: z.object({
p256dh: z.string(),
auth: z.string(),
}),
});
const unsubscribeSchema = z.object({
endpoint: z.string(),
});
export async function POST(request: Request) {
const { session, response } = await requireSession();
if (response) return response;
const json = await request.json();
const parsed = subscribeSchema.safeParse(json);
if (!parsed.success) {
return NextResponse.json({ error: "Invalid body" }, { status: 400 });
}
const body = parsed.data;
await db
.insert(pushSubscriptions)
.values({
id: crypto.randomUUID(),
userId: session!.user.id,
endpoint: body.endpoint,
p256dh: body.keys.p256dh,
auth: body.keys.auth,
})
.onConflictDoUpdate({
target: pushSubscriptions.endpoint,
set: {
p256dh: body.keys.p256dh,
auth: body.keys.auth,
},
setWhere: eq(pushSubscriptions.userId, session!.user.id),
});
return NextResponse.json({ ok: true });
}
export async function DELETE(request: Request) {
const { session, response } = await requireSession();
if (response) return response;
const json = await request.json();
const parsed = unsubscribeSchema.safeParse(json);
if (!parsed.success) {
return NextResponse.json({ error: "Invalid body" }, { status: 400 });
}
await db
.delete(pushSubscriptions)
.where(
and(
eq(pushSubscriptions.endpoint, parsed.data.endpoint),
eq(pushSubscriptions.userId, session!.user.id)
)
);
return NextResponse.json({ ok: true });
}
@@ -30,7 +30,8 @@ export async function GET(req: NextRequest, { params }: Params) {
counts[row.type] = row.cnt;
}
// Check for session to return user's reactions
// Optional auth — GET reactions is public; session present means also return user's own reactions
// response intentionally ignored: unauthenticated callers get counts only with empty userReactions
const { session } = await requireSession();
let userReactions: string[] = [];
@@ -6,7 +6,7 @@ import { requireSession } from "@/lib/api-auth";
type Params = { params: Promise<{ id: string }> };
const Schema = z.object({
servings: z.number().int().min(1).optional(),
servings: z.number().int().min(1).max(1000).optional(),
notes: z.string().max(2000).optional(),
deductFromPantry: z.boolean().default(true),
});
@@ -56,7 +56,7 @@ export async function POST(_req: NextRequest, { params }: Params) {
})),
});
await db.update(recipes).set({ nutritionData: result }).where(eq(recipes.id, id));
await db.update(recipes).set({ nutritionData: result }).where(and(eq(recipes.id, id), eq(recipes.authorId, session!.user.id)));
return NextResponse.json({ nutrition: result });
}
+14 -11
View File
@@ -4,15 +4,17 @@ import { eq, and, max } from "@epicure/db";
import { z } from "zod";
import { requireSessionOrApiKey } from "@/lib/api-auth";
import { dispatchWebhook } from "@/lib/webhooks";
import { parseQuantity } from "@/lib/parse-quantity";
const UpdateRecipeSchema = z.object({
title: z.string().min(1).max(200).optional(),
description: z.string().optional(),
description: z.string().max(2000).optional(),
baseServings: z.number().int().min(1).max(100).optional(),
visibility: z.enum(["private", "unlisted", "public"]).optional(),
difficulty: z.enum(["easy", "medium", "hard"]).nullable().optional(),
prepMins: z.number().int().min(0).nullable().optional(),
cookMins: z.number().int().min(0).nullable().optional(),
prepMins: z.number().int().min(0).max(1440).nullable().optional(),
cookMins: z.number().int().min(0).max(1440).nullable().optional(),
tags: z.array(z.string().min(1).max(50)).max(20).optional(),
dietaryTags: z.object({
vegan: z.boolean().optional(),
vegetarian: z.boolean().optional(),
@@ -23,17 +25,17 @@ const UpdateRecipeSchema = z.object({
kosher: z.boolean().optional(),
}).optional(),
ingredients: z.array(z.object({
rawName: z.string().min(1),
quantity: z.string().optional(),
unit: z.string().optional(),
note: z.string().optional(),
rawName: z.string().min(1).max(200),
quantity: z.union([z.number(), z.string().max(50)]).optional().transform(parseQuantity),
unit: z.string().max(50).optional(),
note: z.string().max(500).optional(),
order: z.number().int().default(0),
})).optional(),
})).max(100).optional(),
steps: z.array(z.object({
instruction: z.string().min(1),
timerSeconds: z.number().int().optional(),
instruction: z.string().min(1).max(2000),
timerSeconds: z.number().int().min(0).max(86400).optional(),
order: z.number().int(),
})).optional(),
})).max(100).optional(),
});
type Params = { params: Promise<{ id: string }> };
@@ -119,6 +121,7 @@ export async function PUT(req: NextRequest, { params }: Params) {
if (data.difficulty !== undefined) updates.difficulty = data.difficulty ?? undefined;
if (data.prepMins !== undefined) updates.prepMins = data.prepMins ?? undefined;
if (data.cookMins !== undefined) updates.cookMins = data.cookMins ?? undefined;
if (data.tags !== undefined) updates.tags = data.tags;
if (data.dietaryTags !== undefined) updates.dietaryTags = data.dietaryTags;
await tx.update(recipes).set(updates).where(eq(recipes.id, id));
@@ -0,0 +1,154 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
// --- Mock dependencies ---
const mockSession = {
user: { id: "user-1", name: "Test User", email: "test@test.com", tier: "free", role: "user" },
};
vi.mock("@/lib/api-auth", () => ({
requireSessionOrApiKey: vi.fn(),
requireSession: vi.fn(),
}));
vi.mock("@/lib/tiers", () => ({
checkTierLimit: vi.fn(),
incrementUsage: vi.fn(),
TierLimitError: class TierLimitError extends Error {},
}));
vi.mock("@/lib/webhooks", () => ({
dispatchWebhook: vi.fn(),
}));
vi.mock("@/lib/rate-limit", () => ({
applyRateLimit: vi.fn().mockResolvedValue(null),
}));
const { mockTransaction, mockInsertValues, mockSelectChain } = vi.hoisted(() => {
const mockInsertValues = vi.fn().mockResolvedValue(undefined);
const mockSelectChain = {
from: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
orderBy: vi.fn().mockReturnThis(),
limit: vi.fn().mockReturnThis(),
offset: vi.fn().mockResolvedValue([]),
};
return { mockTransaction: vi.fn(), mockInsertValues, mockSelectChain };
});
vi.mock("@epicure/db", () => ({
db: {
select: vi.fn(() => mockSelectChain),
insert: vi.fn(() => ({ values: mockInsertValues })),
transaction: mockTransaction,
query: {
recipes: { findFirst: vi.fn().mockResolvedValue({ id: "new-id", title: "Test Recipe" }) },
},
},
recipes: { id: "id", authorId: "author_id", title: "title", visibility: "visibility" },
recipeIngredients: {},
recipeSteps: {},
eq: vi.fn((a, b) => ({ a, b, op: "eq" })),
desc: vi.fn((a) => ({ a, op: "desc" })),
and: vi.fn((...args) => ({ args, op: "and" })),
count: vi.fn(() => ({ op: "count" })),
ilike: vi.fn((a, b) => ({ a, b, op: "ilike" })),
or: vi.fn((...args) => ({ args, op: "or" })),
sql: vi.fn(),
}));
const { requireSessionOrApiKey } = await import("@/lib/api-auth");
import { GET, POST } from "../route";
function makeRequest(method: string, body?: unknown, search = "") {
const url = `http://localhost/api/v1/recipes${search}`;
return new NextRequest(url, {
method,
headers: { "Content-Type": "application/json" },
body: body ? JSON.stringify(body) : undefined,
});
}
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(requireSessionOrApiKey).mockResolvedValue({ session: mockSession as never, response: null });
mockTransaction.mockImplementation(async (fn: (tx: unknown) => Promise<unknown>) => {
const tx = {
insert: vi.fn(() => ({ values: vi.fn().mockResolvedValue(undefined) })),
};
return fn(tx);
});
});
describe("GET /api/v1/recipes", () => {
it("returns 200 with empty list", async () => {
mockSelectChain.offset.mockResolvedValue([]);
const res = await GET(makeRequest("GET"));
expect(res.status).toBe(200);
const body = await res.json() as { data: unknown[] };
expect(Array.isArray(body.data)).toBe(true);
});
it("returns 401 when not authenticated", async () => {
vi.mocked(requireSessionOrApiKey).mockResolvedValue({
session: null,
response: new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401 }),
} as never);
const res = await GET(makeRequest("GET"));
expect(res.status).toBe(401);
});
});
describe("POST /api/v1/recipes", () => {
const validRecipe = {
title: "Test Recipe",
baseServings: 4,
visibility: "private",
ingredients: [{ rawName: "flour", quantity: "2", unit: "cups" }],
steps: [{ instruction: "Mix everything" }],
};
it("returns 201 on valid recipe creation", async () => {
const res = await POST(makeRequest("POST", validRecipe));
expect(res.status).toBe(201);
const body = await res.json() as { id: string };
expect(body.id).toBeTruthy();
});
it("returns 400 on invalid body (missing title)", async () => {
const res = await POST(makeRequest("POST", { baseServings: 4 }));
expect(res.status).toBe(400);
});
it("returns 400 when title is empty", async () => {
const res = await POST(makeRequest("POST", { ...validRecipe, title: "" }));
expect(res.status).toBe(400);
});
it("returns 400 when title is too long", async () => {
const res = await POST(makeRequest("POST", { ...validRecipe, title: "x".repeat(201) }));
expect(res.status).toBe(400);
});
it("returns 403 when tier limit reached", async () => {
const { checkTierLimit } = await import("@/lib/tiers");
const { TierLimitError } = await import("@/lib/tiers");
vi.mocked(checkTierLimit).mockRejectedValue(new TierLimitError("recipe", "free"));
const res = await POST(makeRequest("POST", validRecipe));
expect(res.status).toBe(403);
});
it("returns 401 when not authenticated", async () => {
vi.mocked(requireSessionOrApiKey).mockResolvedValue({
session: null,
response: new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401 }),
} as never);
const res = await POST(makeRequest("POST", validRecipe));
expect(res.status).toBe(401);
});
});
@@ -0,0 +1,96 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
const mockSession = {
user: { id: "user-1", name: "Test", email: "t@t.com", tier: "free" },
};
const { mockRequireSession } = vi.hoisted(() => ({
mockRequireSession: vi.fn(),
}));
vi.mock("@/lib/api-auth", () => ({
requireSession: mockRequireSession,
}));
vi.mock("@epicure/db", () => ({
db: {
delete: vi.fn(() => ({ where: vi.fn().mockResolvedValue(undefined) })),
update: vi.fn(() => ({
set: vi.fn(() => ({ where: vi.fn().mockResolvedValue(undefined) })),
})),
},
recipes: { id: "id", authorId: "author_id", visibility: "visibility" },
eq: vi.fn(),
and: vi.fn(),
inArray: vi.fn(),
}));
import { DELETE, PATCH } from "../route";
function makeRequest(method: string, body: unknown) {
return new NextRequest(`http://localhost/api/v1/recipes/bulk`, {
method,
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
}
beforeEach(() => {
vi.clearAllMocks();
mockRequireSession.mockResolvedValue({ session: mockSession, response: null });
});
describe("DELETE /api/v1/recipes/bulk", () => {
it("returns 200 on valid ids", async () => {
const res = await DELETE(makeRequest("DELETE", { ids: ["r1", "r2"] }));
expect(res.status).toBe(200);
const body = await res.json() as { ok: boolean };
expect(body.ok).toBe(true);
});
it("returns 400 when ids is empty array", async () => {
const res = await DELETE(makeRequest("DELETE", { ids: [] }));
expect(res.status).toBe(400);
});
it("returns 400 when body is invalid", async () => {
const res = await DELETE(makeRequest("DELETE", { notIds: "wrong" }));
expect(res.status).toBe(400);
});
it("returns 401 when not authenticated", async () => {
mockRequireSession.mockResolvedValue({
session: null,
response: new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401 }),
});
const res = await DELETE(makeRequest("DELETE", { ids: ["r1"] }));
expect(res.status).toBe(401);
});
});
describe("PATCH /api/v1/recipes/bulk", () => {
it("returns 200 on valid visibility update", async () => {
const res = await PATCH(makeRequest("PATCH", { ids: ["r1"], visibility: "public" }));
expect(res.status).toBe(200);
});
it("returns 400 when visibility is missing", async () => {
const res = await PATCH(makeRequest("PATCH", { ids: ["r1"] }));
expect(res.status).toBe(400);
});
it("returns 400 when ids is empty", async () => {
const res = await PATCH(makeRequest("PATCH", { ids: [], visibility: "public" }));
expect(res.status).toBe(400);
});
it("returns 401 when not authenticated", async () => {
mockRequireSession.mockResolvedValue({
session: null,
response: new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401 }),
});
const res = await PATCH(makeRequest("PATCH", { ids: ["r1"], visibility: "public" }));
expect(res.status).toBe(401);
});
});
+6 -6
View File
@@ -13,22 +13,22 @@ const BulkUpdateSchema = z.object({
});
export async function DELETE(req: NextRequest) {
const session = await requireSession();
if (session instanceof NextResponse) return session;
const { session, response } = await requireSession();
if (response) return response;
const body = BulkDeleteSchema.safeParse(await req.json());
if (!body.success) return NextResponse.json({ error: "Invalid request" }, { status: 400 });
await db
.delete(recipes)
.where(and(inArray(recipes.id, body.data.ids), eq(recipes.authorId, session.user.id)));
.where(and(inArray(recipes.id, body.data.ids), eq(recipes.authorId, session!.user.id)));
return NextResponse.json({ ok: true });
}
export async function PATCH(req: NextRequest) {
const session = await requireSession();
if (session instanceof NextResponse) return session;
const { session, response } = await requireSession();
if (response) return response;
const body = BulkUpdateSchema.safeParse(await req.json());
if (!body.success) return NextResponse.json({ error: "Invalid request" }, { status: 400 });
@@ -39,7 +39,7 @@ export async function PATCH(req: NextRequest) {
await db
.update(recipes)
.set({ visibility, updatedAt: new Date() })
.where(and(inArray(recipes.id, ids), eq(recipes.authorId, session.user.id)));
.where(and(inArray(recipes.id, ids), eq(recipes.authorId, session!.user.id)));
return NextResponse.json({ ok: true });
}
+24 -52
View File
@@ -3,56 +3,21 @@ import { db, recipes, recipeIngredients, recipeSteps } from "@epicure/db";
import { eq, desc, and } from "@epicure/db";
import { z } from "zod";
import { requireSessionOrApiKey } from "@/lib/api-auth";
import { checkTierLimit, incrementUsage } from "@/lib/tiers";
import { checkAndIncrementTierLimit, TierLimitError } from "@/lib/tiers";
import { dispatchWebhook } from "@/lib/webhooks";
const UNICODE_FRACTIONS: Record<string, number> = {
"½": 0.5, "⅓": 1/3, "⅔": 2/3, "¼": 0.25, "¾": 0.75,
"⅕": 0.2, "⅖": 0.4, "⅗": 0.6, "⅘": 0.8,
"⅙": 1/6, "⅚": 5/6, "⅛": 0.125, "⅜": 0.375, "⅝": 0.625, "⅞": 0.875,
};
function parseQuantity(v: string | number | undefined): string | undefined {
if (v === undefined || v === "") return undefined;
const s = String(v).trim();
if (!s) return undefined;
if (UNICODE_FRACTIONS[s] !== undefined) return String(UNICODE_FRACTIONS[s]);
for (const [frac, val] of Object.entries(UNICODE_FRACTIONS)) {
if (s.endsWith(frac)) {
const whole = s.slice(0, -frac.length).trim();
if (!whole) return String(val);
const w = parseFloat(whole);
if (!isNaN(w)) return String(w + val);
}
}
const mixed = s.match(/^(\d+)\s+(\d+)\s*\/\s*(\d+)$/);
if (mixed) {
const den = parseInt(mixed[3]!);
if (den !== 0) return String(parseInt(mixed[1]!) + parseInt(mixed[2]!) / den);
}
const slash = s.match(/^(\d+)\s*\/\s*(\d+)$/);
if (slash) {
const den = parseInt(slash[2]!);
if (den !== 0) return String(parseInt(slash[1]!) / den);
}
const n = parseFloat(s);
return isNaN(n) ? undefined : String(n);
}
import { parseQuantity } from "@/lib/parse-quantity";
const CreateRecipeSchema = z.object({
title: z.string().min(1).max(200),
description: z.string().optional(),
description: z.string().max(2000).optional(),
baseServings: z.number().int().min(1).max(100).default(4),
visibility: z.enum(["private", "unlisted", "public"]).default("private"),
difficulty: z.enum(["easy", "medium", "hard"]).optional(),
prepMins: z.number().int().min(0).optional(),
cookMins: z.number().int().min(0).optional(),
prepMins: z.number().int().min(0).max(1440).optional(),
cookMins: z.number().int().min(0).max(1440).optional(),
tags: z.array(z.string().min(1).max(50)).max(20).default([]),
aiGenerated: z.boolean().optional(),
language: z.string().max(10).optional(),
dietaryTags: z.object({
vegan: z.boolean().optional(),
vegetarian: z.boolean().optional(),
@@ -63,17 +28,17 @@ const CreateRecipeSchema = z.object({
kosher: z.boolean().optional(),
}).optional(),
ingredients: z.array(z.object({
rawName: z.string().min(1),
rawName: z.string().min(1).max(200),
quantity: z.union([z.number(), z.string()]).optional().transform(parseQuantity),
unit: z.string().optional(),
note: z.string().optional(),
unit: z.string().max(50).optional(),
note: z.string().max(500).optional(),
order: z.number().int().default(0),
})).default([]),
})).max(100).default([]),
steps: z.array(z.object({
instruction: z.string().min(1),
timerSeconds: z.number().int().optional(),
instruction: z.string().min(1).max(2000),
timerSeconds: z.number().int().min(0).max(86400).optional(),
order: z.number().int().optional(),
})).default([]),
})).max(100).default([]),
});
export async function GET(req: NextRequest) {
@@ -109,7 +74,14 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: "Validation error", issues: parsed.error.issues }, { status: 400 });
}
await checkTierLimit(session!.user.id, session!.user.tier as "free" | "pro", "recipe");
try {
await checkAndIncrementTierLimit(session!.user.id, session!.user.tier as "free" | "pro", "recipe");
} catch (err) {
if (err instanceof TierLimitError) {
return NextResponse.json({ error: "Recipe limit reached for your tier" }, { status: 403 });
}
throw err;
}
const id = crypto.randomUUID();
const now = new Date();
@@ -126,8 +98,10 @@ export async function POST(req: NextRequest) {
difficulty: data.difficulty,
prepMins: data.prepMins,
cookMins: data.cookMins,
tags: data.tags,
dietaryTags: data.dietaryTags ?? {},
aiGenerated: data.aiGenerated ?? false,
language: data.language,
createdAt: now,
updatedAt: now,
});
@@ -159,8 +133,6 @@ export async function POST(req: NextRequest) {
}
});
await incrementUsage(session!.user.id, "recipe");
const recipe = await db.query.recipes.findFirst({ where: eq(recipes.id, id) });
void dispatchWebhook(session!.user.id, "recipe.created", { id, title: data.title });
return NextResponse.json(recipe, { status: 201 });
+124
View File
@@ -0,0 +1,124 @@
import { NextRequest, NextResponse } from "next/server";
import {
db,
recipes,
users,
eq,
and,
or,
ilike,
sql,
desc,
} from "@epicure/db";
const VALID_DIETARY = ["vegan", "vegetarian", "glutenFree", "dairyFree"] as const;
type DietaryTag = (typeof VALID_DIETARY)[number];
export async function GET(req: NextRequest) {
const { searchParams } = req.nextUrl;
// --- Parse & validate required param ---
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." },
{ status: 400 }
);
}
// --- Optional params ---
const difficultyParam = searchParams.get("difficulty");
const difficulty =
difficultyParam === "easy" ||
difficultyParam === "medium" ||
difficultyParam === "hard"
? (difficultyParam as "easy" | "medium" | "hard")
: undefined;
const maxMinsRaw = searchParams.get("maxMins");
const maxMins =
maxMinsRaw !== null && !Number.isNaN(Number(maxMinsRaw))
? Number(maxMinsRaw)
: undefined;
const limitRaw = searchParams.get("limit");
const limit = Math.min(
limitRaw !== null && !Number.isNaN(Number(limitRaw))
? Math.max(1, Number(limitRaw))
: 20,
50
);
const offsetRaw = searchParams.get("offset");
const offset =
offsetRaw !== null && !Number.isNaN(Number(offsetRaw))
? Math.max(0, Number(offsetRaw))
: 0;
const dietaryRaw = searchParams.get("dietary");
const dietaryTags: DietaryTag[] = dietaryRaw
? (dietaryRaw
.split(",")
.map((s) => s.trim())
.filter((s): s is DietaryTag =>
(VALID_DIETARY as readonly string[]).includes(s)
))
: [];
// --- Build WHERE conditions ---
const conditions = [
eq(recipes.visibility, "public"),
or(
ilike(recipes.title, `%${q}%`),
ilike(recipes.description, `%${q}%`)
)!,
];
if (difficulty) {
conditions.push(eq(recipes.difficulty, difficulty));
}
if (maxMins !== undefined) {
conditions.push(
sql`(${recipes.prepMins} + ${recipes.cookMins}) <= ${maxMins}`
);
}
for (const tag of dietaryTags) {
conditions.push(sql`${recipes.dietaryTags}->>${tag} = 'true'`);
}
const where = and(...conditions);
// --- Main data query ---
const rows = await db
.select({
id: recipes.id,
title: recipes.title,
description: recipes.description,
difficulty: recipes.difficulty,
baseServings: recipes.baseServings,
prepMins: recipes.prepMins,
cookMins: recipes.cookMins,
authorId: recipes.authorId,
authorName: users.name,
createdAt: recipes.createdAt,
})
.from(recipes)
.innerJoin(users, eq(recipes.authorId, users.id))
.where(where)
.orderBy(desc(recipes.createdAt))
.limit(limit)
.offset(offset);
// --- Count query ---
const countResult = await db
.select({ total: sql<number>`count(*)::int` })
.from(recipes)
.innerJoin(users, eq(recipes.authorId, users.id))
.where(where);
const total = countResult[0]?.total ?? 0;
return NextResponse.json({ data: rows, total, limit, offset });
}
@@ -0,0 +1,23 @@
import { NextRequest, NextResponse } from "next/server";
import { db, shoppingLists, shoppingListItems, eq, and } from "@epicure/db";
import { requireSession } from "@/lib/api-auth";
type Params = { params: Promise<{ id: string; itemId: string }> };
export async function PUT(req: NextRequest, { params }: Params) {
const { session, response } = await requireSession();
if (response) return response;
const { id, itemId } = await params;
const list = await db.query.shoppingLists.findFirst({
where: and(eq(shoppingLists.id, id), eq(shoppingLists.userId, session!.user.id)),
});
if (!list) return NextResponse.json({ error: "Not found" }, { status: 404 });
const body = await req.json() as { checked?: boolean };
await db.update(shoppingListItems)
.set({ checked: body.checked ?? false })
.where(and(eq(shoppingListItems.id, itemId), eq(shoppingListItems.listId, id)));
return NextResponse.json({ updated: true });
}
@@ -0,0 +1,44 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { db, shoppingLists, shoppingListItems, eq, and } from "@epicure/db";
import { requireSession } from "@/lib/api-auth";
const AddItemsSchema = z.object({
items: z.array(z.object({
rawName: z.string().min(1),
quantity: z.string().optional(),
unit: z.string().optional(),
aisle: z.string().optional(),
})).min(1),
});
type Params = { params: Promise<{ id: string }> };
export async function POST(req: NextRequest, { params }: Params) {
const { session, response } = await requireSession();
if (response) return response;
const { id } = await params;
const list = await db.query.shoppingLists.findFirst({
where: and(eq(shoppingLists.id, id), eq(shoppingLists.userId, session!.user.id)),
});
if (!list) return NextResponse.json({ error: "Not found" }, { status: 404 });
const body = await req.json() as unknown;
const parsed = AddItemsSchema.safeParse(body);
if (!parsed.success) return NextResponse.json({ error: "Validation error" }, { status: 400 });
await db.insert(shoppingListItems).values(
parsed.data.items.map((item) => ({
id: crypto.randomUUID(),
listId: id,
rawName: item.rawName,
quantity: item.quantity,
unit: item.unit,
aisle: item.aisle,
checked: false,
}))
);
return NextResponse.json({ ok: true }, { status: 201 });
}
@@ -0,0 +1,54 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { db, shoppingLists, shoppingListItems, eq, and } from "@epicure/db";
import { requireSession } from "@/lib/api-auth";
import { dispatchWebhook } from "@/lib/webhooks";
type Params = { params: Promise<{ id: string }> };
export async function GET(_req: NextRequest, { params }: Params) {
const { session, response } = await requireSession();
if (response) return response;
const { id } = await params;
const 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) return NextResponse.json({ error: "Not found" }, { status: 404 });
return NextResponse.json(list);
}
const PatchSchema = z.object({ completed: z.boolean() });
export async function PATCH(req: NextRequest, { params }: Params) {
const { session, response } = await requireSession();
if (response) return response;
const { id } = await params;
const list = await db.query.shoppingLists.findFirst({
where: and(eq(shoppingLists.id, id), eq(shoppingLists.userId, session!.user.id)),
});
if (!list) return NextResponse.json({ error: "Not found" }, { status: 404 });
const body = PatchSchema.safeParse(await req.json());
if (!body.success) return NextResponse.json({ error: "Validation error" }, { status: 400 });
if (body.data.completed) {
// Mark all items as checked
await db.update(shoppingListItems).set({ checked: true }).where(eq(shoppingListItems.listId, id));
void dispatchWebhook(session!.user.id, "shopping_list.completed", { id, name: list.name });
}
return NextResponse.json({ updated: true });
}
export async function DELETE(_req: NextRequest, { params }: Params) {
const { session, response } = await requireSession();
if (response) return response;
const { id } = await params;
await db.delete(shoppingLists).where(and(eq(shoppingLists.id, id), eq(shoppingLists.userId, session!.user.id)));
return new NextResponse(null, { status: 204 });
}
@@ -0,0 +1,91 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { db, shoppingLists, shoppingListItems, mealPlans, mealPlanEntries, recipeIngredients, eq, and, desc, inArray } from "@epicure/db";
import { requireSession } from "@/lib/api-auth";
const CreateSchema = z.object({
name: z.string().min(1).max(100),
items: z.array(z.object({
rawName: z.string().min(1),
quantity: z.string().optional(),
unit: z.string().optional(),
aisle: z.string().optional(),
})).optional(),
fromMealPlanWeek: z.string().optional(),
});
export async function GET(_req: NextRequest) {
const { session, response } = await requireSession();
if (response) return response;
const lists = await db.query.shoppingLists.findMany({
where: eq(shoppingLists.userId, session!.user.id),
orderBy: desc(shoppingLists.createdAt),
with: { items: { orderBy: (t, { asc }) => asc(t.aisle) } },
});
return NextResponse.json(lists);
}
export async function POST(req: NextRequest) {
const { session, response } = await requireSession();
if (response) return response;
const body = await req.json() as unknown;
const parsed = CreateSchema.safeParse(body);
if (!parsed.success) return NextResponse.json({ error: "Validation error" }, { status: 400 });
const data = parsed.data;
const listId = crypto.randomUUID();
let items: Array<{ rawName: string; quantity?: string; unit?: string; aisle?: string }> = data.items ?? [];
if (data.fromMealPlanWeek) {
const plan = await db.query.mealPlans.findFirst({
where: and(eq(mealPlans.userId, session!.user.id), eq(mealPlans.weekStart, data.fromMealPlanWeek)),
with: { entries: true },
});
if (plan) {
const recipeIds = plan.entries.map((e) => e.recipeId).filter(Boolean) as string[];
if (recipeIds.length > 0) {
const ings = await db.query.recipeIngredients.findMany({
where: inArray(recipeIngredients.recipeId, recipeIds),
});
// Simple merge by rawName (case-insensitive)
const merged = new Map<string, { rawName: string; quantity?: string; unit?: string }>();
for (const ing of ings) {
const key = ing.rawName.toLowerCase();
if (!merged.has(key)) {
merged.set(key, { rawName: ing.rawName, quantity: ing.quantity ?? undefined, unit: ing.unit ?? undefined });
}
}
items = Array.from(merged.values());
}
}
}
await db.insert(shoppingLists).values({
id: listId,
userId: session!.user.id,
name: data.name,
generatedAt: data.fromMealPlanWeek ? new Date() : undefined,
});
if (items.length > 0) {
await db.insert(shoppingListItems).values(
items.map((item) => ({
id: crypto.randomUUID(),
listId,
rawName: item.rawName,
quantity: item.quantity,
unit: item.unit,
aisle: item.aisle,
checked: false,
}))
);
}
return NextResponse.json({ id: listId }, { status: 201 });
}
@@ -0,0 +1,38 @@
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];
const Schema = z.object({
contentType: z.string().refine((t): t is AllowedType => (ALLOWED_TYPES as readonly string[]).includes(t), {
message: "Content type must be jpeg, png, webp, or avif",
}),
recipeId: z.string().uuid(),
});
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", 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);
return NextResponse.json({ url, key });
}
@@ -0,0 +1,36 @@
import { NextRequest, NextResponse } from "next/server";
import { db, users, userFollows, eq, and } from "@epicure/db";
import { requireSession } from "@/lib/api-auth";
type Params = { params: Promise<{ username: string }> };
export async function POST(_req: NextRequest, { params }: Params) {
const { session, response } = await requireSession();
if (response) return response;
const { username } = await params;
const target = await db.query.users.findFirst({ where: eq(users.username, username) });
if (!target) return NextResponse.json({ error: "Not found" }, { status: 404 });
if (target.id === session!.user.id) return NextResponse.json({ error: "Cannot follow yourself" }, { status: 400 });
await db.insert(userFollows)
.values({ followerId: session!.user.id, followingId: target.id })
.onConflictDoNothing();
return NextResponse.json({ following: true });
}
export async function DELETE(_req: NextRequest, { params }: Params) {
const { session, response } = await requireSession();
if (response) return response;
const { username } = await params;
const target = await db.query.users.findFirst({ where: eq(users.username, username) });
if (!target) return NextResponse.json({ error: "Not found" }, { status: 404 });
await db.delete(userFollows).where(
and(eq(userFollows.followerId, session!.user.id), eq(userFollows.followingId, target.id))
);
return NextResponse.json({ following: false });
}
@@ -0,0 +1,57 @@
import { NextRequest, NextResponse } from "next/server";
import { headers } from "next/headers";
import { auth } from "@/lib/auth/server";
import { db, users, recipes, userFollows, eq, and, count } from "@epicure/db";
type Params = { params: Promise<{ username: string }> };
export async function GET(req: NextRequest, { params }: Params) {
const { username } = await params;
const user = await db.query.users.findFirst({ where: eq(users.username, username) });
if (!user) return NextResponse.json({ error: "Not found" }, { status: 404 });
const [followerCountRow, followingCountRow, recipeCountRow] = await Promise.all([
db
.select({ count: count() })
.from(userFollows)
.where(eq(userFollows.followingId, user.id)),
db
.select({ count: count() })
.from(userFollows)
.where(eq(userFollows.followerId, user.id)),
db
.select({ count: count() })
.from(recipes)
.where(and(eq(recipes.authorId, user.id), eq(recipes.visibility, "public"))),
]);
let isFollowing = false;
try {
const session = await auth.api.getSession({ headers: await headers() });
if (session && session.user.id !== user.id) {
const followRow = await db.query.userFollows.findFirst({
where: and(
eq(userFollows.followerId, session.user.id),
eq(userFollows.followingId, user.id),
),
});
isFollowing = !!followRow;
}
} catch {
// unauthenticated — isFollowing stays false
}
return NextResponse.json({
id: user.id,
name: user.name,
username: user.username,
avatarUrl: user.avatarUrl,
bio: user.bio,
createdAt: user.createdAt,
followerCount: followerCountRow[0]?.count ?? 0,
followingCount: followingCountRow[0]?.count ?? 0,
recipeCount: recipeCountRow[0]?.count ?? 0,
isFollowing,
});
}
@@ -0,0 +1,42 @@
import { type NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { requireSession } from "@/lib/api-auth";
import { db, userModelPrefs, eq } from "@epicure/db";
const Schema = z.object({
textProvider: z.string().nullable().optional(),
textModel: z.string().nullable().optional(),
visionProvider: z.string().nullable().optional(),
visionModel: z.string().nullable().optional(),
mealPlanProvider: z.string().nullable().optional(),
mealPlanModel: z.string().nullable().optional(),
});
export async function GET() {
const { session, response } = await requireSession();
if (response) return response;
const prefs = await db.query.userModelPrefs.findFirst({
where: eq(userModelPrefs.userId, session!.user.id),
});
return NextResponse.json(prefs ?? null);
}
export async function PUT(req: NextRequest) {
const { session, response } = await requireSession();
if (response) return response;
const body = Schema.safeParse(await req.json());
if (!body.success) return NextResponse.json({ error: "Invalid request" }, { status: 400 });
await db
.insert(userModelPrefs)
.values({ id: crypto.randomUUID(), userId: session!.user.id, ...body.data, updatedAt: new Date() })
.onConflictDoUpdate({
target: userModelPrefs.userId,
set: { ...body.data, updatedAt: new Date() },
});
return NextResponse.json({ ok: true });
}
@@ -0,0 +1,45 @@
import { NextRequest, NextResponse } from "next/server";
import { db, userNutritionGoals, eq } from "@epicure/db";
import { requireSession } from "@/lib/api-auth";
import { z } from "zod";
const PutSchema = z.object({
caloriesKcal: z.number().int().min(0).optional(),
proteinG: z.number().int().min(0).optional(),
carbsG: z.number().int().min(0).optional(),
fatG: z.number().int().min(0).optional(),
});
export async function GET() {
const { session, response } = await requireSession();
if (response) return response;
const goals = await db.query.userNutritionGoals.findFirst({
where: eq(userNutritionGoals.userId, session!.user.id),
});
return NextResponse.json({ data: goals ?? null });
}
export async function PUT(req: NextRequest) {
const { session, response } = await requireSession();
if (response) return response;
const parsed = PutSchema.safeParse(await req.json());
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
}
const body = parsed.data;
const userId = session!.user.id;
await db
.insert(userNutritionGoals)
.values({ id: crypto.randomUUID(), userId, ...body, updatedAt: new Date() })
.onConflictDoUpdate({
target: userNutritionGoals.userId,
set: { ...body, updatedAt: new Date() },
});
return NextResponse.json({ ok: true });
}
+23
View File
@@ -0,0 +1,23 @@
import { NextResponse } from "next/server";
import { headers } from "next/headers";
import { auth } from "@/lib/auth/server";
import { db, users, eq } from "@epicure/db";
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) {
const session = await auth.api.getSession({ headers: await headers() });
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const body = PatchSchema.safeParse(await req.json());
if (!body.success) return NextResponse.json({ error: body.error.flatten() }, { status: 400 });
await db.update(users).set(body.data).where(eq(users.id, session.user.id));
return NextResponse.json({ ok: true });
}
@@ -0,0 +1,27 @@
import { NextRequest, NextResponse } from "next/server";
import { db, webhooks, webhookDeliveries, eq, and, desc } from "@epicure/db";
import { requireSession } from "@/lib/api-auth";
type Params = { params: Promise<{ id: string }> };
export async function GET(_req: NextRequest, { params }: Params) {
const { session, response } = await requireSession();
if (response) return response;
const { id } = await params;
const hook = await db.query.webhooks.findFirst({
where: and(eq(webhooks.id, id), eq(webhooks.userId, session!.user.id)),
columns: { id: true },
});
if (!hook) return NextResponse.json({ error: "Not found" }, { status: 404 });
const deliveries = await db
.select()
.from(webhookDeliveries)
.where(eq(webhookDeliveries.webhookId, id))
.orderBy(desc(webhookDeliveries.createdAt))
.limit(20);
return NextResponse.json(deliveries);
}
@@ -0,0 +1,41 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { db, webhooks, webhookDeliveries, eq, and } from "@epicure/db";
import { requireSession } from "@/lib/api-auth";
import { dispatchWebhook, type WebhookEvent } from "@/lib/webhooks";
const Schema = z.object({ deliveryId: z.string().uuid() });
type Params = { params: Promise<{ id: string }> };
export async function POST(req: NextRequest, { params }: Params) {
const { session, response } = await requireSession();
if (response) return response;
const { id } = await params;
const hook = await db.query.webhooks.findFirst({
where: and(eq(webhooks.id, id), eq(webhooks.userId, session!.user.id)),
columns: { id: true },
});
if (!hook) return NextResponse.json({ error: "Not found" }, { status: 404 });
const body = Schema.safeParse(await req.json());
if (!body.success) return NextResponse.json({ error: body.error.flatten() }, { status: 400 });
const delivery = await db.query.webhookDeliveries.findFirst({
where: and(
eq(webhookDeliveries.id, body.data.deliveryId),
eq(webhookDeliveries.webhookId, id)
),
});
if (!delivery) return NextResponse.json({ error: "Delivery not found" }, { status: 404 });
void dispatchWebhook(
session!.user.id,
delivery.event as WebhookEvent,
(delivery.payload ?? {}) as object
);
return NextResponse.json({ ok: true });
}
+104
View File
@@ -0,0 +1,104 @@
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;
const UpdateWebhookBody = z.object({
url: z.string().min(1).max(2048).optional(),
events: z.array(z.enum(VALID_EVENTS)).optional(),
active: z.boolean().optional(),
});
export async function DELETE(
_req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { session, response } = await requireSession();
if (response) return response;
const { id } = await params;
const existing = await db
.select({ id: webhooks.id })
.from(webhooks)
.where(and(eq(webhooks.id, id), eq(webhooks.userId, session!.user.id)))
.limit(1);
if (existing.length === 0) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
await db
.delete(webhooks)
.where(and(eq(webhooks.id, id), eq(webhooks.userId, session!.user.id)));
return new NextResponse(null, { status: 204 });
}
export async function PATCH(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { session, response } = await requireSession();
if (response) return response;
const { id } = await params;
const existing = await db
.select({ id: webhooks.id })
.from(webhooks)
.where(and(eq(webhooks.id, id), eq(webhooks.userId, session!.user.id)))
.limit(1);
if (existing.length === 0) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
const body = await req.json() as unknown;
const parsed = UpdateWebhookBody.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: "Validation error", issues: parsed.error.issues },
{ status: 400 }
);
}
if (parsed.data.url) {
const ssrfError = await validateWebhookUrl(parsed.data.url);
if (ssrfError) {
return NextResponse.json({ error: ssrfError }, { status: 400 });
}
}
const updates: Partial<{ url: string; events: string[]; active: boolean }> = {};
if (parsed.data.url !== undefined) updates.url = parsed.data.url;
if (parsed.data.events !== undefined) updates.events = parsed.data.events;
if (parsed.data.active !== undefined) updates.active = parsed.data.active;
if (Object.keys(updates).length === 0) {
return NextResponse.json({ error: "No fields to update" }, { status: 400 });
}
await db
.update(webhooks)
.set(updates)
.where(and(eq(webhooks.id, id), eq(webhooks.userId, session!.user.id)));
const updated = await db
.select({
id: webhooks.id,
userId: webhooks.userId,
url: webhooks.url,
events: webhooks.events,
active: webhooks.active,
createdAt: webhooks.createdAt,
})
.from(webhooks)
.where(eq(webhooks.id, id))
.limit(1);
return NextResponse.json(updated[0]);
}
+85
View File
@@ -0,0 +1,85 @@
import { NextRequest, NextResponse } from "next/server";
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;
const CreateWebhookBody = z.object({
url: z.string().min(1).max(2048),
events: z.array(z.enum(VALID_EVENTS)).default([]),
});
const UpdateWebhookBody = z.object({
url: z.string().min(1).max(2048).optional(),
events: z.array(z.enum(VALID_EVENTS)).optional(),
active: z.boolean().optional(),
});
export async function GET() {
const { session, response } = await requireSession();
if (response) return response;
const rows = await db
.select({
id: webhooks.id,
userId: webhooks.userId,
url: webhooks.url,
events: webhooks.events,
active: webhooks.active,
createdAt: webhooks.createdAt,
})
.from(webhooks)
.where(eq(webhooks.userId, session!.user.id));
return NextResponse.json(rows);
}
export async function POST(req: NextRequest) {
const { session, response } = await requireSession();
if (response) return response;
const body = await req.json() as unknown;
const parsed = CreateWebhookBody.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: "Validation error", issues: parsed.error.issues },
{ 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");
const id = crypto.randomUUID();
const now = new Date();
await db.insert(webhooks).values({
id,
userId: session!.user.id,
url: parsed.data.url,
events: parsed.data.events,
secret,
active: true,
createdAt: now,
});
return NextResponse.json(
{
id,
userId: session!.user.id,
url: parsed.data.url,
events: parsed.data.events,
secret,
active: true,
createdAt: now.toISOString(),
},
{ status: 201 }
);
}
+93
View File
@@ -0,0 +1,93 @@
import { NextRequest, NextResponse } from "next/server";
import crypto from "node:crypto";
// Stripe webhook handler — verifies stripe-signature header using HMAC-SHA256.
// Handles:
// - checkout.session.completed → upgrade user to pro
// - customer.subscription.deleted → downgrade user to free
const STRIPE_TOLERANCE_SECONDS = 300; // 5 minutes
function verifyStripeSignature(
rawBody: string,
sigHeader: string,
secret: string
): { valid: boolean; payload: string | null } {
// sigHeader format: "t=<timestamp>,v1=<hmac>[,v1=<hmac>...]"
const parts = sigHeader.split(",");
const tPart = parts.find((p) => p.startsWith("t="));
const v1Parts = parts.filter((p) => p.startsWith("v1="));
if (!tPart || v1Parts.length === 0) {
return { valid: false, payload: null };
}
const timestamp = tPart.slice(2);
const tsNum = parseInt(timestamp, 10);
if (isNaN(tsNum)) return { valid: false, payload: null };
// Reject stale webhooks
const nowSec = Math.floor(Date.now() / 1000);
if (Math.abs(nowSec - tsNum) > STRIPE_TOLERANCE_SECONDS) {
return { valid: false, payload: null };
}
const signedPayload = `${timestamp}.${rawBody}`;
const expected = crypto
.createHmac("sha256", secret)
.update(signedPayload, "utf8")
.digest();
const matched = v1Parts.some((v1Part) => {
const provided = v1Part.slice(3); // strip "v1="
let providedBuf: Buffer;
try {
providedBuf = Buffer.from(provided, "hex");
} catch {
return false;
}
if (providedBuf.length !== expected.length) return false;
return crypto.timingSafeEqual(expected, providedBuf);
});
return { valid: matched, payload: matched ? rawBody : null };
}
export async function POST(req: NextRequest) {
const body = await req.text();
const sig = req.headers.get("stripe-signature");
const webhookSecret = process.env["STRIPE_WEBHOOK_SECRET"];
if (!sig || !webhookSecret) {
return NextResponse.json({ error: "Stripe not configured" }, { status: 400 });
}
const { valid } = verifyStripeSignature(body, sig, webhookSecret);
if (!valid) {
return NextResponse.json({ error: "Invalid signature" }, { status: 400 });
}
let event: { type: string; data: { object: Record<string, unknown> } };
try {
event = JSON.parse(body) as typeof event;
} catch {
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
}
// TODO: wire up DB calls when Stripe billing is fully configured
switch (event.type) {
case "checkout.session.completed":
// upgrade user to pro
console.log("[stripe-webhook] checkout.session.completed", event.data.object["id"]);
break;
case "customer.subscription.deleted":
// downgrade user to free
console.log("[stripe-webhook] customer.subscription.deleted", event.data.object["id"]);
break;
default:
// ignore unhandled event types
break;
}
return NextResponse.json({ received: true });
}
+15
View File
@@ -0,0 +1,15 @@
export async function GET() {
const html = `<!DOCTYPE html>
<html>
<head>
<title>Epicure API Reference</title>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
</head>
<body>
<script id="api-reference" data-url="/api/v1/openapi.json"></script>
<script src="https://cdn.jsdelivr.net/npm/@scalar/api-reference"></script>
</body>
</html>`;
return new Response(html, { headers: { "Content-Type": "text/html" } });
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

+18
View File
@@ -0,0 +1,18 @@
import Link from "next/link";
export default function OfflinePage() {
return (
<div className="flex min-h-screen flex-col items-center justify-center gap-4 p-8 text-center">
<h1 className="text-2xl font-semibold">You're offline</h1>
<p className="max-w-sm text-muted-foreground">
Your recently visited recipes are available. Connect to internet to load new content.
</p>
<Link
href="/recipes"
className="mt-2 underline underline-offset-4 hover:text-primary"
>
Back to my recipes
</Link>
</div>
);
}

Some files were not shown because too many files have changed in this diff Show More