Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8b57a3fd87 | |||
| 9d9dfb46c6 | |||
| 3f96d1ea41 | |||
| d6032edc00 | |||
| cba5d9c3ac | |||
| b2d592afe8 | |||
| e179692adf | |||
| 3636ab27ae | |||
| 9d02a69250 | |||
| d9d58fd01a |
@@ -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
|
||||
@@ -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
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
}))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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 ?? ""} />;
|
||||
}
|
||||
@@ -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() }))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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" }))}>
|
||||
<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) {
|
||||
}))}
|
||||
/>
|
||||
)}
|
||||
{(!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" }))}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger render={
|
||||
<Link href={`/recipes/${id}/edit`} className={cn(buttonVariants({ variant: "ghost", size: "icon" }))}>
|
||||
<Pencil className="h-4 w-4" />
|
||||
Edit
|
||||
</Link>
|
||||
} />
|
||||
<TooltipContent>Edit</TooltipContent>
|
||||
</Tooltip>
|
||||
<DeleteRecipeButton recipeId={id} />
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
|
||||
{avgScore !== null && (
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -210,6 +230,24 @@ export default async function RecipePage({ params }: Params) {
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Empty state */}
|
||||
{recipe.ingredients.length === 0 && recipe.steps.length === 0 && (
|
||||
<div className="flex flex-col items-center gap-4 py-12 text-center">
|
||||
<p className="text-muted-foreground">No ingredients or steps yet.</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<GenerateContentButton
|
||||
recipeId={id}
|
||||
title={recipe.title}
|
||||
description={recipe.description}
|
||||
/>
|
||||
<Link href={`/recipes/${id}/edit`} className={cn(buttonVariants({ variant: "outline", size: "sm" }))}>
|
||||
<Pencil className="h-4 w-4" />
|
||||
Edit manually
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Serving scaler + ingredients */}
|
||||
{recipe.ingredients.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
@@ -285,6 +323,8 @@ export default async function RecipePage({ params }: Params) {
|
||||
<CommentsSection recipeId={id} currentUserId={session.user.id} />
|
||||
</>
|
||||
)}
|
||||
|
||||
<RecipeChatPanel recipeId={id} recipeTitle={recipe.title} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,48 +1,75 @@
|
||||
import type { Metadata } from "next";
|
||||
import { headers } from "next/headers";
|
||||
import { auth } from "@/lib/auth/server";
|
||||
import { db, recipes, recipeIngredients } from "@epicure/db";
|
||||
import { eq, desc, and, ilike, or, sql } from "@epicure/db";
|
||||
import { db, recipes, sql } from "@epicure/db";
|
||||
import { eq, desc, asc, and, ilike, or } from "@epicure/db";
|
||||
import { RecipesHeader } from "@/components/recipe/recipes-header";
|
||||
import { RecipesEmptyState } from "@/components/recipe/recipes-empty-state";
|
||||
import { RecipesGrid } from "@/components/recipe/recipes-grid";
|
||||
|
||||
export const metadata: Metadata = { title: "Recipes" };
|
||||
|
||||
type SearchParams = Promise<{ q?: string }>;
|
||||
type SearchParams = Promise<{
|
||||
q?: string;
|
||||
sort?: string;
|
||||
visibility?: string;
|
||||
difficulty?: string;
|
||||
tag?: string;
|
||||
}>;
|
||||
|
||||
const SORT_MAP = {
|
||||
updated_desc: desc(recipes.updatedAt),
|
||||
updated_asc: asc(recipes.updatedAt),
|
||||
created_desc: desc(recipes.createdAt),
|
||||
created_asc: asc(recipes.createdAt),
|
||||
title_asc: asc(recipes.title),
|
||||
title_desc: desc(recipes.title),
|
||||
} as const;
|
||||
|
||||
type SortKey = keyof typeof SORT_MAP;
|
||||
|
||||
export default async function RecipesPage({ searchParams }: { searchParams: SearchParams }) {
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
if (!session) return null;
|
||||
|
||||
const { q } = await searchParams;
|
||||
const query = q?.trim() ?? "";
|
||||
const { q, sort, visibility, difficulty, tag } = await searchParams;
|
||||
const query = (q ?? "").trim().slice(0, 200);
|
||||
const sortKey: SortKey = (sort && sort in SORT_MAP ? sort : "updated_desc") as SortKey;
|
||||
const tagFilter = tag?.trim().slice(0, 50) || undefined;
|
||||
|
||||
const whereClause = query
|
||||
? and(
|
||||
eq(recipes.authorId, session.user.id),
|
||||
or(
|
||||
ilike(recipes.title, `%${query}%`),
|
||||
ilike(recipes.description, `%${query}%`)
|
||||
)
|
||||
)
|
||||
: eq(recipes.authorId, session.user.id);
|
||||
const visibilityFilter = visibility && ["private", "unlisted", "public"].includes(visibility)
|
||||
? (visibility as "private" | "unlisted" | "public")
|
||||
: undefined;
|
||||
const difficultyFilter = difficulty && ["easy", "medium", "hard"].includes(difficulty)
|
||||
? (difficulty as "easy" | "medium" | "hard")
|
||||
: undefined;
|
||||
|
||||
const userRecipes = await db.query.recipes.findMany({
|
||||
where: whereClause,
|
||||
orderBy: desc(recipes.updatedAt),
|
||||
where: and(
|
||||
eq(recipes.authorId, session.user.id),
|
||||
query
|
||||
? or(ilike(recipes.title, `%${query}%`), ilike(recipes.description, `%${query}%`))
|
||||
: undefined,
|
||||
visibilityFilter ? eq(recipes.visibility, visibilityFilter) : undefined,
|
||||
difficultyFilter ? eq(recipes.difficulty, difficultyFilter) : undefined,
|
||||
tagFilter ? sql`${recipes.tags} @> ARRAY[${tagFilter}]::text[]` : undefined,
|
||||
),
|
||||
orderBy: SORT_MAP[sortKey],
|
||||
with: { photos: true },
|
||||
});
|
||||
|
||||
const totalCount = query ? undefined : userRecipes.length;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<RecipesHeader count={totalCount ?? userRecipes.length} initialQuery={query} />
|
||||
|
||||
<RecipesHeader
|
||||
count={userRecipes.length}
|
||||
initialQuery={query}
|
||||
initialSort={sortKey}
|
||||
initialVisibility={visibilityFilter ?? ""}
|
||||
initialDifficulty={difficultyFilter ?? ""}
|
||||
initialTag={tagFilter ?? ""}
|
||||
/>
|
||||
<RecipesEmptyState query={query} count={userRecipes.length} />
|
||||
|
||||
<RecipesGrid recipes={userRecipes} />
|
||||
<RecipesGrid key={`${query}-${sortKey}-${visibilityFilter}-${difficultyFilter}-${tagFilter}`} recipes={userRecipes} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 ?? ""} />;
|
||||
}
|
||||
@@ -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'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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 > 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,
|
||||
}))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 & 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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": "*" },
|
||||
});
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -4,15 +4,17 @@ import { eq, and, max } from "@epicure/db";
|
||||
import { z } from "zod";
|
||||
import { requireSessionOrApiKey } from "@/lib/api-auth";
|
||||
import { dispatchWebhook } from "@/lib/webhooks";
|
||||
import { parseQuantity } from "@/lib/parse-quantity";
|
||||
|
||||
const UpdateRecipeSchema = z.object({
|
||||
title: z.string().min(1).max(200).optional(),
|
||||
description: z.string().optional(),
|
||||
description: z.string().max(2000).optional(),
|
||||
baseServings: z.number().int().min(1).max(100).optional(),
|
||||
visibility: z.enum(["private", "unlisted", "public"]).optional(),
|
||||
difficulty: z.enum(["easy", "medium", "hard"]).nullable().optional(),
|
||||
prepMins: z.number().int().min(0).nullable().optional(),
|
||||
cookMins: z.number().int().min(0).nullable().optional(),
|
||||
prepMins: z.number().int().min(0).max(1440).nullable().optional(),
|
||||
cookMins: z.number().int().min(0).max(1440).nullable().optional(),
|
||||
tags: z.array(z.string().min(1).max(50)).max(20).optional(),
|
||||
dietaryTags: z.object({
|
||||
vegan: z.boolean().optional(),
|
||||
vegetarian: z.boolean().optional(),
|
||||
@@ -23,17 +25,17 @@ const UpdateRecipeSchema = z.object({
|
||||
kosher: z.boolean().optional(),
|
||||
}).optional(),
|
||||
ingredients: z.array(z.object({
|
||||
rawName: z.string().min(1),
|
||||
quantity: z.string().optional(),
|
||||
unit: z.string().optional(),
|
||||
note: z.string().optional(),
|
||||
rawName: z.string().min(1).max(200),
|
||||
quantity: z.union([z.number(), z.string().max(50)]).optional().transform(parseQuantity),
|
||||
unit: z.string().max(50).optional(),
|
||||
note: z.string().max(500).optional(),
|
||||
order: z.number().int().default(0),
|
||||
})).optional(),
|
||||
})).max(100).optional(),
|
||||
steps: z.array(z.object({
|
||||
instruction: z.string().min(1),
|
||||
timerSeconds: z.number().int().optional(),
|
||||
instruction: z.string().min(1).max(2000),
|
||||
timerSeconds: z.number().int().min(0).max(86400).optional(),
|
||||
order: z.number().int(),
|
||||
})).optional(),
|
||||
})).max(100).optional(),
|
||||
});
|
||||
|
||||
type Params = { params: Promise<{ id: string }> };
|
||||
@@ -119,6 +121,7 @@ export async function PUT(req: NextRequest, { params }: Params) {
|
||||
if (data.difficulty !== undefined) updates.difficulty = data.difficulty ?? undefined;
|
||||
if (data.prepMins !== undefined) updates.prepMins = data.prepMins ?? undefined;
|
||||
if (data.cookMins !== undefined) updates.cookMins = data.cookMins ?? undefined;
|
||||
if (data.tags !== undefined) updates.tags = data.tags;
|
||||
if (data.dietaryTags !== undefined) updates.dietaryTags = data.dietaryTags;
|
||||
|
||||
await tx.update(recipes).set(updates).where(eq(recipes.id, id));
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
// --- Mock dependencies ---
|
||||
const mockSession = {
|
||||
user: { id: "user-1", name: "Test User", email: "test@test.com", tier: "free", role: "user" },
|
||||
};
|
||||
|
||||
vi.mock("@/lib/api-auth", () => ({
|
||||
requireSessionOrApiKey: vi.fn(),
|
||||
requireSession: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/tiers", () => ({
|
||||
checkTierLimit: vi.fn(),
|
||||
incrementUsage: vi.fn(),
|
||||
TierLimitError: class TierLimitError extends Error {},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/webhooks", () => ({
|
||||
dispatchWebhook: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
applyRateLimit: vi.fn().mockResolvedValue(null),
|
||||
}));
|
||||
|
||||
const { mockTransaction, mockInsertValues, mockSelectChain } = vi.hoisted(() => {
|
||||
const mockInsertValues = vi.fn().mockResolvedValue(undefined);
|
||||
const mockSelectChain = {
|
||||
from: vi.fn().mockReturnThis(),
|
||||
where: vi.fn().mockReturnThis(),
|
||||
orderBy: vi.fn().mockReturnThis(),
|
||||
limit: vi.fn().mockReturnThis(),
|
||||
offset: vi.fn().mockResolvedValue([]),
|
||||
};
|
||||
return { mockTransaction: vi.fn(), mockInsertValues, mockSelectChain };
|
||||
});
|
||||
|
||||
vi.mock("@epicure/db", () => ({
|
||||
db: {
|
||||
select: vi.fn(() => mockSelectChain),
|
||||
insert: vi.fn(() => ({ values: mockInsertValues })),
|
||||
transaction: mockTransaction,
|
||||
query: {
|
||||
recipes: { findFirst: vi.fn().mockResolvedValue({ id: "new-id", title: "Test Recipe" }) },
|
||||
},
|
||||
},
|
||||
recipes: { id: "id", authorId: "author_id", title: "title", visibility: "visibility" },
|
||||
recipeIngredients: {},
|
||||
recipeSteps: {},
|
||||
eq: vi.fn((a, b) => ({ a, b, op: "eq" })),
|
||||
desc: vi.fn((a) => ({ a, op: "desc" })),
|
||||
and: vi.fn((...args) => ({ args, op: "and" })),
|
||||
count: vi.fn(() => ({ op: "count" })),
|
||||
ilike: vi.fn((a, b) => ({ a, b, op: "ilike" })),
|
||||
or: vi.fn((...args) => ({ args, op: "or" })),
|
||||
sql: vi.fn(),
|
||||
}));
|
||||
|
||||
const { requireSessionOrApiKey } = await import("@/lib/api-auth");
|
||||
|
||||
import { GET, POST } from "../route";
|
||||
|
||||
function makeRequest(method: string, body?: unknown, search = "") {
|
||||
const url = `http://localhost/api/v1/recipes${search}`;
|
||||
return new NextRequest(url, {
|
||||
method,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(requireSessionOrApiKey).mockResolvedValue({ session: mockSession as never, response: null });
|
||||
mockTransaction.mockImplementation(async (fn: (tx: unknown) => Promise<unknown>) => {
|
||||
const tx = {
|
||||
insert: vi.fn(() => ({ values: vi.fn().mockResolvedValue(undefined) })),
|
||||
};
|
||||
return fn(tx);
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/v1/recipes", () => {
|
||||
it("returns 200 with empty list", async () => {
|
||||
mockSelectChain.offset.mockResolvedValue([]);
|
||||
const res = await GET(makeRequest("GET"));
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { data: unknown[] };
|
||||
expect(Array.isArray(body.data)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns 401 when not authenticated", async () => {
|
||||
vi.mocked(requireSessionOrApiKey).mockResolvedValue({
|
||||
session: null,
|
||||
response: new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401 }),
|
||||
} as never);
|
||||
|
||||
const res = await GET(makeRequest("GET"));
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/v1/recipes", () => {
|
||||
const validRecipe = {
|
||||
title: "Test Recipe",
|
||||
baseServings: 4,
|
||||
visibility: "private",
|
||||
ingredients: [{ rawName: "flour", quantity: "2", unit: "cups" }],
|
||||
steps: [{ instruction: "Mix everything" }],
|
||||
};
|
||||
|
||||
it("returns 201 on valid recipe creation", async () => {
|
||||
const res = await POST(makeRequest("POST", validRecipe));
|
||||
expect(res.status).toBe(201);
|
||||
const body = await res.json() as { id: string };
|
||||
expect(body.id).toBeTruthy();
|
||||
});
|
||||
|
||||
it("returns 400 on invalid body (missing title)", async () => {
|
||||
const res = await POST(makeRequest("POST", { baseServings: 4 }));
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns 400 when title is empty", async () => {
|
||||
const res = await POST(makeRequest("POST", { ...validRecipe, title: "" }));
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns 400 when title is too long", async () => {
|
||||
const res = await POST(makeRequest("POST", { ...validRecipe, title: "x".repeat(201) }));
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns 403 when tier limit reached", async () => {
|
||||
const { checkTierLimit } = await import("@/lib/tiers");
|
||||
const { TierLimitError } = await import("@/lib/tiers");
|
||||
vi.mocked(checkTierLimit).mockRejectedValue(new TierLimitError("recipe", "free"));
|
||||
|
||||
const res = await POST(makeRequest("POST", validRecipe));
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it("returns 401 when not authenticated", async () => {
|
||||
vi.mocked(requireSessionOrApiKey).mockResolvedValue({
|
||||
session: null,
|
||||
response: new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401 }),
|
||||
} as never);
|
||||
|
||||
const res = await POST(makeRequest("POST", validRecipe));
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
const mockSession = {
|
||||
user: { id: "user-1", name: "Test", email: "t@t.com", tier: "free" },
|
||||
};
|
||||
|
||||
const { mockRequireSession } = vi.hoisted(() => ({
|
||||
mockRequireSession: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api-auth", () => ({
|
||||
requireSession: mockRequireSession,
|
||||
}));
|
||||
|
||||
vi.mock("@epicure/db", () => ({
|
||||
db: {
|
||||
delete: vi.fn(() => ({ where: vi.fn().mockResolvedValue(undefined) })),
|
||||
update: vi.fn(() => ({
|
||||
set: vi.fn(() => ({ where: vi.fn().mockResolvedValue(undefined) })),
|
||||
})),
|
||||
},
|
||||
recipes: { id: "id", authorId: "author_id", visibility: "visibility" },
|
||||
eq: vi.fn(),
|
||||
and: vi.fn(),
|
||||
inArray: vi.fn(),
|
||||
}));
|
||||
|
||||
import { DELETE, PATCH } from "../route";
|
||||
|
||||
function makeRequest(method: string, body: unknown) {
|
||||
return new NextRequest(`http://localhost/api/v1/recipes/bulk`, {
|
||||
method,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockRequireSession.mockResolvedValue({ session: mockSession, response: null });
|
||||
});
|
||||
|
||||
describe("DELETE /api/v1/recipes/bulk", () => {
|
||||
it("returns 200 on valid ids", async () => {
|
||||
const res = await DELETE(makeRequest("DELETE", { ids: ["r1", "r2"] }));
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json() as { ok: boolean };
|
||||
expect(body.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("returns 400 when ids is empty array", async () => {
|
||||
const res = await DELETE(makeRequest("DELETE", { ids: [] }));
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns 400 when body is invalid", async () => {
|
||||
const res = await DELETE(makeRequest("DELETE", { notIds: "wrong" }));
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns 401 when not authenticated", async () => {
|
||||
mockRequireSession.mockResolvedValue({
|
||||
session: null,
|
||||
response: new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401 }),
|
||||
});
|
||||
const res = await DELETE(makeRequest("DELETE", { ids: ["r1"] }));
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PATCH /api/v1/recipes/bulk", () => {
|
||||
it("returns 200 on valid visibility update", async () => {
|
||||
const res = await PATCH(makeRequest("PATCH", { ids: ["r1"], visibility: "public" }));
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("returns 400 when visibility is missing", async () => {
|
||||
const res = await PATCH(makeRequest("PATCH", { ids: ["r1"] }));
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns 400 when ids is empty", async () => {
|
||||
const res = await PATCH(makeRequest("PATCH", { ids: [], visibility: "public" }));
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("returns 401 when not authenticated", async () => {
|
||||
mockRequireSession.mockResolvedValue({
|
||||
session: null,
|
||||
response: new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401 }),
|
||||
});
|
||||
const res = await PATCH(makeRequest("PATCH", { ids: ["r1"], visibility: "public" }));
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
@@ -13,22 +13,22 @@ const BulkUpdateSchema = z.object({
|
||||
});
|
||||
|
||||
export async function DELETE(req: NextRequest) {
|
||||
const session = await requireSession();
|
||||
if (session instanceof NextResponse) return session;
|
||||
const { session, response } = await requireSession();
|
||||
if (response) return response;
|
||||
|
||||
const body = BulkDeleteSchema.safeParse(await req.json());
|
||||
if (!body.success) return NextResponse.json({ error: "Invalid request" }, { status: 400 });
|
||||
|
||||
await db
|
||||
.delete(recipes)
|
||||
.where(and(inArray(recipes.id, body.data.ids), eq(recipes.authorId, session.user.id)));
|
||||
.where(and(inArray(recipes.id, body.data.ids), eq(recipes.authorId, session!.user.id)));
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
||||
export async function PATCH(req: NextRequest) {
|
||||
const session = await requireSession();
|
||||
if (session instanceof NextResponse) return session;
|
||||
const { session, response } = await requireSession();
|
||||
if (response) return response;
|
||||
|
||||
const body = BulkUpdateSchema.safeParse(await req.json());
|
||||
if (!body.success) return NextResponse.json({ error: "Invalid request" }, { status: 400 });
|
||||
@@ -39,7 +39,7 @@ export async function PATCH(req: NextRequest) {
|
||||
await db
|
||||
.update(recipes)
|
||||
.set({ visibility, updatedAt: new Date() })
|
||||
.where(and(inArray(recipes.id, ids), eq(recipes.authorId, session.user.id)));
|
||||
.where(and(inArray(recipes.id, ids), eq(recipes.authorId, session!.user.id)));
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
||||
@@ -3,56 +3,21 @@ import { db, recipes, recipeIngredients, recipeSteps } from "@epicure/db";
|
||||
import { eq, desc, and } from "@epicure/db";
|
||||
import { z } from "zod";
|
||||
import { requireSessionOrApiKey } from "@/lib/api-auth";
|
||||
import { checkTierLimit, incrementUsage } from "@/lib/tiers";
|
||||
import { checkAndIncrementTierLimit, TierLimitError } from "@/lib/tiers";
|
||||
import { dispatchWebhook } from "@/lib/webhooks";
|
||||
|
||||
const UNICODE_FRACTIONS: Record<string, number> = {
|
||||
"½": 0.5, "⅓": 1/3, "⅔": 2/3, "¼": 0.25, "¾": 0.75,
|
||||
"⅕": 0.2, "⅖": 0.4, "⅗": 0.6, "⅘": 0.8,
|
||||
"⅙": 1/6, "⅚": 5/6, "⅛": 0.125, "⅜": 0.375, "⅝": 0.625, "⅞": 0.875,
|
||||
};
|
||||
|
||||
function parseQuantity(v: string | number | undefined): string | undefined {
|
||||
if (v === undefined || v === "") return undefined;
|
||||
const s = String(v).trim();
|
||||
if (!s) return undefined;
|
||||
|
||||
if (UNICODE_FRACTIONS[s] !== undefined) return String(UNICODE_FRACTIONS[s]);
|
||||
|
||||
for (const [frac, val] of Object.entries(UNICODE_FRACTIONS)) {
|
||||
if (s.endsWith(frac)) {
|
||||
const whole = s.slice(0, -frac.length).trim();
|
||||
if (!whole) return String(val);
|
||||
const w = parseFloat(whole);
|
||||
if (!isNaN(w)) return String(w + val);
|
||||
}
|
||||
}
|
||||
|
||||
const mixed = s.match(/^(\d+)\s+(\d+)\s*\/\s*(\d+)$/);
|
||||
if (mixed) {
|
||||
const den = parseInt(mixed[3]!);
|
||||
if (den !== 0) return String(parseInt(mixed[1]!) + parseInt(mixed[2]!) / den);
|
||||
}
|
||||
|
||||
const slash = s.match(/^(\d+)\s*\/\s*(\d+)$/);
|
||||
if (slash) {
|
||||
const den = parseInt(slash[2]!);
|
||||
if (den !== 0) return String(parseInt(slash[1]!) / den);
|
||||
}
|
||||
|
||||
const n = parseFloat(s);
|
||||
return isNaN(n) ? undefined : String(n);
|
||||
}
|
||||
import { parseQuantity } from "@/lib/parse-quantity";
|
||||
|
||||
const CreateRecipeSchema = z.object({
|
||||
title: z.string().min(1).max(200),
|
||||
description: z.string().optional(),
|
||||
description: z.string().max(2000).optional(),
|
||||
baseServings: z.number().int().min(1).max(100).default(4),
|
||||
visibility: z.enum(["private", "unlisted", "public"]).default("private"),
|
||||
difficulty: z.enum(["easy", "medium", "hard"]).optional(),
|
||||
prepMins: z.number().int().min(0).optional(),
|
||||
cookMins: z.number().int().min(0).optional(),
|
||||
prepMins: z.number().int().min(0).max(1440).optional(),
|
||||
cookMins: z.number().int().min(0).max(1440).optional(),
|
||||
tags: z.array(z.string().min(1).max(50)).max(20).default([]),
|
||||
aiGenerated: z.boolean().optional(),
|
||||
language: z.string().max(10).optional(),
|
||||
dietaryTags: z.object({
|
||||
vegan: z.boolean().optional(),
|
||||
vegetarian: z.boolean().optional(),
|
||||
@@ -63,17 +28,17 @@ const CreateRecipeSchema = z.object({
|
||||
kosher: z.boolean().optional(),
|
||||
}).optional(),
|
||||
ingredients: z.array(z.object({
|
||||
rawName: z.string().min(1),
|
||||
rawName: z.string().min(1).max(200),
|
||||
quantity: z.union([z.number(), z.string()]).optional().transform(parseQuantity),
|
||||
unit: z.string().optional(),
|
||||
note: z.string().optional(),
|
||||
unit: z.string().max(50).optional(),
|
||||
note: z.string().max(500).optional(),
|
||||
order: z.number().int().default(0),
|
||||
})).default([]),
|
||||
})).max(100).default([]),
|
||||
steps: z.array(z.object({
|
||||
instruction: z.string().min(1),
|
||||
timerSeconds: z.number().int().optional(),
|
||||
instruction: z.string().min(1).max(2000),
|
||||
timerSeconds: z.number().int().min(0).max(86400).optional(),
|
||||
order: z.number().int().optional(),
|
||||
})).default([]),
|
||||
})).max(100).default([]),
|
||||
});
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
@@ -109,7 +74,14 @@ export async function POST(req: NextRequest) {
|
||||
return NextResponse.json({ error: "Validation error", issues: parsed.error.issues }, { status: 400 });
|
||||
}
|
||||
|
||||
await checkTierLimit(session!.user.id, session!.user.tier as "free" | "pro", "recipe");
|
||||
try {
|
||||
await checkAndIncrementTierLimit(session!.user.id, session!.user.tier as "free" | "pro", "recipe");
|
||||
} catch (err) {
|
||||
if (err instanceof TierLimitError) {
|
||||
return NextResponse.json({ error: "Recipe limit reached for your tier" }, { status: 403 });
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
const id = crypto.randomUUID();
|
||||
const now = new Date();
|
||||
@@ -126,8 +98,10 @@ export async function POST(req: NextRequest) {
|
||||
difficulty: data.difficulty,
|
||||
prepMins: data.prepMins,
|
||||
cookMins: data.cookMins,
|
||||
tags: data.tags,
|
||||
dietaryTags: data.dietaryTags ?? {},
|
||||
aiGenerated: data.aiGenerated ?? false,
|
||||
language: data.language,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
@@ -159,8 +133,6 @@ export async function POST(req: NextRequest) {
|
||||
}
|
||||
});
|
||||
|
||||
await incrementUsage(session!.user.id, "recipe");
|
||||
|
||||
const recipe = await db.query.recipes.findFirst({ where: eq(recipes.id, id) });
|
||||
void dispatchWebhook(session!.user.id, "recipe.created", { id, title: data.title });
|
||||
return NextResponse.json(recipe, { status: 201 });
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 |
@@ -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
Reference in New Issue
Block a user