Compare commits
10 Commits
84d6cfeb07
...
8b57a3fd87
| 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,
|
difficulty: recipe.difficulty,
|
||||||
prepMins: recipe.prepMins,
|
prepMins: recipe.prepMins,
|
||||||
cookMins: recipe.cookMins,
|
cookMins: recipe.cookMins,
|
||||||
|
tags: recipe.tags ?? [],
|
||||||
dietaryTags: (recipe.dietaryTags as Record<string, boolean | undefined>) ?? {},
|
dietaryTags: (recipe.dietaryTags as Record<string, boolean | undefined>) ?? {},
|
||||||
ingredients: recipe.ingredients.map((ing) => ({
|
ingredients: recipe.ingredients.map((ing) => ({
|
||||||
id: ing.id,
|
id: ing.id,
|
||||||
|
|||||||
@@ -13,18 +13,21 @@ import { PrintButton } from "@/components/recipe/print-button";
|
|||||||
import { VersionHistoryButton } from "@/components/recipe/version-history-button";
|
import { VersionHistoryButton } from "@/components/recipe/version-history-button";
|
||||||
import { DeleteRecipeButton } from "@/components/recipe/delete-recipe-button";
|
import { DeleteRecipeButton } from "@/components/recipe/delete-recipe-button";
|
||||||
import { NutritionPanel } from "@/components/recipe/nutrition-panel";
|
import { NutritionPanel } from "@/components/recipe/nutrition-panel";
|
||||||
|
import { GenerateContentButton } from "@/components/recipe/generate-content-button";
|
||||||
import { auth } from "@/lib/auth/server";
|
import { auth } from "@/lib/auth/server";
|
||||||
import { db, recipes, ratings, favorites, avg } from "@epicure/db";
|
import { db, recipes, ratings, favorites, avg } from "@epicure/db";
|
||||||
import { and, eq, count } from "@epicure/db";
|
import { and, eq, count } from "@epicure/db";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { buttonVariants } from "@/components/ui/button";
|
import { buttonVariants } from "@/components/ui/button";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
import { ServingScaler } from "@/components/recipe/serving-scaler";
|
import { ServingScaler } from "@/components/recipe/serving-scaler";
|
||||||
import { FavoriteButton } from "@/components/social/favorite-button";
|
import { FavoriteButton } from "@/components/social/favorite-button";
|
||||||
import { RatingStars } from "@/components/social/rating-stars";
|
import { RatingStars } from "@/components/social/rating-stars";
|
||||||
import { CommentsSection } from "@/components/social/comments-section";
|
import { CommentsSection } from "@/components/social/comments-section";
|
||||||
import { getPublicUrl } from "@/lib/storage";
|
import { getPublicUrl } from "@/lib/storage";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { RecipeChatPanel } from "@/components/recipe/recipe-chat-panel";
|
||||||
|
|
||||||
type Params = { params: Promise<{ id: string }> };
|
type Params = { params: Promise<{ id: string }> };
|
||||||
|
|
||||||
@@ -86,14 +89,30 @@ export default async function RecipePage({ params }: Params) {
|
|||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h1 className="text-3xl font-bold tracking-tight">{recipe.title}</h1>
|
<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">
|
<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} />
|
<FavoriteButton recipeId={id} initialFavorited={isFavorited} />
|
||||||
<MealPairingButton recipeId={id} />
|
<MealPairingButton recipeId={id} />
|
||||||
<DrinkPairingButton recipeId={id} />
|
<DrinkPairingButton recipeId={id} />
|
||||||
{recipe.visibility === "public" && (
|
{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" />
|
<ExternalLink className="h-4 w-4" />
|
||||||
</Link>
|
</Link>
|
||||||
|
} />
|
||||||
|
<TooltipContent>View publicly</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
{recipe.ingredients.length > 0 && (
|
{recipe.ingredients.length > 0 && (
|
||||||
<AddToShoppingListButton
|
<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} />
|
<TranslateButton recipeId={id} />
|
||||||
|
)}
|
||||||
{recipe.ingredients.length > 0 && (
|
{recipe.ingredients.length > 0 && (
|
||||||
<AdaptRecipeButton
|
<AdaptRecipeButton
|
||||||
recipeId={id}
|
recipeId={id}
|
||||||
@@ -133,20 +154,19 @@ export default async function RecipePage({ params }: Params) {
|
|||||||
order: s.order,
|
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} />
|
<PrintButton recipeId={id} />
|
||||||
<VersionHistoryButton 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" />
|
<Pencil className="h-4 w-4" />
|
||||||
Edit
|
|
||||||
</Link>
|
</Link>
|
||||||
|
} />
|
||||||
|
<TooltipContent>Edit</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
<DeleteRecipeButton recipeId={id} />
|
<DeleteRecipeButton recipeId={id} />
|
||||||
</div>
|
</div>
|
||||||
|
</TooltipProvider>
|
||||||
|
|
||||||
{avgScore !== null && (
|
{avgScore !== null && (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -210,6 +230,24 @@ export default async function RecipePage({ params }: Params) {
|
|||||||
|
|
||||||
<Separator />
|
<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 */}
|
{/* Serving scaler + ingredients */}
|
||||||
{recipe.ingredients.length > 0 && (
|
{recipe.ingredients.length > 0 && (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
@@ -285,6 +323,8 @@ export default async function RecipePage({ params }: Params) {
|
|||||||
<CommentsSection recipeId={id} currentUserId={session.user.id} />
|
<CommentsSection recipeId={id} currentUserId={session.user.id} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<RecipeChatPanel recipeId={id} recipeTitle={recipe.title} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,48 +1,75 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { headers } from "next/headers";
|
import { headers } from "next/headers";
|
||||||
import { auth } from "@/lib/auth/server";
|
import { auth } from "@/lib/auth/server";
|
||||||
import { db, recipes, recipeIngredients } from "@epicure/db";
|
import { db, recipes, sql } from "@epicure/db";
|
||||||
import { eq, desc, and, ilike, or, sql } from "@epicure/db";
|
import { eq, desc, asc, and, ilike, or } from "@epicure/db";
|
||||||
import { RecipesHeader } from "@/components/recipe/recipes-header";
|
import { RecipesHeader } from "@/components/recipe/recipes-header";
|
||||||
import { RecipesEmptyState } from "@/components/recipe/recipes-empty-state";
|
import { RecipesEmptyState } from "@/components/recipe/recipes-empty-state";
|
||||||
import { RecipesGrid } from "@/components/recipe/recipes-grid";
|
import { RecipesGrid } from "@/components/recipe/recipes-grid";
|
||||||
|
|
||||||
export const metadata: Metadata = { title: "Recipes" };
|
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 }) {
|
export default async function RecipesPage({ searchParams }: { searchParams: SearchParams }) {
|
||||||
const session = await auth.api.getSession({ headers: await headers() });
|
const session = await auth.api.getSession({ headers: await headers() });
|
||||||
if (!session) return null;
|
if (!session) return null;
|
||||||
|
|
||||||
const { q } = await searchParams;
|
const { q, sort, visibility, difficulty, tag } = await searchParams;
|
||||||
const query = q?.trim() ?? "";
|
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
|
const visibilityFilter = visibility && ["private", "unlisted", "public"].includes(visibility)
|
||||||
? and(
|
? (visibility as "private" | "unlisted" | "public")
|
||||||
eq(recipes.authorId, session.user.id),
|
: undefined;
|
||||||
or(
|
const difficultyFilter = difficulty && ["easy", "medium", "hard"].includes(difficulty)
|
||||||
ilike(recipes.title, `%${query}%`),
|
? (difficulty as "easy" | "medium" | "hard")
|
||||||
ilike(recipes.description, `%${query}%`)
|
: undefined;
|
||||||
)
|
|
||||||
)
|
|
||||||
: eq(recipes.authorId, session.user.id);
|
|
||||||
|
|
||||||
const userRecipes = await db.query.recipes.findMany({
|
const userRecipes = await db.query.recipes.findMany({
|
||||||
where: whereClause,
|
where: and(
|
||||||
orderBy: desc(recipes.updatedAt),
|
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 },
|
with: { photos: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
const totalCount = query ? undefined : userRecipes.length;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<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} />
|
<RecipesEmptyState query={query} count={userRecipes.length} />
|
||||||
|
<RecipesGrid key={`${query}-${sortKey}-${visibilityFilter}-${difficultyFilter}-${tagFilter}`} recipes={userRecipes} />
|
||||||
<RecipesGrid recipes={userRecipes} />
|
|
||||||
</div>
|
</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 (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="space-y-1">
|
<CardHeader className="space-y-1">
|
||||||
@@ -65,9 +61,15 @@ export default function LoginPage() {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<form onSubmit={handleSubmit}>
|
<form onSubmit={handleSubmit}>
|
||||||
<CardContent className="space-y-4">
|
<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")}
|
{t("continueWithGoogle")}
|
||||||
</Button>
|
</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">
|
<div className="flex items-center gap-2">
|
||||||
<Separator className="flex-1" />
|
<Separator className="flex-1" />
|
||||||
<span className="text-xs text-muted-foreground">{t("or")}</span>
|
<span className="text-xs text-muted-foreground">{t("or")}</span>
|
||||||
@@ -118,3 +120,59 @@ export default function LoginPage() {
|
|||||||
</Card>
|
</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;
|
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();
|
const { session } = await requireSession();
|
||||||
let userReactions: string[] = [];
|
let userReactions: string[] = [];
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { requireSession } from "@/lib/api-auth";
|
|||||||
type Params = { params: Promise<{ id: string }> };
|
type Params = { params: Promise<{ id: string }> };
|
||||||
|
|
||||||
const Schema = z.object({
|
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(),
|
notes: z.string().max(2000).optional(),
|
||||||
deductFromPantry: z.boolean().default(true),
|
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 });
|
return NextResponse.json({ nutrition: result });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,15 +4,17 @@ import { eq, and, max } from "@epicure/db";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { requireSessionOrApiKey } from "@/lib/api-auth";
|
import { requireSessionOrApiKey } from "@/lib/api-auth";
|
||||||
import { dispatchWebhook } from "@/lib/webhooks";
|
import { dispatchWebhook } from "@/lib/webhooks";
|
||||||
|
import { parseQuantity } from "@/lib/parse-quantity";
|
||||||
|
|
||||||
const UpdateRecipeSchema = z.object({
|
const UpdateRecipeSchema = z.object({
|
||||||
title: z.string().min(1).max(200).optional(),
|
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(),
|
baseServings: z.number().int().min(1).max(100).optional(),
|
||||||
visibility: z.enum(["private", "unlisted", "public"]).optional(),
|
visibility: z.enum(["private", "unlisted", "public"]).optional(),
|
||||||
difficulty: z.enum(["easy", "medium", "hard"]).nullable().optional(),
|
difficulty: z.enum(["easy", "medium", "hard"]).nullable().optional(),
|
||||||
prepMins: z.number().int().min(0).nullable().optional(),
|
prepMins: z.number().int().min(0).max(1440).nullable().optional(),
|
||||||
cookMins: z.number().int().min(0).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({
|
dietaryTags: z.object({
|
||||||
vegan: z.boolean().optional(),
|
vegan: z.boolean().optional(),
|
||||||
vegetarian: z.boolean().optional(),
|
vegetarian: z.boolean().optional(),
|
||||||
@@ -23,17 +25,17 @@ const UpdateRecipeSchema = z.object({
|
|||||||
kosher: z.boolean().optional(),
|
kosher: z.boolean().optional(),
|
||||||
}).optional(),
|
}).optional(),
|
||||||
ingredients: z.array(z.object({
|
ingredients: z.array(z.object({
|
||||||
rawName: z.string().min(1),
|
rawName: z.string().min(1).max(200),
|
||||||
quantity: z.string().optional(),
|
quantity: z.union([z.number(), z.string().max(50)]).optional().transform(parseQuantity),
|
||||||
unit: z.string().optional(),
|
unit: z.string().max(50).optional(),
|
||||||
note: z.string().optional(),
|
note: z.string().max(500).optional(),
|
||||||
order: z.number().int().default(0),
|
order: z.number().int().default(0),
|
||||||
})).optional(),
|
})).max(100).optional(),
|
||||||
steps: z.array(z.object({
|
steps: z.array(z.object({
|
||||||
instruction: z.string().min(1),
|
instruction: z.string().min(1).max(2000),
|
||||||
timerSeconds: z.number().int().optional(),
|
timerSeconds: z.number().int().min(0).max(86400).optional(),
|
||||||
order: z.number().int(),
|
order: z.number().int(),
|
||||||
})).optional(),
|
})).max(100).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
type Params = { params: Promise<{ id: string }> };
|
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.difficulty !== undefined) updates.difficulty = data.difficulty ?? undefined;
|
||||||
if (data.prepMins !== undefined) updates.prepMins = data.prepMins ?? undefined;
|
if (data.prepMins !== undefined) updates.prepMins = data.prepMins ?? undefined;
|
||||||
if (data.cookMins !== undefined) updates.cookMins = data.cookMins ?? 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;
|
if (data.dietaryTags !== undefined) updates.dietaryTags = data.dietaryTags;
|
||||||
|
|
||||||
await tx.update(recipes).set(updates).where(eq(recipes.id, id));
|
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) {
|
export async function DELETE(req: NextRequest) {
|
||||||
const session = await requireSession();
|
const { session, response } = await requireSession();
|
||||||
if (session instanceof NextResponse) return session;
|
if (response) return response;
|
||||||
|
|
||||||
const body = BulkDeleteSchema.safeParse(await req.json());
|
const body = BulkDeleteSchema.safeParse(await req.json());
|
||||||
if (!body.success) return NextResponse.json({ error: "Invalid request" }, { status: 400 });
|
if (!body.success) return NextResponse.json({ error: "Invalid request" }, { status: 400 });
|
||||||
|
|
||||||
await db
|
await db
|
||||||
.delete(recipes)
|
.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 });
|
return NextResponse.json({ ok: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function PATCH(req: NextRequest) {
|
export async function PATCH(req: NextRequest) {
|
||||||
const session = await requireSession();
|
const { session, response } = await requireSession();
|
||||||
if (session instanceof NextResponse) return session;
|
if (response) return response;
|
||||||
|
|
||||||
const body = BulkUpdateSchema.safeParse(await req.json());
|
const body = BulkUpdateSchema.safeParse(await req.json());
|
||||||
if (!body.success) return NextResponse.json({ error: "Invalid request" }, { status: 400 });
|
if (!body.success) return NextResponse.json({ error: "Invalid request" }, { status: 400 });
|
||||||
@@ -39,7 +39,7 @@ export async function PATCH(req: NextRequest) {
|
|||||||
await db
|
await db
|
||||||
.update(recipes)
|
.update(recipes)
|
||||||
.set({ visibility, updatedAt: new Date() })
|
.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 });
|
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 { eq, desc, and } from "@epicure/db";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { requireSessionOrApiKey } from "@/lib/api-auth";
|
import { requireSessionOrApiKey } from "@/lib/api-auth";
|
||||||
import { checkTierLimit, incrementUsage } from "@/lib/tiers";
|
import { checkAndIncrementTierLimit, TierLimitError } from "@/lib/tiers";
|
||||||
import { dispatchWebhook } from "@/lib/webhooks";
|
import { dispatchWebhook } from "@/lib/webhooks";
|
||||||
|
import { parseQuantity } from "@/lib/parse-quantity";
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
const CreateRecipeSchema = z.object({
|
const CreateRecipeSchema = z.object({
|
||||||
title: z.string().min(1).max(200),
|
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),
|
baseServings: z.number().int().min(1).max(100).default(4),
|
||||||
visibility: z.enum(["private", "unlisted", "public"]).default("private"),
|
visibility: z.enum(["private", "unlisted", "public"]).default("private"),
|
||||||
difficulty: z.enum(["easy", "medium", "hard"]).optional(),
|
difficulty: z.enum(["easy", "medium", "hard"]).optional(),
|
||||||
prepMins: z.number().int().min(0).optional(),
|
prepMins: z.number().int().min(0).max(1440).optional(),
|
||||||
cookMins: z.number().int().min(0).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(),
|
aiGenerated: z.boolean().optional(),
|
||||||
|
language: z.string().max(10).optional(),
|
||||||
dietaryTags: z.object({
|
dietaryTags: z.object({
|
||||||
vegan: z.boolean().optional(),
|
vegan: z.boolean().optional(),
|
||||||
vegetarian: z.boolean().optional(),
|
vegetarian: z.boolean().optional(),
|
||||||
@@ -63,17 +28,17 @@ const CreateRecipeSchema = z.object({
|
|||||||
kosher: z.boolean().optional(),
|
kosher: z.boolean().optional(),
|
||||||
}).optional(),
|
}).optional(),
|
||||||
ingredients: z.array(z.object({
|
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),
|
quantity: z.union([z.number(), z.string()]).optional().transform(parseQuantity),
|
||||||
unit: z.string().optional(),
|
unit: z.string().max(50).optional(),
|
||||||
note: z.string().optional(),
|
note: z.string().max(500).optional(),
|
||||||
order: z.number().int().default(0),
|
order: z.number().int().default(0),
|
||||||
})).default([]),
|
})).max(100).default([]),
|
||||||
steps: z.array(z.object({
|
steps: z.array(z.object({
|
||||||
instruction: z.string().min(1),
|
instruction: z.string().min(1).max(2000),
|
||||||
timerSeconds: z.number().int().optional(),
|
timerSeconds: z.number().int().min(0).max(86400).optional(),
|
||||||
order: z.number().int().optional(),
|
order: z.number().int().optional(),
|
||||||
})).default([]),
|
})).max(100).default([]),
|
||||||
});
|
});
|
||||||
|
|
||||||
export async function GET(req: NextRequest) {
|
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 });
|
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 id = crypto.randomUUID();
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
@@ -126,8 +98,10 @@ export async function POST(req: NextRequest) {
|
|||||||
difficulty: data.difficulty,
|
difficulty: data.difficulty,
|
||||||
prepMins: data.prepMins,
|
prepMins: data.prepMins,
|
||||||
cookMins: data.cookMins,
|
cookMins: data.cookMins,
|
||||||
|
tags: data.tags,
|
||||||
dietaryTags: data.dietaryTags ?? {},
|
dietaryTags: data.dietaryTags ?? {},
|
||||||
aiGenerated: data.aiGenerated ?? false,
|
aiGenerated: data.aiGenerated ?? false,
|
||||||
|
language: data.language,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: 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) });
|
const recipe = await db.query.recipes.findFirst({ where: eq(recipes.id, id) });
|
||||||
void dispatchWebhook(session!.user.id, "recipe.created", { id, title: data.title });
|
void dispatchWebhook(session!.user.id, "recipe.created", { id, title: data.title });
|
||||||
return NextResponse.json(recipe, { status: 201 });
|
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