Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e5d1080fb9 | |||
| d2faf98ac1 |
@@ -66,3 +66,7 @@ OPENROUTER_DEFAULT_MODEL=google/gemini-flash-1.5
|
|||||||
OPENAI_API_KEY=
|
OPENAI_API_KEY=
|
||||||
ANTHROPIC_API_KEY=
|
ANTHROPIC_API_KEY=
|
||||||
OLLAMA_BASE_URL=http://localhost:11434
|
OLLAMA_BASE_URL=http://localhost:11434
|
||||||
|
|
||||||
|
# Grocery delivery handoff (optional — without these, shopping lists only offer "copy as text")
|
||||||
|
NEXT_PUBLIC_GROCERY_PROVIDER=
|
||||||
|
INSTACART_API_KEY=
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
# Known issues / backlog
|
||||||
|
|
||||||
|
Findings from a codebase health scan (security, data integrity, tests, perf, cleanup). All items below are resolved as of this pass.
|
||||||
|
|
||||||
|
## Resolved
|
||||||
|
|
||||||
|
1. ~~**IDOR** — `collections/[id]/route.ts`~~ — investigated: the `PUT` handler's top-level ownership check (`existing = findFirst(id + userId)`) already gates the entire handler, including `removeRecipeId`/`addRecipeId`. Not actually vulnerable. No change made.
|
||||||
|
2. ~~**Missing transaction** — meal-plan generation~~ — wrapped the per-entry insert sequence in `db.transaction(...)`.
|
||||||
|
3. ~~**Missing indexes**~~ — added `userId` indexes to `collections`, `collectionMembers`, `cookingHistory`, `ratings`, `favorites`.
|
||||||
|
4. ~~**Zero test coverage** on `api/v1/admin/*` and `api/v1/webhooks/*`~~ — added Vitest coverage for all 7 route files (role checks, SSRF validation path, redelivery logic).
|
||||||
|
5. ~~SSRF gap — malformed IPv6~~ — replaced string-prefix heuristics with a proper IPv6 parser (handles `::` compression, IPv4-mapped addresses, fails closed on malformed input). Also found and fixed an identical duplicated bug in `lib/ai/features/import-url.ts`; consolidated both call sites onto the one fixed implementation.
|
||||||
|
6. ~~Race condition — `checkAndIncrementTierLimit`~~ — investigated: already atomic (single `INSERT ... ON CONFLICT DO UPDATE ... RETURNING`). The old racy `checkTierLimit` was dead code (zero callers) — deleted.
|
||||||
|
7. ~~N+1 / no pagination — collections list~~ — added `limit`/`offset` pagination, matching the `search` route's pattern.
|
||||||
|
8. ~~Dietary-tag search — missing index~~ — added a GIN index on `recipes.dietaryTags`; also switched the search filter from `->>` text extraction to `@>` containment so the index is actually used.
|
||||||
|
9. ~~Duplicated Zod schemas across AI features~~ — extracted shared `dietaryTagsSchema`/`ingredientSchema`/`stepSchema` into `lib/ai/features/recipe-schema.ts`.
|
||||||
|
10. ~~Stripe webhook stubbed~~ — implemented tier upgrade/downgrade; added `users.stripeCustomerId` to map `customer.subscription.deleted` events back to a user.
|
||||||
|
11. ~~No rate limit on `ai-keys`~~ — added `applyRateLimit` to the POST handler.
|
||||||
|
12. ~~`pnpm typecheck` documented but missing~~ — added the script to all three workspace packages; also fixed the pre-existing type errors it exposed (`packages/db` missing `@types/node`, two stale test mocks).
|
||||||
|
13. ~~Hardcoded `localhost:3001` fallback~~ — now throws a 500 with a clear message if `BETTER_AUTH_URL` is unset, instead of silently generating a broken link.
|
||||||
|
|
||||||
|
# Feature ideas
|
||||||
|
|
||||||
|
Brainstormed extensions building on existing infra (pantry match, meal planning, cooking mode w/ voice, tiers, AI generation, social/collections, print, version history).
|
||||||
|
|
||||||
|
## Done
|
||||||
|
|
||||||
|
1. **Expiry-aware pantry** — done. Pantry schema/UI already had `expiresAt` fully wired (date input, sort, expiry badges); added the missing piece — the canCook page now surfaces a "Use it up" badge on recipes that use soon-expiring pantry items and sorts them to the top.
|
||||||
|
2. **Shared meal plans/shopping lists** — done. Added `shoppingListMembers`/`mealPlanMembers` tables (viewer/editor roles, mirrors `collectionMembers`), share dialogs, and membership-checked API routes. Meal plans keep their owner-side weekly routes; shared access goes through new `/api/v1/meal-plans/shared/[mealPlanId]` routes since plans are addressed by `(userId, weekStart)`.
|
||||||
|
3. **PDF cookbook export** — done. `/print/collection/[id]` renders every recipe in a collection with `page-break-after` between them, reusing the existing print-page CSS; browser print-to-PDF produces the file, matching the existing single-recipe/meal-plan print pattern (no new PDF-rendering dependency).
|
||||||
|
4. **Recipe diff/compare view** — done. Added the `diff` package + `VersionDiffView`; version-history-button now has a "Compare with current" action next to Restore.
|
||||||
|
5. **Grocery delivery handoff** — done as a stub, per confirmed scope: shopping lists get a "Send to grocery delivery" button that always offers copy-as-text, plus a documented (not live) Instacart adapter gated behind `INSTACART_API_KEY`/`NEXT_PUBLIC_GROCERY_PROVIDER` — real activation needs an actual partner agreement.
|
||||||
|
6. **Personalized "for you" feed** — done. New `/api/v1/feed/for-you` ranks public recipes by tag/dietary-tag overlap with the user's favorited/highly-rated history (falls back to recency when there's no history yet); third feed tab added.
|
||||||
|
7. **PWA/offline mode** — done. The service worker and offline fallback already existed; added `manifest.json` + icons and wired them into the root layout metadata so the app is installable. Cache-first on `/cook` already makes previously-visited cooking-mode pages available offline.
|
||||||
|
|
||||||
|
## Also fixed this pass
|
||||||
|
|
||||||
|
- **Mobile responsiveness** — Recipes/Collections/Pantry/Meal Plan/Shopping Lists page headers now stack and wrap instead of clipping buttons off-screen on narrow viewports (`flex-col sm:flex-row` + `flex-wrap`).
|
||||||
@@ -1,11 +1,15 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import { headers } from "next/headers";
|
import { headers } from "next/headers";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Printer } from "lucide-react";
|
||||||
import { auth } from "@/lib/auth/server";
|
import { auth } from "@/lib/auth/server";
|
||||||
import { db, collections, eq, and, or } from "@epicure/db";
|
import { db, collections, eq, and, or } from "@epicure/db";
|
||||||
import { RecipeCard } from "@/components/recipe/recipe-card";
|
import { RecipeCard } from "@/components/recipe/recipe-card";
|
||||||
import { ForkCollectionButton } from "@/components/collections/fork-collection-button";
|
import { ForkCollectionButton } from "@/components/collections/fork-collection-button";
|
||||||
import { ShareCollectionButton } from "@/components/collections/share-collection-button";
|
import { ShareCollectionButton } from "@/components/collections/share-collection-button";
|
||||||
|
import { buttonVariants } from "@/components/ui/button";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
type Params = { params: Promise<{ id: string }> };
|
type Params = { params: Promise<{ id: string }> };
|
||||||
|
|
||||||
@@ -30,7 +34,7 @@ export default async function CollectionPage({ params }: Params) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-start justify-between gap-4">
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold tracking-tight">{col.name}</h1>
|
<h1 className="text-3xl font-bold tracking-tight">{col.name}</h1>
|
||||||
{col.description && <p className="text-muted-foreground mt-1">{col.description}</p>}
|
{col.description && <p className="text-muted-foreground mt-1">{col.description}</p>}
|
||||||
@@ -38,7 +42,13 @@ export default async function CollectionPage({ params }: Params) {
|
|||||||
{col.recipes.length} recipe{col.recipes.length !== 1 ? "s" : ""} · {col.isPublic ? "Public" : "Private"}
|
{col.recipes.length} recipe{col.recipes.length !== 1 ? "s" : ""} · {col.isPublic ? "Public" : "Private"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
{col.recipes.length > 0 && (
|
||||||
|
<Link href={`/print/collection/${id}`} target="_blank" className={cn(buttonVariants({ variant: "outline", size: "sm" }))}>
|
||||||
|
<Printer className="h-4 w-4" />
|
||||||
|
Export as PDF
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
{isOwner && <ShareCollectionButton collectionId={id} />}
|
{isOwner && <ShareCollectionButton collectionId={id} />}
|
||||||
{!isOwner && col.isPublic && (
|
{!isOwner && col.isPublic && (
|
||||||
<ForkCollectionButton collectionId={id} />
|
<ForkCollectionButton collectionId={id} />
|
||||||
|
|||||||
@@ -3,9 +3,10 @@ import { headers } from "next/headers";
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { ChevronLeft, ChevronRight, ShoppingCart, Printer } from "lucide-react";
|
import { ChevronLeft, ChevronRight, ShoppingCart, Printer } from "lucide-react";
|
||||||
import { auth } from "@/lib/auth/server";
|
import { auth } from "@/lib/auth/server";
|
||||||
import { db, mealPlans, recipes, eq, and, desc } from "@epicure/db";
|
import { db, mealPlans, mealPlanMembers, recipes, eq, and, desc } from "@epicure/db";
|
||||||
import { buttonVariants } from "@/components/ui/button";
|
import { buttonVariants } from "@/components/ui/button";
|
||||||
import { MealPlanner } from "@/components/meal-plan/meal-planner";
|
import { MealPlanner } from "@/components/meal-plan/meal-planner";
|
||||||
|
import { ShareMealPlanButton } from "@/components/meal-plan/share-meal-plan-button";
|
||||||
import { WeeklyNutritionBar } from "@/components/nutrition/weekly-nutrition-bar";
|
import { WeeklyNutritionBar } from "@/components/nutrition/weekly-nutrition-bar";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
@@ -47,7 +48,7 @@ export default async function MealPlanPage({
|
|||||||
const sunday = addWeeks(monday, 1);
|
const sunday = addWeeks(monday, 1);
|
||||||
sunday.setDate(sunday.getDate() - 1);
|
sunday.setDate(sunday.getDate() - 1);
|
||||||
|
|
||||||
const [plan, userRecipes] = await Promise.all([
|
const [plan, userRecipes, sharedMemberships] = await Promise.all([
|
||||||
db.query.mealPlans.findFirst({
|
db.query.mealPlans.findFirst({
|
||||||
where: and(eq(mealPlans.userId, session.user.id), eq(mealPlans.weekStart, weekStart)),
|
where: and(eq(mealPlans.userId, session.user.id), eq(mealPlans.weekStart, weekStart)),
|
||||||
with: {
|
with: {
|
||||||
@@ -61,6 +62,10 @@ export default async function MealPlanPage({
|
|||||||
orderBy: desc(recipes.updatedAt),
|
orderBy: desc(recipes.updatedAt),
|
||||||
columns: { id: true, title: true },
|
columns: { id: true, title: true },
|
||||||
}),
|
}),
|
||||||
|
db.query.mealPlanMembers.findMany({
|
||||||
|
where: eq(mealPlanMembers.userId, session.user.id),
|
||||||
|
with: { mealPlan: { with: { user: true } } },
|
||||||
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const entries = (plan?.entries ?? []).map((e) => ({
|
const entries = (plan?.entries ?? []).map((e) => ({
|
||||||
@@ -76,12 +81,13 @@ export default async function MealPlanPage({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold tracking-tight">Meal Plan</h1>
|
<h1 className="text-3xl font-bold tracking-tight">Meal Plan</h1>
|
||||||
<p className="text-muted-foreground mt-1">{label}</p>
|
<p className="text-muted-foreground mt-1">{label}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<ShareMealPlanButton weekStart={weekStart} />
|
||||||
<Link href="/shopping-lists" className={cn(buttonVariants({ variant: "outline", size: "sm" }))}>
|
<Link href="/shopping-lists" className={cn(buttonVariants({ variant: "outline", size: "sm" }))}>
|
||||||
<ShoppingCart className="h-4 w-4" />
|
<ShoppingCart className="h-4 w-4" />
|
||||||
Shopping lists
|
Shopping lists
|
||||||
@@ -101,6 +107,26 @@ export default async function MealPlanPage({
|
|||||||
|
|
||||||
<WeeklyNutritionBar weekStart={weekStart} />
|
<WeeklyNutritionBar weekStart={weekStart} />
|
||||||
<MealPlanner weekStart={weekStart} initialEntries={entries} userRecipes={userRecipes} />
|
<MealPlanner weekStart={weekStart} initialEntries={entries} userRecipes={userRecipes} />
|
||||||
|
|
||||||
|
{sharedMemberships.length > 0 && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h2 className="text-sm font-semibold text-muted-foreground">Shared with you</h2>
|
||||||
|
<div className="space-y-2 max-w-lg">
|
||||||
|
{sharedMemberships.map((m) => (
|
||||||
|
<Link
|
||||||
|
key={m.id}
|
||||||
|
href={`/meal-plan/shared/${m.mealPlan.id}`}
|
||||||
|
className="flex items-center justify-between rounded-xl border p-4 hover:shadow-sm transition-shadow"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">{`${m.mealPlan.user?.name ?? "Unknown"}'s plan`}</p>
|
||||||
|
<p className="text-sm text-muted-foreground">Week of {m.mealPlan.weekStart} · {m.role}</p>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { headers } from "next/headers";
|
||||||
|
import { auth } from "@/lib/auth/server";
|
||||||
|
import { db, mealPlans, recipes, eq, desc } from "@epicure/db";
|
||||||
|
import { getMealPlanAccessById, canWriteMealPlan } from "@/lib/meal-plan-access";
|
||||||
|
import { SharedMealPlanView } from "@/components/meal-plan/shared-meal-plan-view";
|
||||||
|
|
||||||
|
type Params = { params: Promise<{ mealPlanId: string }> };
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: "Shared Meal Plan" };
|
||||||
|
|
||||||
|
export default async function SharedMealPlanPage({ params }: Params) {
|
||||||
|
const { mealPlanId } = await params;
|
||||||
|
const session = await auth.api.getSession({ headers: await headers() });
|
||||||
|
if (!session) return null;
|
||||||
|
|
||||||
|
const access = await getMealPlanAccessById(mealPlanId, session.user.id);
|
||||||
|
if (!access) notFound();
|
||||||
|
|
||||||
|
const plan = await db.query.mealPlans.findFirst({
|
||||||
|
where: eq(mealPlans.id, mealPlanId),
|
||||||
|
with: { entries: { with: { recipe: true } }, user: true },
|
||||||
|
});
|
||||||
|
if (!plan) notFound();
|
||||||
|
|
||||||
|
const userRecipes = await db.query.recipes.findMany({
|
||||||
|
where: eq(recipes.authorId, session.user.id),
|
||||||
|
orderBy: desc(recipes.updatedAt),
|
||||||
|
columns: { id: true, title: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const canEdit = canWriteMealPlan(access.role);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold tracking-tight">{`${plan.user?.name ?? "Shared"}'s Meal Plan`}</h1>
|
||||||
|
<p className="text-muted-foreground mt-1">Week of {plan.weekStart} · {access.role}</p>
|
||||||
|
</div>
|
||||||
|
<SharedMealPlanView
|
||||||
|
mealPlanId={mealPlanId}
|
||||||
|
canEdit={canEdit}
|
||||||
|
userRecipes={userRecipes}
|
||||||
|
initialEntries={plan.entries.map((e) => ({
|
||||||
|
id: e.id,
|
||||||
|
day: e.day,
|
||||||
|
mealType: e.mealType,
|
||||||
|
servings: e.servings,
|
||||||
|
recipe: e.recipe ? { id: e.recipe.id, title: e.recipe.title } : null,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -149,7 +149,23 @@ export default async function RecipePage({ params }: Params) {
|
|||||||
}))}
|
}))}
|
||||||
/>
|
/>
|
||||||
<PrintButton recipeId={id} />
|
<PrintButton recipeId={id} />
|
||||||
<VersionHistoryButton recipeId={id} />
|
<VersionHistoryButton
|
||||||
|
recipeId={id}
|
||||||
|
currentSnapshot={{
|
||||||
|
title: recipe.title,
|
||||||
|
description: recipe.description,
|
||||||
|
ingredients: recipe.ingredients.map((ing) => ({
|
||||||
|
rawName: ing.rawName,
|
||||||
|
quantity: ing.quantity,
|
||||||
|
unit: ing.unit,
|
||||||
|
note: ing.note,
|
||||||
|
})),
|
||||||
|
steps: recipe.steps.map((s) => ({
|
||||||
|
instruction: s.instruction,
|
||||||
|
timerSeconds: s.timerSeconds,
|
||||||
|
})),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger render={
|
<TooltipTrigger render={
|
||||||
<Link href={`/recipes/${id}/edit`} className={cn(buttonVariants({ variant: "ghost", size: "icon" }))}>
|
<Link href={`/recipes/${id}/edit`} className={cn(buttonVariants({ variant: "ghost", size: "icon" }))}>
|
||||||
|
|||||||
@@ -8,6 +8,14 @@ import { CanCookContent } from "@/components/recipe/can-cook-content";
|
|||||||
|
|
||||||
export const metadata: Metadata = { title: "What can I cook?" };
|
export const metadata: Metadata = { title: "What can I cook?" };
|
||||||
|
|
||||||
|
const EXPIRING_WITHIN_DAYS = 3;
|
||||||
|
|
||||||
|
function isExpiringSoon(expiresAt: Date | null): boolean {
|
||||||
|
if (!expiresAt) return false;
|
||||||
|
const days = Math.ceil((expiresAt.getTime() - Date.now()) / (1000 * 60 * 60 * 24));
|
||||||
|
return days >= 0 && days <= EXPIRING_WITHIN_DAYS;
|
||||||
|
}
|
||||||
|
|
||||||
export default async function CanCookPage() {
|
export default async function CanCookPage() {
|
||||||
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;
|
||||||
@@ -27,6 +35,12 @@ export default async function CanCookPage() {
|
|||||||
|
|
||||||
const pantryKeys = new Set(pantry.map((p) => p.rawName.toLowerCase()));
|
const pantryKeys = new Set(pantry.map((p) => p.rawName.toLowerCase()));
|
||||||
|
|
||||||
|
const expiringSoonKeys = new Set(
|
||||||
|
pantry
|
||||||
|
.filter((p) => isExpiringSoon(p.expiresAt))
|
||||||
|
.map((p) => p.rawName.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
const scored = userRecipes
|
const scored = userRecipes
|
||||||
.filter((r) => r.ingredients.length > 0)
|
.filter((r) => r.ingredients.length > 0)
|
||||||
.map((recipe) => {
|
.map((recipe) => {
|
||||||
@@ -37,6 +51,9 @@ export default async function CanCookPage() {
|
|||||||
.filter((ing) => !pantryKeys.has(ing.rawName.toLowerCase()))
|
.filter((ing) => !pantryKeys.has(ing.rawName.toLowerCase()))
|
||||||
.map((ing) => ing.rawName)
|
.map((ing) => ing.rawName)
|
||||||
.slice(0, 5);
|
.slice(0, 5);
|
||||||
|
const usesExpiring = recipe.ingredients
|
||||||
|
.filter((ing) => expiringSoonKeys.has(ing.rawName.toLowerCase()))
|
||||||
|
.map((ing) => ing.rawName);
|
||||||
const total = recipe.ingredients.length;
|
const total = recipe.ingredients.length;
|
||||||
const cover = recipe.photos?.find((p) => p.isCover) ?? recipe.photos?.[0];
|
const cover = recipe.photos?.find((p) => p.isCover) ?? recipe.photos?.[0];
|
||||||
return {
|
return {
|
||||||
@@ -50,9 +67,15 @@ export default async function CanCookPage() {
|
|||||||
total,
|
total,
|
||||||
pct: Math.round((matched / total) * 100),
|
pct: Math.round((matched / total) * 100),
|
||||||
missing,
|
missing,
|
||||||
|
usesExpiring,
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
.sort((a, b) => b.pct - a.pct);
|
.sort((a, b) => {
|
||||||
|
if (a.usesExpiring.length > 0 !== b.usesExpiring.length > 0) {
|
||||||
|
return a.usesExpiring.length > 0 ? -1 : 1;
|
||||||
|
}
|
||||||
|
return b.pct - a.pct;
|
||||||
|
});
|
||||||
|
|
||||||
return <CanCookContent pantryCount={pantry.length} scored={scored} />;
|
return <CanCookContent pantryCount={pantry.length} scored={scored} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,10 +4,13 @@ import { headers } from "next/headers";
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Printer } from "lucide-react";
|
import { Printer } from "lucide-react";
|
||||||
import { auth } from "@/lib/auth/server";
|
import { auth } from "@/lib/auth/server";
|
||||||
import { db, shoppingLists, eq, and } from "@epicure/db";
|
import { db, shoppingLists, eq } from "@epicure/db";
|
||||||
import { ShoppingListView } from "@/components/meal-plan/shopping-list-view";
|
import { ShoppingListView } from "@/components/meal-plan/shopping-list-view";
|
||||||
|
import { ShareShoppingListButton } from "@/components/shopping-lists/share-shopping-list-button";
|
||||||
|
import { GroceryExportButton } from "@/components/shopping-lists/grocery-export-button";
|
||||||
import { buttonVariants } from "@/components/ui/button";
|
import { buttonVariants } from "@/components/ui/button";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { getShoppingListAccess, canWriteShoppingList } from "@/lib/shopping-list-access";
|
||||||
|
|
||||||
type Params = { params: Promise<{ id: string }> };
|
type Params = { params: Promise<{ id: string }> };
|
||||||
|
|
||||||
@@ -18,29 +21,39 @@ export default async function ShoppingListPage({ params }: Params) {
|
|||||||
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 access = await getShoppingListAccess(id, session.user.id);
|
||||||
|
if (!access) notFound();
|
||||||
|
|
||||||
const list = await db.query.shoppingLists.findFirst({
|
const list = await db.query.shoppingLists.findFirst({
|
||||||
where: and(eq(shoppingLists.id, id), eq(shoppingLists.userId, session.user.id)),
|
where: eq(shoppingLists.id, id),
|
||||||
with: { items: { orderBy: (t, { asc }) => [asc(t.aisle), asc(t.rawName)] } },
|
with: { items: { orderBy: (t, { asc }) => [asc(t.aisle), asc(t.rawName)] } },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!list) notFound();
|
if (!list) notFound();
|
||||||
|
|
||||||
|
const canEdit = canWriteShoppingList(access.role);
|
||||||
|
const instacartEnabled = process.env["NEXT_PUBLIC_GROCERY_PROVIDER"] === "instacart";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 max-w-lg">
|
<div className="space-y-6 max-w-lg">
|
||||||
<div className="flex items-start justify-between">
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold tracking-tight">{list.name}</h1>
|
<h1 className="text-3xl font-bold tracking-tight">{list.name}</h1>
|
||||||
<p className="text-muted-foreground mt-1">
|
<p className="text-muted-foreground mt-1">
|
||||||
{list.items.length} items{list.generatedAt ? " · Generated from meal plan" : ""}
|
{list.items.length} items{list.generatedAt ? " · Generated from meal plan" : ""}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Link href={`/print/shopping-list/${id}`} target="_blank" className={cn(buttonVariants({ variant: "outline", size: "sm" }))}>
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<Printer className="h-4 w-4" />
|
<GroceryExportButton listId={id} instacartEnabled={instacartEnabled} />
|
||||||
Print
|
{access.role === "owner" && <ShareShoppingListButton listId={id} />}
|
||||||
</Link>
|
<Link href={`/print/shopping-list/${id}`} target="_blank" className={cn(buttonVariants({ variant: "outline", size: "sm" }))}>
|
||||||
|
<Printer className="h-4 w-4" />
|
||||||
|
Print
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<ShoppingListView
|
<ShoppingListView
|
||||||
listId={id}
|
listId={id}
|
||||||
|
readOnly={!canEdit}
|
||||||
initialItems={list.items.map((i) => ({
|
initialItems={list.items.map((i) => ({
|
||||||
id: i.id,
|
id: i.id,
|
||||||
rawName: i.rawName,
|
rawName: i.rawName,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
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, shoppingLists, eq, desc } from "@epicure/db";
|
import { db, shoppingLists, shoppingListMembers, eq, desc } from "@epicure/db";
|
||||||
import { ShoppingListsPageContent } from "@/components/shopping-lists/shopping-lists-page-content";
|
import { ShoppingListsPageContent } from "@/components/shopping-lists/shopping-lists-page-content";
|
||||||
|
|
||||||
export const metadata: Metadata = { title: "Shopping Lists" };
|
export const metadata: Metadata = { title: "Shopping Lists" };
|
||||||
@@ -10,11 +10,17 @@ export default async function ShoppingListsPage() {
|
|||||||
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 lists = await db.query.shoppingLists.findMany({
|
const [lists, memberships] = await Promise.all([
|
||||||
where: eq(shoppingLists.userId, session.user.id),
|
db.query.shoppingLists.findMany({
|
||||||
orderBy: desc(shoppingLists.createdAt),
|
where: eq(shoppingLists.userId, session.user.id),
|
||||||
with: { items: { columns: { id: true, checked: true } } },
|
orderBy: desc(shoppingLists.createdAt),
|
||||||
});
|
with: { items: { columns: { id: true, checked: true } } },
|
||||||
|
}),
|
||||||
|
db.query.shoppingListMembers.findMany({
|
||||||
|
where: eq(shoppingListMembers.userId, session.user.id),
|
||||||
|
with: { list: { with: { user: true } } },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ShoppingListsPageContent
|
<ShoppingListsPageContent
|
||||||
@@ -25,6 +31,12 @@ export default async function ShoppingListsPage() {
|
|||||||
totalItems: list.items.length,
|
totalItems: list.items.length,
|
||||||
checkedItems: list.items.filter((i) => i.checked).length,
|
checkedItems: list.items.filter((i) => i.checked).length,
|
||||||
}))}
|
}))}
|
||||||
|
sharedLists={memberships.map((m) => ({
|
||||||
|
id: m.list.id,
|
||||||
|
name: m.list.name,
|
||||||
|
ownerName: m.list.user?.name ?? "Unknown",
|
||||||
|
role: m.role,
|
||||||
|
}))}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { NextRequest } from "next/server";
|
||||||
|
|
||||||
|
const mockAdminSession = { user: { id: "admin-1", role: "admin" } };
|
||||||
|
|
||||||
|
vi.mock("next/headers", () => ({
|
||||||
|
headers: vi.fn().mockResolvedValue(new Headers()),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/auth/server", () => ({
|
||||||
|
auth: { api: { getSession: vi.fn() } },
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/site-settings", () => ({
|
||||||
|
setSiteSetting: vi.fn().mockResolvedValue(undefined),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { mockSelectChain, mockInsertValues } = vi.hoisted(() => {
|
||||||
|
const mockSelectChain = {
|
||||||
|
from: vi.fn().mockReturnThis(),
|
||||||
|
where: vi.fn().mockResolvedValue([{ role: "admin" }]),
|
||||||
|
};
|
||||||
|
return { mockSelectChain, mockInsertValues: vi.fn().mockResolvedValue(undefined) };
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("@epicure/db", () => ({
|
||||||
|
db: {
|
||||||
|
select: vi.fn(() => mockSelectChain),
|
||||||
|
insert: vi.fn(() => ({ values: mockInsertValues })),
|
||||||
|
},
|
||||||
|
users: { id: "id", role: "role" },
|
||||||
|
auditLogs: {},
|
||||||
|
eq: vi.fn((a, b) => ({ a, b, op: "eq" })),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { auth } = await import("@/lib/auth/server");
|
||||||
|
const { setSiteSetting } = await import("@/lib/site-settings");
|
||||||
|
import { PUT } from "../route";
|
||||||
|
|
||||||
|
function makeRequest(body: unknown) {
|
||||||
|
return new NextRequest("http://localhost/api/v1/admin/settings", {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
vi.mocked(auth.api.getSession).mockResolvedValue(mockAdminSession as never);
|
||||||
|
mockSelectChain.where.mockResolvedValue([{ role: "admin" }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("PUT /api/v1/admin/settings", () => {
|
||||||
|
it("returns 403 when caller is not an admin", async () => {
|
||||||
|
mockSelectChain.where.mockResolvedValue([{ role: "user" }]);
|
||||||
|
const res = await PUT(makeRequest({ OPENAI_API_KEY: "sk-1" }));
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 403 when there is no session", async () => {
|
||||||
|
vi.mocked(auth.api.getSession).mockResolvedValue(null as never);
|
||||||
|
const res = await PUT(makeRequest({ OPENAI_API_KEY: "sk-1" }));
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updates allowed keys and writes an audit log", async () => {
|
||||||
|
const res = await PUT(makeRequest({ OPENAI_API_KEY: "sk-1" }));
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(vi.mocked(setSiteSetting)).toHaveBeenCalledWith("OPENAI_API_KEY", "sk-1", "admin-1");
|
||||||
|
expect(mockInsertValues).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("silently ignores keys not in the allow-list", async () => {
|
||||||
|
const res = await PUT(makeRequest({ NOT_A_REAL_KEY: "x" }));
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(vi.mocked(setSiteSetting)).not.toHaveBeenCalled();
|
||||||
|
expect(mockInsertValues).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
import { NextRequest } from "next/server";
|
||||||
|
|
||||||
|
const mockAdminSession = { user: { id: "admin-1", role: "admin" } };
|
||||||
|
|
||||||
|
vi.mock("@/lib/api-auth", () => ({
|
||||||
|
requireAdmin: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/email", () => ({
|
||||||
|
sendEmail: vi.fn().mockResolvedValue(undefined),
|
||||||
|
verifyEmailHtml: vi.fn((url: string) => `<a href="${url}">verify</a>`),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { requireAdmin } = await import("@/lib/api-auth");
|
||||||
|
const { sendEmail } = await import("@/lib/email");
|
||||||
|
import { POST } from "../route";
|
||||||
|
|
||||||
|
function makeRequest(body: unknown) {
|
||||||
|
return new NextRequest("http://localhost/api/v1/admin/test-email", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const ORIGINAL_ENV = process.env["BETTER_AUTH_URL"];
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
vi.mocked(requireAdmin).mockResolvedValue({ session: mockAdminSession as never, response: null });
|
||||||
|
process.env["BETTER_AUTH_URL"] = "https://epicure.example.com";
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (ORIGINAL_ENV === undefined) delete process.env["BETTER_AUTH_URL"];
|
||||||
|
else process.env["BETTER_AUTH_URL"] = ORIGINAL_ENV;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("POST /api/v1/admin/test-email", () => {
|
||||||
|
it("returns 403 when caller is not an admin", async () => {
|
||||||
|
vi.mocked(requireAdmin).mockResolvedValue({
|
||||||
|
session: null,
|
||||||
|
response: new Response(JSON.stringify({ error: "Forbidden" }), { status: 403 }),
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
const res = await POST(makeRequest({ to: "user@example.com" }));
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 400 when 'to' is missing", async () => {
|
||||||
|
const res = await POST(makeRequest({}));
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 500 when BETTER_AUTH_URL is not configured", async () => {
|
||||||
|
delete process.env["BETTER_AUTH_URL"];
|
||||||
|
const res = await POST(makeRequest({ to: "user@example.com" }));
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
expect(sendEmail).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends the test email using the configured base URL", async () => {
|
||||||
|
const res = await POST(makeRequest({ to: "user@example.com" }));
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(sendEmail).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ to: "user@example.com" })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -9,11 +9,16 @@ export async function POST(req: NextRequest) {
|
|||||||
const { to } = await req.json() as { to: string };
|
const { to } = await req.json() as { to: string };
|
||||||
if (!to) return NextResponse.json({ error: "Missing 'to'" }, { status: 400 });
|
if (!to) return NextResponse.json({ error: "Missing 'to'" }, { status: 400 });
|
||||||
|
|
||||||
|
const baseUrl = process.env["BETTER_AUTH_URL"];
|
||||||
|
if (!baseUrl) {
|
||||||
|
return NextResponse.json({ error: "BETTER_AUTH_URL is not configured" }, { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await sendEmail({
|
await sendEmail({
|
||||||
to,
|
to,
|
||||||
subject: "Epicure — test email",
|
subject: "Epicure — test email",
|
||||||
html: verifyEmailHtml(`${process.env["BETTER_AUTH_URL"] ?? "http://localhost:3001"}/verify-email?token=test`),
|
html: verifyEmailHtml(`${baseUrl}/verify-email?token=test`),
|
||||||
});
|
});
|
||||||
return NextResponse.json({ ok: true });
|
return NextResponse.json({ ok: true });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { NextRequest } from "next/server";
|
||||||
|
|
||||||
|
const mockAdminSession = { user: { id: "admin-1", role: "admin" } };
|
||||||
|
|
||||||
|
vi.mock("@/lib/api-auth", () => ({
|
||||||
|
requireAdmin: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { mockUpdateChain, mockInsertValues } = vi.hoisted(() => {
|
||||||
|
const mockUpdateChain = {
|
||||||
|
set: vi.fn().mockReturnThis(),
|
||||||
|
where: vi.fn().mockReturnThis(),
|
||||||
|
returning: vi.fn().mockResolvedValue([{ id: "target-1", role: "moderator", tier: "free" }]),
|
||||||
|
};
|
||||||
|
return { mockUpdateChain, mockInsertValues: vi.fn().mockResolvedValue(undefined) };
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("@epicure/db", () => ({
|
||||||
|
db: {
|
||||||
|
update: vi.fn(() => mockUpdateChain),
|
||||||
|
insert: vi.fn(() => ({ values: mockInsertValues })),
|
||||||
|
},
|
||||||
|
users: { id: "id", role: "role", tier: "tier" },
|
||||||
|
auditLogs: {},
|
||||||
|
eq: vi.fn((a, b) => ({ a, b, op: "eq" })),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { requireAdmin } = await import("@/lib/api-auth");
|
||||||
|
import { PATCH } from "../route";
|
||||||
|
|
||||||
|
function makeRequest(body: unknown) {
|
||||||
|
return new NextRequest("http://localhost/api/v1/admin/users/target-1", {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const ctx = { params: Promise.resolve({ id: "target-1" }) };
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
vi.mocked(requireAdmin).mockResolvedValue({ session: mockAdminSession as never, response: null });
|
||||||
|
mockUpdateChain.returning.mockResolvedValue([{ id: "target-1", role: "moderator", tier: "free" }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("PATCH /api/v1/admin/users/[id]", () => {
|
||||||
|
it("returns 403 when caller is not an admin", async () => {
|
||||||
|
vi.mocked(requireAdmin).mockResolvedValue({
|
||||||
|
session: null,
|
||||||
|
response: new Response(JSON.stringify({ error: "Forbidden" }), { status: 403 }),
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
const res = await PATCH(makeRequest({ role: "admin" }), ctx);
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 400 for an invalid role", async () => {
|
||||||
|
const res = await PATCH(makeRequest({ role: "superuser" }), ctx);
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 400 for an invalid tier", async () => {
|
||||||
|
const res = await PATCH(makeRequest({ tier: "enterprise" }), ctx);
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 404 when the target user does not exist", async () => {
|
||||||
|
mockUpdateChain.returning.mockResolvedValue([]);
|
||||||
|
const res = await PATCH(makeRequest({ role: "moderator" }), ctx);
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updates the user and writes an audit log", async () => {
|
||||||
|
const res = await PATCH(makeRequest({ role: "moderator" }), ctx);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = await res.json() as { user: { id: string } };
|
||||||
|
expect(body.user.id).toBe("target-1");
|
||||||
|
expect(mockInsertValues).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ action: "admin.user.update", targetId: "target-1" })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,6 +3,7 @@ import { z } from "zod";
|
|||||||
import { db, userAiKeys, eq, and } from "@epicure/db";
|
import { db, userAiKeys, eq, and } from "@epicure/db";
|
||||||
import { requireSession } from "@/lib/api-auth";
|
import { requireSession } from "@/lib/api-auth";
|
||||||
import { encrypt } from "@/lib/encrypt";
|
import { encrypt } from "@/lib/encrypt";
|
||||||
|
import { applyRateLimit } from "@/lib/rate-limit";
|
||||||
|
|
||||||
const VALID_PROVIDERS = ["openai", "anthropic", "openrouter", "ollama"] as const;
|
const VALID_PROVIDERS = ["openai", "anthropic", "openrouter", "ollama"] as const;
|
||||||
|
|
||||||
@@ -27,6 +28,9 @@ export async function POST(req: Request) {
|
|||||||
const { session, response } = await requireSession();
|
const { session, response } = await requireSession();
|
||||||
if (response) return response;
|
if (response) return response;
|
||||||
|
|
||||||
|
const limited = await applyRateLimit(`rl:ai-keys:${session!.user.id}`, 5, 3600);
|
||||||
|
if (limited) return limited;
|
||||||
|
|
||||||
const body = PostSchema.safeParse(await req.json());
|
const body = PostSchema.safeParse(await req.json());
|
||||||
if (!body.success) return NextResponse.json({ error: body.error.flatten() }, { status: 400 });
|
if (!body.success) return NextResponse.json({ error: body.error.flatten() }, { status: 400 });
|
||||||
|
|
||||||
|
|||||||
@@ -78,71 +78,73 @@ export async function POST(req: NextRequest) {
|
|||||||
|
|
||||||
const createdEntries: Array<{ id: string; day: string; mealType: string; recipeId: string; recipeTitle: string }> = [];
|
const createdEntries: Array<{ id: string; day: string; mealType: string; recipeId: string; recipeTitle: string }> = [];
|
||||||
|
|
||||||
for (const entry of plan.entries) {
|
await db.transaction(async (tx) => {
|
||||||
// Create draft recipe
|
for (const entry of plan.entries) {
|
||||||
const recipeId = crypto.randomUUID();
|
// Create draft recipe
|
||||||
await db.insert(recipes).values({
|
const recipeId = crypto.randomUUID();
|
||||||
id: recipeId,
|
await tx.insert(recipes).values({
|
||||||
authorId: userId,
|
id: recipeId,
|
||||||
title: entry.recipe.title,
|
authorId: userId,
|
||||||
description: entry.recipe.description,
|
title: entry.recipe.title,
|
||||||
baseServings: entry.servings,
|
description: entry.recipe.description,
|
||||||
visibility: "private",
|
baseServings: entry.servings,
|
||||||
aiGenerated: true,
|
visibility: "private",
|
||||||
difficulty: entry.recipe.difficulty ?? null,
|
aiGenerated: true,
|
||||||
prepMins: entry.recipe.prepMins ?? null,
|
difficulty: entry.recipe.difficulty ?? null,
|
||||||
cookMins: entry.recipe.cookMins ?? null,
|
prepMins: entry.recipe.prepMins ?? null,
|
||||||
});
|
cookMins: entry.recipe.cookMins ?? null,
|
||||||
|
});
|
||||||
|
|
||||||
if (entry.recipe.ingredients.length > 0) {
|
if (entry.recipe.ingredients.length > 0) {
|
||||||
await db.insert(recipeIngredients).values(
|
await tx.insert(recipeIngredients).values(
|
||||||
entry.recipe.ingredients.map((ing, i) => ({
|
entry.recipe.ingredients.map((ing, i) => ({
|
||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
recipeId,
|
recipeId,
|
||||||
rawName: ing.rawName,
|
rawName: ing.rawName,
|
||||||
quantity: ing.quantity != null ? String(ing.quantity) : null,
|
quantity: ing.quantity != null ? String(ing.quantity) : null,
|
||||||
unit: ing.unit ?? null,
|
unit: ing.unit ?? null,
|
||||||
order: i,
|
order: i,
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.recipe.steps.length > 0) {
|
||||||
|
await tx.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 tx.query.mealPlanEntries.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(mealPlanEntries.mealPlanId, mealPlan!.id),
|
||||||
|
eq(mealPlanEntries.day, entry.day),
|
||||||
|
eq(mealPlanEntries.mealType, entry.mealType)
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingEntry) {
|
||||||
|
await tx.delete(mealPlanEntries).where(eq(mealPlanEntries.id, existingEntry.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
const entryId = crypto.randomUUID();
|
||||||
|
await tx.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 });
|
||||||
}
|
}
|
||||||
|
});
|
||||||
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 });
|
return NextResponse.json({ weekStart: parsed.data.weekStart, entries: createdEntries });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { db, collections, eq, desc } from "@epicure/db";
|
import { db, collections, eq, desc, sql } from "@epicure/db";
|
||||||
import { requireSession } from "@/lib/api-auth";
|
import { requireSession } from "@/lib/api-auth";
|
||||||
|
|
||||||
const Schema = z.object({
|
const Schema = z.object({
|
||||||
@@ -9,17 +9,42 @@ const Schema = z.object({
|
|||||||
isPublic: z.boolean().default(false),
|
isPublic: z.boolean().default(false),
|
||||||
});
|
});
|
||||||
|
|
||||||
export async function GET(_req: NextRequest) {
|
export async function GET(req: NextRequest) {
|
||||||
const { session, response } = await requireSession();
|
const { session, response } = await requireSession();
|
||||||
if (response) return response;
|
if (response) return response;
|
||||||
|
|
||||||
const rows = await db.query.collections.findMany({
|
const { searchParams } = req.nextUrl;
|
||||||
where: eq(collections.userId, session!.user.id),
|
|
||||||
orderBy: desc(collections.updatedAt),
|
|
||||||
with: { recipes: { limit: 4, with: { recipe: { with: { photos: true } } } } },
|
|
||||||
});
|
|
||||||
|
|
||||||
return NextResponse.json(rows);
|
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 where = eq(collections.userId, session!.user.id);
|
||||||
|
|
||||||
|
const [rows, countResult] = await Promise.all([
|
||||||
|
db.query.collections.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: desc(collections.updatedAt),
|
||||||
|
with: { recipes: { limit: 4, with: { recipe: { with: { photos: true } } } } },
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
}),
|
||||||
|
db.select({ total: sql<number>`count(*)::int` }).from(collections).where(where),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const total = countResult[0]?.total ?? 0;
|
||||||
|
|
||||||
|
return NextResponse.json({ data: rows, total, limit, offset });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { db, recipes, users, favorites, ratings, eq, and, ne, gte, notInArray, inArray, desc } from "@epicure/db";
|
||||||
|
import { requireSession } from "@/lib/api-auth";
|
||||||
|
import { buildPreferenceMap, rankForYou } from "@/lib/for-you-ranking";
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
const { session, response } = await requireSession();
|
||||||
|
if (response) return response;
|
||||||
|
const userId = session!.user.id;
|
||||||
|
|
||||||
|
const { searchParams } = new URL(req.url);
|
||||||
|
const limit = Math.min(parseInt(searchParams.get("limit") ?? "20"), 50);
|
||||||
|
|
||||||
|
// Recipes the user has favorited, or rated 4+, define their taste profile.
|
||||||
|
const [favoritedRows, highRatedRows] = await Promise.all([
|
||||||
|
db.select({ recipeId: favorites.recipeId }).from(favorites).where(eq(favorites.userId, userId)),
|
||||||
|
db.select({ recipeId: ratings.recipeId }).from(ratings).where(and(eq(ratings.userId, userId), gte(ratings.score, 4))),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const likedIds = [...new Set([...favoritedRows.map((r) => r.recipeId), ...highRatedRows.map((r) => r.recipeId)])];
|
||||||
|
|
||||||
|
const likedRecipes = likedIds.length > 0
|
||||||
|
? await db.select({ tags: recipes.tags, dietaryTags: recipes.dietaryTags }).from(recipes).where(inArray(recipes.id, likedIds))
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const preferences = buildPreferenceMap(likedRecipes);
|
||||||
|
|
||||||
|
const excludeIds = likedIds.length > 0 ? likedIds : ["__none__"];
|
||||||
|
|
||||||
|
const candidates = 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,
|
||||||
|
tags: recipes.tags,
|
||||||
|
dietaryTags: recipes.dietaryTags,
|
||||||
|
})
|
||||||
|
.from(recipes)
|
||||||
|
.innerJoin(users, eq(recipes.authorId, users.id))
|
||||||
|
.where(and(
|
||||||
|
eq(recipes.visibility, "public"),
|
||||||
|
ne(recipes.authorId, userId),
|
||||||
|
notInArray(recipes.id, excludeIds)
|
||||||
|
))
|
||||||
|
.orderBy(desc(recipes.createdAt))
|
||||||
|
.limit(200); // score a bounded recent window rather than the whole table
|
||||||
|
|
||||||
|
const ranked = preferences.size > 0
|
||||||
|
? rankForYou(candidates, preferences)
|
||||||
|
: [...candidates].sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
|
||||||
|
|
||||||
|
const data = ranked.slice(0, limit).map(({ tags: _tags, dietaryTags: _dietaryTags, ...r }) => ({
|
||||||
|
...r,
|
||||||
|
createdAt: r.createdAt.toISOString(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return NextResponse.json({ data });
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { NextRequest } from "next/server";
|
||||||
|
|
||||||
|
const mockSession = { user: { id: "user-1" } };
|
||||||
|
|
||||||
|
vi.mock("@/lib/api-auth", () => ({
|
||||||
|
requireSession: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { mockPlanFindFirst, mockMemberFindFirst, mockMemberFindMany, mockUserFindFirst, mockInsertValues, mockInsertPlanValues, mockDeleteWhere } = vi.hoisted(() => ({
|
||||||
|
mockPlanFindFirst: vi.fn(),
|
||||||
|
mockMemberFindFirst: vi.fn(),
|
||||||
|
mockMemberFindMany: vi.fn(),
|
||||||
|
mockUserFindFirst: vi.fn(),
|
||||||
|
mockInsertValues: vi.fn().mockResolvedValue(undefined),
|
||||||
|
mockInsertPlanValues: vi.fn().mockResolvedValue(undefined),
|
||||||
|
mockDeleteWhere: vi.fn().mockResolvedValue(undefined),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@epicure/db", () => ({
|
||||||
|
db: {
|
||||||
|
query: {
|
||||||
|
mealPlans: { findFirst: mockPlanFindFirst },
|
||||||
|
mealPlanMembers: { findFirst: mockMemberFindFirst, findMany: mockMemberFindMany },
|
||||||
|
users: { findFirst: mockUserFindFirst },
|
||||||
|
},
|
||||||
|
insert: vi.fn(() => ({ values: mockInsertValues })),
|
||||||
|
delete: vi.fn(() => ({ where: mockDeleteWhere })),
|
||||||
|
},
|
||||||
|
mealPlans: { id: "id", userId: "user_id", weekStart: "week_start" },
|
||||||
|
mealPlanMembers: { id: "id", mealPlanId: "meal_plan_id", userId: "user_id" },
|
||||||
|
users: { id: "id", email: "email" },
|
||||||
|
eq: vi.fn((a, b) => ({ a, b, op: "eq" })),
|
||||||
|
and: vi.fn((...args) => ({ args, op: "and" })),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { requireSession } = await import("@/lib/api-auth");
|
||||||
|
import { GET, POST, DELETE } from "../route";
|
||||||
|
|
||||||
|
const ctx = { params: Promise.resolve({ weekStart: "2026-06-01" }) };
|
||||||
|
|
||||||
|
function makeRequest(method: string, body?: unknown, search = "") {
|
||||||
|
return new NextRequest(`http://localhost/api/v1/meal-plans/2026-06-01/members${search}`, {
|
||||||
|
method,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
vi.mocked(requireSession).mockResolvedValue({ session: mockSession as never, response: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("GET /api/v1/meal-plans/[weekStart]/members", () => {
|
||||||
|
it("returns an empty list when the owner has no plan for this week yet", async () => {
|
||||||
|
mockPlanFindFirst.mockResolvedValue(undefined);
|
||||||
|
const res = await GET(makeRequest("GET"), ctx);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(await res.json()).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns members for an existing plan", async () => {
|
||||||
|
mockPlanFindFirst.mockResolvedValue({ id: "plan-1", userId: "user-1" });
|
||||||
|
mockMemberFindMany.mockResolvedValue([
|
||||||
|
{ id: "m1", userId: "user-2", role: "editor", createdAt: new Date(), user: { name: "Bob", username: null, avatarUrl: null } },
|
||||||
|
]);
|
||||||
|
const res = await GET(makeRequest("GET"), ctx);
|
||||||
|
const body = await res.json() as unknown[];
|
||||||
|
expect(body).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("POST /api/v1/meal-plans/[weekStart]/members", () => {
|
||||||
|
it("returns 400 on invalid body", async () => {
|
||||||
|
const res = await POST(makeRequest("POST", { role: "viewer" }), ctx);
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 404 when the target user doesn't exist", async () => {
|
||||||
|
mockUserFindFirst.mockResolvedValue(undefined);
|
||||||
|
const res = await POST(makeRequest("POST", { email: "b@test.com", role: "viewer" }), ctx);
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 400 when inviting yourself", async () => {
|
||||||
|
mockUserFindFirst.mockResolvedValue({ id: "user-1" });
|
||||||
|
const res = await POST(makeRequest("POST", { email: "a@test.com", role: "viewer" }), ctx);
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("auto-creates the plan for the week and invites the member", async () => {
|
||||||
|
mockUserFindFirst.mockResolvedValue({ id: "user-2" });
|
||||||
|
mockPlanFindFirst.mockResolvedValue(undefined); // no plan yet for this week
|
||||||
|
mockMemberFindFirst.mockResolvedValue(undefined);
|
||||||
|
const res = await POST(makeRequest("POST", { email: "b@test.com", role: "editor" }), ctx);
|
||||||
|
expect(res.status).toBe(201);
|
||||||
|
expect(mockInsertValues).toHaveBeenCalledWith(expect.objectContaining({ userId: "user-1", weekStart: "2026-06-01" }));
|
||||||
|
expect(mockInsertValues).toHaveBeenCalledWith(expect.objectContaining({ userId: "user-2", role: "editor" }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 409 when already a member", async () => {
|
||||||
|
mockUserFindFirst.mockResolvedValue({ id: "user-2" });
|
||||||
|
mockPlanFindFirst.mockResolvedValue({ id: "plan-1", userId: "user-1", weekStart: "2026-06-01" });
|
||||||
|
mockMemberFindFirst.mockResolvedValue({ id: "existing" });
|
||||||
|
const res = await POST(makeRequest("POST", { email: "b@test.com", role: "viewer" }), ctx);
|
||||||
|
expect(res.status).toBe(409);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("DELETE /api/v1/meal-plans/[weekStart]/members", () => {
|
||||||
|
it("returns 400 when memberId is missing", async () => {
|
||||||
|
const res = await DELETE(makeRequest("DELETE"), ctx);
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 404 when the owner has no plan for this week", async () => {
|
||||||
|
mockPlanFindFirst.mockResolvedValue(undefined);
|
||||||
|
const res = await DELETE(makeRequest("DELETE", undefined, "?memberId=m1"), ctx);
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 403 when caller is neither owner nor the member themselves", async () => {
|
||||||
|
mockPlanFindFirst.mockResolvedValue({ id: "plan-1", userId: "user-1" });
|
||||||
|
mockMemberFindFirst.mockResolvedValue({ id: "m1", userId: "user-2" });
|
||||||
|
vi.mocked(requireSession).mockResolvedValue({ session: { user: { id: "user-3" } } as never, response: null });
|
||||||
|
const res = await DELETE(makeRequest("DELETE", undefined, "?memberId=m1"), ctx);
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows the owner to remove a member", async () => {
|
||||||
|
mockPlanFindFirst.mockResolvedValue({ id: "plan-1", userId: "user-1" });
|
||||||
|
mockMemberFindFirst.mockResolvedValue({ id: "m1", userId: "user-2" });
|
||||||
|
const res = await DELETE(makeRequest("DELETE", undefined, "?memberId=m1"), ctx);
|
||||||
|
expect(res.status).toBe(204);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { db, mealPlans, mealPlanMembers, users, eq, and } from "@epicure/db";
|
||||||
|
import { requireSession } from "@/lib/api-auth";
|
||||||
|
|
||||||
|
type Params = { params: Promise<{ weekStart: string }> };
|
||||||
|
|
||||||
|
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, createdAt: new Date() };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── GET /api/v1/meal-plans/[weekStart]/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 { weekStart } = 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([]);
|
||||||
|
|
||||||
|
const members = await db.query.mealPlanMembers.findMany({
|
||||||
|
where: eq(mealPlanMembers.mealPlanId, plan.id),
|
||||||
|
with: { user: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
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 },
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── POST /api/v1/meal-plans/[weekStart]/members ─────────────────────────────
|
||||||
|
// Owner only — invite by email or userId. Auto-creates the plan for this week if missing.
|
||||||
|
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 { weekStart } = await params;
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
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 });
|
||||||
|
|
||||||
|
if (targetUser.id === session!.user.id) {
|
||||||
|
return NextResponse.json({ error: "Cannot invite yourself" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const plan = await getOrCreatePlan(session!.user.id, weekStart);
|
||||||
|
|
||||||
|
const existing = await db.query.mealPlanMembers.findFirst({
|
||||||
|
where: and(eq(mealPlanMembers.mealPlanId, plan.id), eq(mealPlanMembers.userId, targetUser.id)),
|
||||||
|
});
|
||||||
|
if (existing) return NextResponse.json({ error: "Already a member" }, { status: 409 });
|
||||||
|
|
||||||
|
const memberId = crypto.randomUUID();
|
||||||
|
await db.insert(mealPlanMembers).values({
|
||||||
|
id: memberId,
|
||||||
|
mealPlanId: plan.id,
|
||||||
|
userId: targetUser.id,
|
||||||
|
role,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ id: memberId, mealPlanId: plan.id }, { status: 201 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── DELETE /api/v1/meal-plans/[weekStart]/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 { weekStart } = await params;
|
||||||
|
const memberId = req.nextUrl.searchParams.get("memberId");
|
||||||
|
if (!memberId) return NextResponse.json({ error: "memberId required" }, { status: 400 });
|
||||||
|
|
||||||
|
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 });
|
||||||
|
|
||||||
|
const member = await db.query.mealPlanMembers.findFirst({
|
||||||
|
where: and(eq(mealPlanMembers.id, memberId), eq(mealPlanMembers.mealPlanId, plan.id)),
|
||||||
|
});
|
||||||
|
if (!member) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||||
|
|
||||||
|
const isOwner = plan.userId === session!.user.id;
|
||||||
|
const isSelf = member.userId === session!.user.id;
|
||||||
|
if (!isOwner && !isSelf) return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||||
|
|
||||||
|
await db.delete(mealPlanMembers).where(eq(mealPlanMembers.id, memberId));
|
||||||
|
return new NextResponse(null, { status: 204 });
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { NextRequest } from "next/server";
|
||||||
|
|
||||||
|
const mockSession = { user: { id: "user-2" } };
|
||||||
|
|
||||||
|
vi.mock("@/lib/api-auth", () => ({
|
||||||
|
requireSession: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/webhooks", () => ({
|
||||||
|
dispatchWebhook: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { mockPlanFindFirst, mockMemberFindFirst, mockRecipeFindFirst, mockInsertValues, mockDeleteWhere } = vi.hoisted(() => ({
|
||||||
|
mockPlanFindFirst: vi.fn(),
|
||||||
|
mockMemberFindFirst: vi.fn(),
|
||||||
|
mockRecipeFindFirst: vi.fn(),
|
||||||
|
mockInsertValues: vi.fn().mockResolvedValue(undefined),
|
||||||
|
mockDeleteWhere: vi.fn().mockResolvedValue(undefined),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@epicure/db", () => ({
|
||||||
|
db: {
|
||||||
|
query: {
|
||||||
|
mealPlans: { findFirst: mockPlanFindFirst },
|
||||||
|
mealPlanMembers: { findFirst: mockMemberFindFirst },
|
||||||
|
recipes: { findFirst: mockRecipeFindFirst },
|
||||||
|
},
|
||||||
|
insert: vi.fn(() => ({ values: mockInsertValues })),
|
||||||
|
delete: vi.fn(() => ({ where: mockDeleteWhere })),
|
||||||
|
},
|
||||||
|
mealPlans: { id: "id", userId: "user_id" },
|
||||||
|
mealPlanMembers: { mealPlanId: "meal_plan_id", userId: "user_id" },
|
||||||
|
mealPlanEntries: { id: "id", mealPlanId: "meal_plan_id", day: "day", mealType: "meal_type" },
|
||||||
|
recipes: { id: "id", authorId: "author_id", visibility: "visibility" },
|
||||||
|
eq: vi.fn((a, b) => ({ a, b, op: "eq" })),
|
||||||
|
and: vi.fn((...args) => ({ args, op: "and" })),
|
||||||
|
or: vi.fn((...args) => ({ args, op: "or" })),
|
||||||
|
ne: vi.fn((a, b) => ({ a, b, op: "ne" })),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { requireSession } = await import("@/lib/api-auth");
|
||||||
|
import { POST, DELETE } from "../route";
|
||||||
|
|
||||||
|
const ctx = { params: Promise.resolve({ mealPlanId: "plan-1" }) };
|
||||||
|
|
||||||
|
function makeRequest(method: string, body?: unknown, search = "") {
|
||||||
|
return new NextRequest(`http://localhost/api/v1/meal-plans/shared/plan-1/entries${search}`, {
|
||||||
|
method,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
vi.mocked(requireSession).mockResolvedValue({ session: mockSession as never, response: null });
|
||||||
|
mockPlanFindFirst.mockResolvedValue({ id: "plan-1", userId: "user-1" });
|
||||||
|
});
|
||||||
|
|
||||||
|
const validBody = { day: "mon", mealType: "dinner", recipeId: "r-1", servings: 2 };
|
||||||
|
|
||||||
|
describe("POST /api/v1/meal-plans/shared/[mealPlanId]/entries", () => {
|
||||||
|
it("returns 404 when the caller has no access to the plan", async () => {
|
||||||
|
mockMemberFindFirst.mockResolvedValue(undefined);
|
||||||
|
const res = await POST(makeRequest("POST", validBody), ctx);
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 403 for a viewer trying to add an entry", async () => {
|
||||||
|
mockMemberFindFirst.mockResolvedValue({ role: "viewer" });
|
||||||
|
const res = await POST(makeRequest("POST", validBody), ctx);
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows an editor to add an entry", async () => {
|
||||||
|
mockMemberFindFirst.mockResolvedValue({ role: "editor" });
|
||||||
|
mockRecipeFindFirst.mockResolvedValue({ id: "r-1" });
|
||||||
|
const res = await POST(makeRequest("POST", validBody), ctx);
|
||||||
|
expect(res.status).toBe(201);
|
||||||
|
expect(mockInsertValues).toHaveBeenCalledWith(expect.objectContaining({ mealPlanId: "plan-1", day: "mon" }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 404 when the recipe isn't accessible to the editor", async () => {
|
||||||
|
mockMemberFindFirst.mockResolvedValue({ role: "editor" });
|
||||||
|
mockRecipeFindFirst.mockResolvedValue(undefined);
|
||||||
|
const res = await POST(makeRequest("POST", validBody), ctx);
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("DELETE /api/v1/meal-plans/shared/[mealPlanId]/entries", () => {
|
||||||
|
it("returns 403 for a viewer trying to remove an entry", async () => {
|
||||||
|
mockMemberFindFirst.mockResolvedValue({ role: "viewer" });
|
||||||
|
const res = await DELETE(makeRequest("DELETE", undefined, "?entryId=e1"), ctx);
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows an editor to remove an entry", async () => {
|
||||||
|
mockMemberFindFirst.mockResolvedValue({ role: "editor" });
|
||||||
|
const res = await DELETE(makeRequest("DELETE", undefined, "?entryId=e1"), ctx);
|
||||||
|
expect(res.status).toBe(204);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { db, mealPlanEntries, recipes, eq, and, or, ne } from "@epicure/db";
|
||||||
|
import { requireSession } from "@/lib/api-auth";
|
||||||
|
import { getMealPlanAccessById, canWriteMealPlan } from "@/lib/meal-plan-access";
|
||||||
|
import { dispatchWebhook } from "@/lib/webhooks";
|
||||||
|
|
||||||
|
type Params = { params: Promise<{ mealPlanId: 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(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest, { params }: Params) {
|
||||||
|
const { session, response } = await requireSession();
|
||||||
|
if (response) return response;
|
||||||
|
const { mealPlanId } = await params;
|
||||||
|
|
||||||
|
const access = await getMealPlanAccessById(mealPlanId, session!.user.id);
|
||||||
|
if (!access) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||||
|
if (!canWriteMealPlan(access.role)) return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||||
|
|
||||||
|
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.recipeId) {
|
||||||
|
const recipe = await db.query.recipes.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(recipes.id, parsed.data.recipeId),
|
||||||
|
or(eq(recipes.authorId, session!.user.id), ne(recipes.visibility, "private"))
|
||||||
|
),
|
||||||
|
});
|
||||||
|
if (!recipe) return NextResponse.json({ error: "Recipe not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.delete(mealPlanEntries).where(
|
||||||
|
and(
|
||||||
|
eq(mealPlanEntries.mealPlanId, mealPlanId),
|
||||||
|
eq(mealPlanEntries.day, parsed.data.day),
|
||||||
|
eq(mealPlanEntries.mealType, parsed.data.mealType)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const entryId = crypto.randomUUID();
|
||||||
|
await db.insert(mealPlanEntries).values({
|
||||||
|
id: entryId,
|
||||||
|
mealPlanId,
|
||||||
|
day: parsed.data.day,
|
||||||
|
mealType: parsed.data.mealType,
|
||||||
|
recipeId: parsed.data.recipeId,
|
||||||
|
servings: parsed.data.servings,
|
||||||
|
note: parsed.data.note,
|
||||||
|
});
|
||||||
|
|
||||||
|
void dispatchWebhook(access.plan.userId, "meal_plan.updated", { mealPlanId, day: parsed.data.day, mealType: parsed.data.mealType });
|
||||||
|
return NextResponse.json({ id: entryId }, { status: 201 });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(req: NextRequest, { params }: Params) {
|
||||||
|
const { session, response } = await requireSession();
|
||||||
|
if (response) return response;
|
||||||
|
const { mealPlanId } = await params;
|
||||||
|
|
||||||
|
const access = await getMealPlanAccessById(mealPlanId, session!.user.id);
|
||||||
|
if (!access) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||||
|
if (!canWriteMealPlan(access.role)) return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||||
|
|
||||||
|
const entryId = req.nextUrl.searchParams.get("entryId");
|
||||||
|
if (!entryId) return NextResponse.json({ error: "entryId required" }, { status: 400 });
|
||||||
|
|
||||||
|
await db.delete(mealPlanEntries).where(
|
||||||
|
and(eq(mealPlanEntries.id, entryId), eq(mealPlanEntries.mealPlanId, mealPlanId))
|
||||||
|
);
|
||||||
|
|
||||||
|
return new NextResponse(null, { status: 204 });
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { db, mealPlans, users, eq } from "@epicure/db";
|
||||||
|
import { requireSession } from "@/lib/api-auth";
|
||||||
|
import { getMealPlanAccessById } from "@/lib/meal-plan-access";
|
||||||
|
|
||||||
|
type Params = { params: Promise<{ mealPlanId: string }> };
|
||||||
|
|
||||||
|
export async function GET(_req: NextRequest, { params }: Params) {
|
||||||
|
const { session, response } = await requireSession();
|
||||||
|
if (response) return response;
|
||||||
|
const { mealPlanId } = await params;
|
||||||
|
|
||||||
|
const access = await getMealPlanAccessById(mealPlanId, session!.user.id);
|
||||||
|
if (!access) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||||
|
|
||||||
|
const plan = await db.query.mealPlans.findFirst({
|
||||||
|
where: eq(mealPlans.id, mealPlanId),
|
||||||
|
with: {
|
||||||
|
entries: { with: { recipe: { with: { photos: true } } } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!plan) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||||
|
|
||||||
|
const owner = await db.query.users.findFirst({ where: eq(users.id, plan.userId) });
|
||||||
|
|
||||||
|
return NextResponse.json({ ...plan, role: access.role, owner: owner ? { name: owner.name } : null });
|
||||||
|
}
|
||||||
@@ -12,7 +12,7 @@ vi.mock("@/lib/api-auth", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@/lib/tiers", () => ({
|
vi.mock("@/lib/tiers", () => ({
|
||||||
checkTierLimit: vi.fn(),
|
checkAndIncrementTierLimit: vi.fn(),
|
||||||
incrementUsage: vi.fn(),
|
incrementUsage: vi.fn(),
|
||||||
TierLimitError: class TierLimitError extends Error {},
|
TierLimitError: class TierLimitError extends Error {},
|
||||||
}));
|
}));
|
||||||
@@ -134,9 +134,9 @@ describe("POST /api/v1/recipes", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("returns 403 when tier limit reached", async () => {
|
it("returns 403 when tier limit reached", async () => {
|
||||||
const { checkTierLimit } = await import("@/lib/tiers");
|
const { checkAndIncrementTierLimit } = await import("@/lib/tiers");
|
||||||
const { TierLimitError } = await import("@/lib/tiers");
|
const { TierLimitError } = await import("@/lib/tiers");
|
||||||
vi.mocked(checkTierLimit).mockRejectedValue(new TierLimitError("recipe", "free"));
|
vi.mocked(checkAndIncrementTierLimit).mockRejectedValue(new TierLimitError("recipe", "free"));
|
||||||
|
|
||||||
const res = await POST(makeRequest("POST", validRecipe));
|
const res = await POST(makeRequest("POST", validRecipe));
|
||||||
expect(res.status).toBe(403);
|
expect(res.status).toBe(403);
|
||||||
|
|||||||
@@ -85,7 +85,8 @@ export async function GET(req: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const tag of dietaryTags) {
|
for (const tag of dietaryTags) {
|
||||||
conditions.push(sql`${recipes.dietaryTags}->>${tag} = 'true'`);
|
// Containment (@>) instead of ->> text extraction so the GIN index on dietaryTags is actually used.
|
||||||
|
conditions.push(sql`${recipes.dietaryTags} @> ${JSON.stringify({ [tag]: true })}::jsonb`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const where = and(...conditions);
|
const where = and(...conditions);
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { db, shoppingLists, eq } from "@epicure/db";
|
||||||
|
import { requireSession } from "@/lib/api-auth";
|
||||||
|
import { getShoppingListAccess } from "@/lib/shopping-list-access";
|
||||||
|
import { buildGroceryExportPayload } from "@/lib/grocery-export";
|
||||||
|
import { createInstacartShoppingListLink } from "@/lib/grocery-providers/instacart";
|
||||||
|
|
||||||
|
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 access = await getShoppingListAccess(id, session!.user.id);
|
||||||
|
if (!access) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||||
|
|
||||||
|
const list = await db.query.shoppingLists.findFirst({
|
||||||
|
where: eq(shoppingLists.id, id),
|
||||||
|
with: { items: true },
|
||||||
|
});
|
||||||
|
if (!list) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||||
|
|
||||||
|
const payload = buildGroceryExportPayload(list);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await createInstacartShoppingListLink(payload);
|
||||||
|
if (!result) return NextResponse.json({ error: "Instacart is not configured" }, { status: 501 });
|
||||||
|
return NextResponse.json(result);
|
||||||
|
} catch (err) {
|
||||||
|
return NextResponse.json({ error: String(err instanceof Error ? err.message : err) }, { status: 501 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { db, shoppingLists, eq } from "@epicure/db";
|
||||||
|
import { requireSession } from "@/lib/api-auth";
|
||||||
|
import { getShoppingListAccess } from "@/lib/shopping-list-access";
|
||||||
|
import { buildGroceryExportPayload } from "@/lib/grocery-export";
|
||||||
|
|
||||||
|
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 access = await getShoppingListAccess(id, session!.user.id);
|
||||||
|
if (!access) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||||
|
|
||||||
|
const list = await db.query.shoppingLists.findFirst({
|
||||||
|
where: eq(shoppingLists.id, id),
|
||||||
|
with: { items: true },
|
||||||
|
});
|
||||||
|
if (!list) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||||
|
|
||||||
|
const payload = buildGroceryExportPayload(list);
|
||||||
|
return NextResponse.json(payload);
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { db, shoppingLists, shoppingListItems, eq, and } from "@epicure/db";
|
import { db, shoppingListItems, eq, and } from "@epicure/db";
|
||||||
import { requireSession } from "@/lib/api-auth";
|
import { requireSession } from "@/lib/api-auth";
|
||||||
|
import { getShoppingListAccess, canWriteShoppingList } from "@/lib/shopping-list-access";
|
||||||
|
|
||||||
type Params = { params: Promise<{ id: string; itemId: string }> };
|
type Params = { params: Promise<{ id: string; itemId: string }> };
|
||||||
|
|
||||||
@@ -9,10 +10,9 @@ export async function PUT(req: NextRequest, { params }: Params) {
|
|||||||
if (response) return response;
|
if (response) return response;
|
||||||
const { id, itemId } = await params;
|
const { id, itemId } = await params;
|
||||||
|
|
||||||
const list = await db.query.shoppingLists.findFirst({
|
const access = await getShoppingListAccess(id, session!.user.id);
|
||||||
where: and(eq(shoppingLists.id, id), eq(shoppingLists.userId, session!.user.id)),
|
if (!access) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||||
});
|
if (!canWriteShoppingList(access.role)) return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||||
if (!list) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
|
||||||
|
|
||||||
const body = await req.json() as { checked?: boolean };
|
const body = await req.json() as { checked?: boolean };
|
||||||
await db.update(shoppingListItems)
|
await db.update(shoppingListItems)
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { db, shoppingLists, shoppingListItems, eq, and } from "@epicure/db";
|
import { db, shoppingListItems } from "@epicure/db";
|
||||||
import { requireSession } from "@/lib/api-auth";
|
import { requireSession } from "@/lib/api-auth";
|
||||||
|
import { getShoppingListAccess, canWriteShoppingList } from "@/lib/shopping-list-access";
|
||||||
|
|
||||||
const AddItemsSchema = z.object({
|
const AddItemsSchema = z.object({
|
||||||
items: z.array(z.object({
|
items: z.array(z.object({
|
||||||
@@ -19,10 +20,9 @@ export async function POST(req: NextRequest, { params }: Params) {
|
|||||||
if (response) return response;
|
if (response) return response;
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
|
|
||||||
const list = await db.query.shoppingLists.findFirst({
|
const access = await getShoppingListAccess(id, session!.user.id);
|
||||||
where: and(eq(shoppingLists.id, id), eq(shoppingLists.userId, session!.user.id)),
|
if (!access) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||||
});
|
if (!canWriteShoppingList(access.role)) return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||||
if (!list) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
|
||||||
|
|
||||||
const body = await req.json() as unknown;
|
const body = await req.json() as unknown;
|
||||||
const parsed = AddItemsSchema.safeParse(body);
|
const parsed = AddItemsSchema.safeParse(body);
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { NextRequest } from "next/server";
|
||||||
|
|
||||||
|
const mockSession = { user: { id: "user-1" } };
|
||||||
|
|
||||||
|
vi.mock("@/lib/api-auth", () => ({
|
||||||
|
requireSession: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { mockListFindFirst, mockMemberFindFirst, mockMemberFindMany, mockUserFindFirst, mockInsertValues, mockDeleteWhere } = vi.hoisted(() => ({
|
||||||
|
mockListFindFirst: vi.fn(),
|
||||||
|
mockMemberFindFirst: vi.fn(),
|
||||||
|
mockMemberFindMany: vi.fn(),
|
||||||
|
mockUserFindFirst: vi.fn(),
|
||||||
|
mockInsertValues: vi.fn().mockResolvedValue(undefined),
|
||||||
|
mockDeleteWhere: vi.fn().mockResolvedValue(undefined),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@epicure/db", () => ({
|
||||||
|
db: {
|
||||||
|
query: {
|
||||||
|
shoppingLists: { findFirst: mockListFindFirst },
|
||||||
|
shoppingListMembers: { findFirst: mockMemberFindFirst, findMany: mockMemberFindMany },
|
||||||
|
users: { findFirst: mockUserFindFirst },
|
||||||
|
},
|
||||||
|
insert: vi.fn(() => ({ values: mockInsertValues })),
|
||||||
|
delete: vi.fn(() => ({ where: mockDeleteWhere })),
|
||||||
|
},
|
||||||
|
shoppingLists: { id: "id", userId: "user_id" },
|
||||||
|
shoppingListMembers: { id: "id", listId: "list_id", userId: "user_id" },
|
||||||
|
users: { id: "id", email: "email" },
|
||||||
|
eq: vi.fn((a, b) => ({ a, b, op: "eq" })),
|
||||||
|
and: vi.fn((...args) => ({ args, op: "and" })),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { requireSession } = await import("@/lib/api-auth");
|
||||||
|
import { GET, POST, DELETE } from "../route";
|
||||||
|
|
||||||
|
const ctx = { params: Promise.resolve({ id: "list-1" }) };
|
||||||
|
|
||||||
|
function makeRequest(method: string, body?: unknown, search = "") {
|
||||||
|
return new NextRequest(`http://localhost/api/v1/shopping-lists/list-1/members${search}`, {
|
||||||
|
method,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
vi.mocked(requireSession).mockResolvedValue({ session: mockSession as never, response: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("GET /api/v1/shopping-lists/[id]/members", () => {
|
||||||
|
it("returns 404 when the caller is not the owner", async () => {
|
||||||
|
mockListFindFirst.mockResolvedValue(undefined);
|
||||||
|
const res = await GET(makeRequest("GET"), ctx);
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the member list for the owner", async () => {
|
||||||
|
mockListFindFirst.mockResolvedValue({ id: "list-1", userId: "user-1" });
|
||||||
|
mockMemberFindMany.mockResolvedValue([
|
||||||
|
{ id: "m1", userId: "user-2", role: "viewer", createdAt: new Date(), user: { name: "Bob", username: "bob", avatarUrl: null } },
|
||||||
|
]);
|
||||||
|
const res = await GET(makeRequest("GET"), ctx);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = await res.json() as unknown[];
|
||||||
|
expect(body).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("POST /api/v1/shopping-lists/[id]/members", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockListFindFirst.mockResolvedValue({ id: "list-1", userId: "user-1" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 404 when the caller is not the owner", async () => {
|
||||||
|
mockListFindFirst.mockResolvedValue(undefined);
|
||||||
|
const res = await POST(makeRequest("POST", { email: "b@test.com", role: "viewer" }), ctx);
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 400 on invalid body", async () => {
|
||||||
|
const res = await POST(makeRequest("POST", { role: "viewer" }), ctx);
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 404 when the target user doesn't exist", async () => {
|
||||||
|
mockUserFindFirst.mockResolvedValue(undefined);
|
||||||
|
const res = await POST(makeRequest("POST", { email: "b@test.com", role: "viewer" }), ctx);
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 400 when inviting yourself", async () => {
|
||||||
|
mockUserFindFirst.mockResolvedValue({ id: "user-1", email: "a@test.com" });
|
||||||
|
const res = await POST(makeRequest("POST", { email: "a@test.com", role: "viewer" }), ctx);
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 409 when already a member", async () => {
|
||||||
|
mockUserFindFirst.mockResolvedValue({ id: "user-2", email: "b@test.com" });
|
||||||
|
mockMemberFindFirst.mockResolvedValue({ id: "existing" });
|
||||||
|
const res = await POST(makeRequest("POST", { email: "b@test.com", role: "viewer" }), ctx);
|
||||||
|
expect(res.status).toBe(409);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates the membership on success", async () => {
|
||||||
|
mockUserFindFirst.mockResolvedValue({ id: "user-2", email: "b@test.com" });
|
||||||
|
mockMemberFindFirst.mockResolvedValue(undefined);
|
||||||
|
const res = await POST(makeRequest("POST", { email: "b@test.com", role: "editor" }), ctx);
|
||||||
|
expect(res.status).toBe(201);
|
||||||
|
expect(mockInsertValues).toHaveBeenCalledWith(expect.objectContaining({ listId: "list-1", userId: "user-2", role: "editor" }));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("DELETE /api/v1/shopping-lists/[id]/members", () => {
|
||||||
|
it("returns 400 when memberId is missing", async () => {
|
||||||
|
const res = await DELETE(makeRequest("DELETE"), ctx);
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 404 when the member doesn't exist", async () => {
|
||||||
|
mockMemberFindFirst.mockResolvedValue(undefined);
|
||||||
|
const res = await DELETE(makeRequest("DELETE", undefined, "?memberId=m1"), ctx);
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 403 when caller is neither owner nor the member themselves", async () => {
|
||||||
|
mockMemberFindFirst.mockResolvedValue({ id: "m1", userId: "user-2" });
|
||||||
|
mockListFindFirst.mockResolvedValue({ id: "list-1", userId: "user-3" });
|
||||||
|
const res = await DELETE(makeRequest("DELETE", undefined, "?memberId=m1"), ctx);
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows the owner to remove a member", async () => {
|
||||||
|
mockMemberFindFirst.mockResolvedValue({ id: "m1", userId: "user-2" });
|
||||||
|
mockListFindFirst.mockResolvedValue({ id: "list-1", userId: "user-1" });
|
||||||
|
const res = await DELETE(makeRequest("DELETE", undefined, "?memberId=m1"), ctx);
|
||||||
|
expect(res.status).toBe(204);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows a member to remove themselves", async () => {
|
||||||
|
mockMemberFindFirst.mockResolvedValue({ id: "m1", userId: "user-1" });
|
||||||
|
mockListFindFirst.mockResolvedValue({ id: "list-1", userId: "user-3" });
|
||||||
|
const res = await DELETE(makeRequest("DELETE", undefined, "?memberId=m1"), ctx);
|
||||||
|
expect(res.status).toBe(204);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { db, shoppingLists, shoppingListMembers, users, eq, and } from "@epicure/db";
|
||||||
|
import { requireSession } from "@/lib/api-auth";
|
||||||
|
|
||||||
|
type Params = { params: Promise<{ id: string }> };
|
||||||
|
|
||||||
|
// ─── GET /api/v1/shopping-lists/[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;
|
||||||
|
|
||||||
|
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 members = await db.query.shoppingListMembers.findMany({
|
||||||
|
where: eq(shoppingListMembers.listId, 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/shopping-lists/[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;
|
||||||
|
|
||||||
|
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 = InviteSchema.safeParse(body);
|
||||||
|
if (!parsed.success) return NextResponse.json({ error: "Validation error" }, { status: 400 });
|
||||||
|
|
||||||
|
const { email, userId, role } = parsed.data;
|
||||||
|
|
||||||
|
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 });
|
||||||
|
|
||||||
|
if (targetUser.id === session!.user.id) {
|
||||||
|
return NextResponse.json({ error: "Cannot invite yourself" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await db.query.shoppingListMembers.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(shoppingListMembers.listId, id),
|
||||||
|
eq(shoppingListMembers.userId, targetUser.id),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
if (existing) return NextResponse.json({ error: "Already a member" }, { status: 409 });
|
||||||
|
|
||||||
|
const memberId = crypto.randomUUID();
|
||||||
|
await db.insert(shoppingListMembers).values({
|
||||||
|
id: memberId,
|
||||||
|
listId: id,
|
||||||
|
userId: targetUser.id,
|
||||||
|
role,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ id: memberId }, { status: 201 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── DELETE /api/v1/shopping-lists/[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 });
|
||||||
|
|
||||||
|
const member = await db.query.shoppingListMembers.findFirst({
|
||||||
|
where: and(eq(shoppingListMembers.id, memberId), eq(shoppingListMembers.listId, id)),
|
||||||
|
});
|
||||||
|
if (!member) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||||
|
|
||||||
|
const list = await db.query.shoppingLists.findFirst({
|
||||||
|
where: eq(shoppingLists.id, id),
|
||||||
|
});
|
||||||
|
|
||||||
|
const isOwner = list?.userId === session!.user.id;
|
||||||
|
const isSelf = member.userId === session!.user.id;
|
||||||
|
|
||||||
|
if (!isOwner && !isSelf) {
|
||||||
|
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.delete(shoppingListMembers).where(eq(shoppingListMembers.id, memberId));
|
||||||
|
|
||||||
|
return new NextResponse(null, { status: 204 });
|
||||||
|
}
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { db, shoppingLists, shoppingListItems, eq, and } from "@epicure/db";
|
import { db, shoppingLists, shoppingListItems, eq } from "@epicure/db";
|
||||||
import { requireSession } from "@/lib/api-auth";
|
import { requireSession } from "@/lib/api-auth";
|
||||||
import { dispatchWebhook } from "@/lib/webhooks";
|
import { dispatchWebhook } from "@/lib/webhooks";
|
||||||
|
import { getShoppingListAccess, canWriteShoppingList } from "@/lib/shopping-list-access";
|
||||||
|
|
||||||
type Params = { params: Promise<{ id: string }> };
|
type Params = { params: Promise<{ id: string }> };
|
||||||
|
|
||||||
@@ -11,12 +12,14 @@ export async function GET(_req: NextRequest, { params }: Params) {
|
|||||||
if (response) return response;
|
if (response) return response;
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
|
|
||||||
|
const access = await getShoppingListAccess(id, session!.user.id);
|
||||||
|
if (!access) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||||
|
|
||||||
const list = await db.query.shoppingLists.findFirst({
|
const list = await db.query.shoppingLists.findFirst({
|
||||||
where: and(eq(shoppingLists.id, id), eq(shoppingLists.userId, session!.user.id)),
|
where: eq(shoppingLists.id, id),
|
||||||
with: { items: { orderBy: (t, { asc }) => [asc(t.aisle), asc(t.rawName)] } },
|
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);
|
return NextResponse.json(list);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -27,10 +30,9 @@ export async function PATCH(req: NextRequest, { params }: Params) {
|
|||||||
if (response) return response;
|
if (response) return response;
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
|
|
||||||
const list = await db.query.shoppingLists.findFirst({
|
const access = await getShoppingListAccess(id, session!.user.id);
|
||||||
where: and(eq(shoppingLists.id, id), eq(shoppingLists.userId, session!.user.id)),
|
if (!access) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||||
});
|
if (!canWriteShoppingList(access.role)) return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||||
if (!list) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
|
||||||
|
|
||||||
const body = PatchSchema.safeParse(await req.json());
|
const body = PatchSchema.safeParse(await req.json());
|
||||||
if (!body.success) return NextResponse.json({ error: "Validation error" }, { status: 400 });
|
if (!body.success) return NextResponse.json({ error: "Validation error" }, { status: 400 });
|
||||||
@@ -38,7 +40,7 @@ export async function PATCH(req: NextRequest, { params }: Params) {
|
|||||||
if (body.data.completed) {
|
if (body.data.completed) {
|
||||||
// Mark all items as checked
|
// Mark all items as checked
|
||||||
await db.update(shoppingListItems).set({ checked: true }).where(eq(shoppingListItems.listId, id));
|
await db.update(shoppingListItems).set({ checked: true }).where(eq(shoppingListItems.listId, id));
|
||||||
void dispatchWebhook(session!.user.id, "shopping_list.completed", { id, name: list.name });
|
void dispatchWebhook(session!.user.id, "shopping_list.completed", { id, name: access.list.name });
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json({ updated: true });
|
return NextResponse.json({ updated: true });
|
||||||
@@ -49,6 +51,10 @@ export async function DELETE(_req: NextRequest, { params }: Params) {
|
|||||||
if (response) return response;
|
if (response) return response;
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
|
|
||||||
await db.delete(shoppingLists).where(and(eq(shoppingLists.id, id), eq(shoppingLists.userId, session!.user.id)));
|
const access = await getShoppingListAccess(id, session!.user.id);
|
||||||
|
if (!access) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||||
|
if (access.role !== "owner") return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||||
|
|
||||||
|
await db.delete(shoppingLists).where(eq(shoppingLists.id, id));
|
||||||
return new NextResponse(null, { status: 204 });
|
return new NextResponse(null, { status: 204 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { NextRequest } from "next/server";
|
||||||
|
|
||||||
|
const mockSession = { user: { id: "user-1" } };
|
||||||
|
|
||||||
|
vi.mock("@/lib/api-auth", () => ({
|
||||||
|
requireSession: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/validate-webhook-url", () => ({
|
||||||
|
validateWebhookUrl: vi.fn().mockResolvedValue(null),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { mockSelectChain, mockDeleteChain, mockUpdateChain } = vi.hoisted(() => {
|
||||||
|
const mockSelectChain = {
|
||||||
|
from: vi.fn().mockReturnThis(),
|
||||||
|
where: vi.fn().mockReturnThis(),
|
||||||
|
limit: vi.fn().mockResolvedValue([{ id: "wh-1" }]),
|
||||||
|
};
|
||||||
|
const mockDeleteChain = { where: vi.fn().mockResolvedValue(undefined) };
|
||||||
|
const mockUpdateChain = { set: vi.fn().mockReturnThis(), where: vi.fn().mockResolvedValue(undefined) };
|
||||||
|
return { mockSelectChain, mockDeleteChain, mockUpdateChain };
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("@epicure/db", () => ({
|
||||||
|
db: {
|
||||||
|
select: vi.fn(() => mockSelectChain),
|
||||||
|
delete: vi.fn(() => mockDeleteChain),
|
||||||
|
update: vi.fn(() => mockUpdateChain),
|
||||||
|
},
|
||||||
|
webhooks: { id: "id", userId: "user_id", url: "url", events: "events", active: "active", createdAt: "created_at" },
|
||||||
|
eq: vi.fn((a, b) => ({ a, b, op: "eq" })),
|
||||||
|
and: vi.fn((...args) => ({ args, op: "and" })),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { requireSession } = await import("@/lib/api-auth");
|
||||||
|
const { validateWebhookUrl } = await import("@/lib/validate-webhook-url");
|
||||||
|
import { DELETE, PATCH } from "../route";
|
||||||
|
|
||||||
|
function makeRequest(method: string, body?: unknown) {
|
||||||
|
return new NextRequest("http://localhost/api/v1/webhooks/wh-1", {
|
||||||
|
method,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const ctx = { params: Promise.resolve({ id: "wh-1" }) };
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
vi.mocked(requireSession).mockResolvedValue({ session: mockSession as never, response: null });
|
||||||
|
vi.mocked(validateWebhookUrl).mockResolvedValue(null);
|
||||||
|
mockSelectChain.limit.mockResolvedValue([{ id: "wh-1" }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("DELETE /api/v1/webhooks/[id]", () => {
|
||||||
|
it("returns 404 when the webhook does not belong to the caller", async () => {
|
||||||
|
mockSelectChain.limit.mockResolvedValue([]);
|
||||||
|
const res = await DELETE(makeRequest("DELETE"), ctx);
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 204 on successful deletion", async () => {
|
||||||
|
const res = await DELETE(makeRequest("DELETE"), ctx);
|
||||||
|
expect(res.status).toBe(204);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("PATCH /api/v1/webhooks/[id]", () => {
|
||||||
|
it("returns 404 when the webhook does not belong to the caller", async () => {
|
||||||
|
mockSelectChain.limit.mockResolvedValue([]);
|
||||||
|
const res = await PATCH(makeRequest("PATCH", { active: false }), ctx);
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 400 when the new URL fails SSRF validation", async () => {
|
||||||
|
vi.mocked(validateWebhookUrl).mockResolvedValue("Webhook URL must not point to a private or reserved address");
|
||||||
|
const res = await PATCH(makeRequest("PATCH", { url: "http://169.254.169.254/" }), ctx);
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 400 when there are no fields to update", async () => {
|
||||||
|
const res = await PATCH(makeRequest("PATCH", {}), ctx);
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 200 and applies the update", async () => {
|
||||||
|
const res = await PATCH(makeRequest("PATCH", { active: false }), ctx);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(mockUpdateChain.set).toHaveBeenCalledWith(expect.objectContaining({ active: false }));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { NextRequest } from "next/server";
|
||||||
|
|
||||||
|
const mockSession = { user: { id: "user-1" } };
|
||||||
|
|
||||||
|
vi.mock("@/lib/api-auth", () => ({
|
||||||
|
requireSession: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { mockFindFirst, mockSelectChain } = vi.hoisted(() => {
|
||||||
|
const mockSelectChain = {
|
||||||
|
from: vi.fn().mockReturnThis(),
|
||||||
|
where: vi.fn().mockReturnThis(),
|
||||||
|
orderBy: vi.fn().mockReturnThis(),
|
||||||
|
limit: vi.fn().mockResolvedValue([]),
|
||||||
|
};
|
||||||
|
return { mockFindFirst: vi.fn(), mockSelectChain };
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("@epicure/db", () => ({
|
||||||
|
db: {
|
||||||
|
select: vi.fn(() => mockSelectChain),
|
||||||
|
query: { webhooks: { findFirst: mockFindFirst } },
|
||||||
|
},
|
||||||
|
webhooks: { id: "id", userId: "user_id" },
|
||||||
|
webhookDeliveries: { webhookId: "webhook_id", createdAt: "created_at" },
|
||||||
|
eq: vi.fn((a, b) => ({ a, b, op: "eq" })),
|
||||||
|
and: vi.fn((...args) => ({ args, op: "and" })),
|
||||||
|
desc: vi.fn((a) => ({ a, op: "desc" })),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { requireSession } = await import("@/lib/api-auth");
|
||||||
|
import { GET } from "../route";
|
||||||
|
|
||||||
|
const ctx = { params: Promise.resolve({ id: "wh-1" }) };
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
vi.mocked(requireSession).mockResolvedValue({ session: mockSession as never, response: null });
|
||||||
|
mockFindFirst.mockResolvedValue({ id: "wh-1" });
|
||||||
|
mockSelectChain.limit.mockResolvedValue([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("GET /api/v1/webhooks/[id]/deliveries", () => {
|
||||||
|
it("returns 401 when not authenticated", async () => {
|
||||||
|
vi.mocked(requireSession).mockResolvedValue({
|
||||||
|
session: null,
|
||||||
|
response: new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401 }),
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
const res = await GET(new NextRequest("http://localhost/api/v1/webhooks/wh-1/deliveries"), ctx);
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 404 when the webhook does not belong to the caller", async () => {
|
||||||
|
mockFindFirst.mockResolvedValue(undefined);
|
||||||
|
const res = await GET(new NextRequest("http://localhost/api/v1/webhooks/wh-1/deliveries"), ctx);
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 200 with the delivery history", async () => {
|
||||||
|
mockSelectChain.limit.mockResolvedValue([{ id: "del-1", event: "recipe.created" }]);
|
||||||
|
const res = await GET(new NextRequest("http://localhost/api/v1/webhooks/wh-1/deliveries"), ctx);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = await res.json() as unknown[];
|
||||||
|
expect(body).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { NextRequest } from "next/server";
|
||||||
|
|
||||||
|
const mockSession = { user: { id: "user-1" } };
|
||||||
|
|
||||||
|
vi.mock("@/lib/api-auth", () => ({
|
||||||
|
requireSession: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/webhooks", () => ({
|
||||||
|
dispatchWebhook: vi.fn().mockResolvedValue(undefined),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { mockWebhookFindFirst, mockDeliveryFindFirst } = vi.hoisted(() => ({
|
||||||
|
mockWebhookFindFirst: vi.fn(),
|
||||||
|
mockDeliveryFindFirst: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@epicure/db", () => ({
|
||||||
|
db: {
|
||||||
|
query: {
|
||||||
|
webhooks: { findFirst: mockWebhookFindFirst },
|
||||||
|
webhookDeliveries: { findFirst: mockDeliveryFindFirst },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
webhooks: { id: "id", userId: "user_id" },
|
||||||
|
webhookDeliveries: { id: "id", webhookId: "webhook_id" },
|
||||||
|
eq: vi.fn((a, b) => ({ a, b, op: "eq" })),
|
||||||
|
and: vi.fn((...args) => ({ args, op: "and" })),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { requireSession } = await import("@/lib/api-auth");
|
||||||
|
const { dispatchWebhook } = await import("@/lib/webhooks");
|
||||||
|
import { POST } from "../route";
|
||||||
|
|
||||||
|
const VALID_DELIVERY_ID = "11111111-1111-1111-1111-111111111111";
|
||||||
|
|
||||||
|
function makeRequest(body?: unknown) {
|
||||||
|
return new NextRequest("http://localhost/api/v1/webhooks/wh-1/redeliver", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const ctx = { params: Promise.resolve({ id: "wh-1" }) };
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
vi.mocked(requireSession).mockResolvedValue({ session: mockSession as never, response: null });
|
||||||
|
mockWebhookFindFirst.mockResolvedValue({ id: "wh-1" });
|
||||||
|
mockDeliveryFindFirst.mockResolvedValue({
|
||||||
|
id: VALID_DELIVERY_ID,
|
||||||
|
event: "recipe.created",
|
||||||
|
payload: { recipeId: "r-1" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("POST /api/v1/webhooks/[id]/redeliver", () => {
|
||||||
|
it("returns 401 when not authenticated", async () => {
|
||||||
|
vi.mocked(requireSession).mockResolvedValue({
|
||||||
|
session: null,
|
||||||
|
response: new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401 }),
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
const res = await POST(makeRequest({ deliveryId: VALID_DELIVERY_ID }), ctx);
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 404 when the webhook does not belong to the caller", async () => {
|
||||||
|
mockWebhookFindFirst.mockResolvedValue(undefined);
|
||||||
|
const res = await POST(makeRequest({ deliveryId: VALID_DELIVERY_ID }), ctx);
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 400 when deliveryId is not a valid UUID", async () => {
|
||||||
|
const res = await POST(makeRequest({ deliveryId: "not-a-uuid" }), ctx);
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 404 when the delivery is not found", async () => {
|
||||||
|
mockDeliveryFindFirst.mockResolvedValue(undefined);
|
||||||
|
const res = await POST(makeRequest({ deliveryId: VALID_DELIVERY_ID }), ctx);
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("replays the delivery payload via dispatchWebhook", async () => {
|
||||||
|
const res = await POST(makeRequest({ deliveryId: VALID_DELIVERY_ID }), ctx);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(dispatchWebhook).toHaveBeenCalledWith("user-1", "recipe.created", { recipeId: "r-1" });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { NextRequest } from "next/server";
|
||||||
|
|
||||||
|
const mockSession = { user: { id: "user-1" } };
|
||||||
|
|
||||||
|
vi.mock("@/lib/api-auth", () => ({
|
||||||
|
requireSession: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/validate-webhook-url", () => ({
|
||||||
|
validateWebhookUrl: vi.fn().mockResolvedValue(null),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { mockSelectChain, mockInsertValues } = vi.hoisted(() => {
|
||||||
|
const mockSelectChain = {
|
||||||
|
from: vi.fn().mockReturnThis(),
|
||||||
|
where: vi.fn().mockResolvedValue([]),
|
||||||
|
};
|
||||||
|
return { mockSelectChain, mockInsertValues: vi.fn().mockResolvedValue(undefined) };
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("@epicure/db", () => ({
|
||||||
|
db: {
|
||||||
|
select: vi.fn(() => mockSelectChain),
|
||||||
|
insert: vi.fn(() => ({ values: mockInsertValues })),
|
||||||
|
},
|
||||||
|
webhooks: { id: "id", userId: "user_id", url: "url", events: "events", active: "active", createdAt: "created_at" },
|
||||||
|
eq: vi.fn((a, b) => ({ a, b, op: "eq" })),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { requireSession } = await import("@/lib/api-auth");
|
||||||
|
const { validateWebhookUrl } = await import("@/lib/validate-webhook-url");
|
||||||
|
import { GET, POST } from "../route";
|
||||||
|
|
||||||
|
function makeRequest(method: string, body?: unknown) {
|
||||||
|
return new NextRequest("http://localhost/api/v1/webhooks", {
|
||||||
|
method,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
vi.mocked(requireSession).mockResolvedValue({ session: mockSession as never, response: null });
|
||||||
|
vi.mocked(validateWebhookUrl).mockResolvedValue(null);
|
||||||
|
mockSelectChain.where.mockResolvedValue([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("GET /api/v1/webhooks", () => {
|
||||||
|
it("returns 401 when not authenticated", async () => {
|
||||||
|
vi.mocked(requireSession).mockResolvedValue({
|
||||||
|
session: null,
|
||||||
|
response: new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401 }),
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
const res = await GET();
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 200 with the user's webhooks", async () => {
|
||||||
|
mockSelectChain.where.mockResolvedValue([{ id: "wh-1", userId: "user-1" }]);
|
||||||
|
const res = await GET();
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = await res.json() as unknown[];
|
||||||
|
expect(body).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("POST /api/v1/webhooks", () => {
|
||||||
|
const validBody = { url: "https://example.com/hook", events: ["recipe.created"] };
|
||||||
|
|
||||||
|
it("returns 400 on validation error", async () => {
|
||||||
|
const res = await POST(makeRequest("POST", { url: "" }));
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 400 when the URL fails SSRF validation", async () => {
|
||||||
|
vi.mocked(validateWebhookUrl).mockResolvedValue("Webhook URL must not point to a private or reserved address");
|
||||||
|
const res = await POST(makeRequest("POST", validBody));
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 401 when not authenticated", async () => {
|
||||||
|
vi.mocked(requireSession).mockResolvedValue({
|
||||||
|
session: null,
|
||||||
|
response: new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401 }),
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
const res = await POST(makeRequest("POST", validBody));
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 201 and creates the webhook", async () => {
|
||||||
|
const res = await POST(makeRequest("POST", validBody));
|
||||||
|
expect(res.status).toBe(201);
|
||||||
|
const body = await res.json() as { url: string; secret: string };
|
||||||
|
expect(body.url).toBe(validBody.url);
|
||||||
|
expect(body.secret).toBeTruthy();
|
||||||
|
expect(mockInsertValues).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import crypto from "node:crypto";
|
import crypto from "node:crypto";
|
||||||
|
import { db, users, eq } from "@epicure/db";
|
||||||
|
|
||||||
// Stripe webhook handler — verifies stripe-signature header using HMAC-SHA256.
|
// Stripe webhook handler — verifies stripe-signature header using HMAC-SHA256.
|
||||||
// Handles:
|
// Handles:
|
||||||
@@ -74,16 +75,23 @@ export async function POST(req: NextRequest) {
|
|||||||
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
|
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: wire up DB calls when Stripe billing is fully configured
|
|
||||||
switch (event.type) {
|
switch (event.type) {
|
||||||
case "checkout.session.completed":
|
case "checkout.session.completed": {
|
||||||
// upgrade user to pro
|
// client_reference_id is set to our internal userId when the Checkout Session is created.
|
||||||
console.log("[stripe-webhook] checkout.session.completed", event.data.object["id"]);
|
const userId = event.data.object["client_reference_id"];
|
||||||
|
const customerId = event.data.object["customer"];
|
||||||
|
if (typeof userId === "string" && typeof customerId === "string") {
|
||||||
|
await db.update(users).set({ tier: "pro", stripeCustomerId: customerId }).where(eq(users.id, userId));
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
case "customer.subscription.deleted":
|
}
|
||||||
// downgrade user to free
|
case "customer.subscription.deleted": {
|
||||||
console.log("[stripe-webhook] customer.subscription.deleted", event.data.object["id"]);
|
const customerId = event.data.object["customer"];
|
||||||
|
if (typeof customerId === "string") {
|
||||||
|
await db.update(users).set({ tier: "free" }).where(eq(users.stripeCustomerId, customerId));
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
// ignore unhandled event types
|
// ignore unhandled event types
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata, Viewport } from "next";
|
||||||
import { Lora, Geist_Mono } from "next/font/google";
|
import { Lora, Geist_Mono } from "next/font/google";
|
||||||
import { headers } from "next/headers";
|
import { headers } from "next/headers";
|
||||||
import { Providers } from "@/components/providers";
|
import { Providers } from "@/components/providers";
|
||||||
@@ -21,6 +21,11 @@ const geistMono = Geist_Mono({
|
|||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: { default: "Epicure", template: "%s | Epicure" },
|
title: { default: "Epicure", template: "%s | Epicure" },
|
||||||
description: "Your personal AI-powered recipe book.",
|
description: "Your personal AI-powered recipe book.",
|
||||||
|
manifest: "/manifest.json",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const viewport: Viewport = {
|
||||||
|
themeColor: "#18181b",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default async function RootLayout({
|
export default async function RootLayout({
|
||||||
|
|||||||
@@ -0,0 +1,148 @@
|
|||||||
|
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 { PrintTrigger } from "@/components/recipe/print-trigger";
|
||||||
|
import { hasQuantity } from "@/lib/fractions";
|
||||||
|
import { getMessages, formatMessage } from "@/lib/i18n/server";
|
||||||
|
|
||||||
|
type Params = { params: Promise<{ id: string }> };
|
||||||
|
|
||||||
|
export default async function CollectionPrintPage({ params }: Params) {
|
||||||
|
const { id } = await params;
|
||||||
|
const session = await auth.api.getSession({ headers: await headers() });
|
||||||
|
if (!session) return null;
|
||||||
|
|
||||||
|
const m = getMessages((session.user as { locale?: string }).locale);
|
||||||
|
|
||||||
|
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: {
|
||||||
|
ingredients: { orderBy: (t, { asc }) => asc(t.order) },
|
||||||
|
steps: { orderBy: (t, { asc }) => asc(t.order) },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!col) notFound();
|
||||||
|
|
||||||
|
const recipeEntries = col.recipes.filter((r) => r.recipe !== null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<style>{`
|
||||||
|
@media print {
|
||||||
|
body { margin: 0; }
|
||||||
|
.no-print { display: none !important; }
|
||||||
|
article { page-break-after: always; }
|
||||||
|
article:last-child { page-break-after: auto; }
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
font-family: Georgia, 'Times New Roman', serif;
|
||||||
|
color: #1a1a1a;
|
||||||
|
max-width: 680px;
|
||||||
|
margin: 40px auto;
|
||||||
|
padding: 0 24px;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
h1 { font-size: 2em; margin: 0 0 8px; line-height: 1.2; }
|
||||||
|
h2 { font-size: 1.1em; text-transform: uppercase; letter-spacing: 0.08em; border-bottom: 1px solid #ccc; padding-bottom: 4px; margin: 24px 0 12px; }
|
||||||
|
.meta { display: flex; gap: 24px; font-size: 0.85em; color: #555; margin: 12px 0 16px; flex-wrap: wrap; }
|
||||||
|
.meta span { display: flex; align-items: center; gap: 4px; }
|
||||||
|
.description { color: #444; font-style: italic; margin-bottom: 16px; }
|
||||||
|
ul.ingredients { list-style: none; padding: 0; margin: 0; display: grid; grid-template-columns: 1fr 1fr; gap: 4px 24px; }
|
||||||
|
ul.ingredients li { padding: 4px 0; border-bottom: 1px dotted #e0e0e0; font-family: system-ui, sans-serif; font-size: 0.9em; }
|
||||||
|
ul.ingredients li .qty { color: #666; min-width: 60px; display: inline-block; }
|
||||||
|
ol.steps { padding-left: 20px; margin: 0; }
|
||||||
|
ol.steps li { padding: 6px 0 6px 4px; border-bottom: 1px dotted #e0e0e0; font-size: 0.95em; }
|
||||||
|
ol.steps li:last-child { border-bottom: none; }
|
||||||
|
.timer { font-size: 0.85em; color: #666; font-family: system-ui, sans-serif; margin-left: 8px; }
|
||||||
|
.cookbook-cover { text-align: center; margin-bottom: 40px; }
|
||||||
|
.cookbook-cover h1 { font-size: 2.6em; }
|
||||||
|
footer { margin-top: 40px; font-size: 0.75em; color: #aaa; text-align: center; font-family: system-ui, sans-serif; }
|
||||||
|
.print-btn {
|
||||||
|
position: fixed; top: 16px; right: 16px; padding: 8px 16px;
|
||||||
|
background: #18181b; color: white; border: none; border-radius: 6px;
|
||||||
|
cursor: pointer; font-size: 13px; font-family: system-ui, sans-serif;
|
||||||
|
}
|
||||||
|
.print-btn:hover { background: #3f3f46; }
|
||||||
|
`}</style>
|
||||||
|
|
||||||
|
<PrintTrigger />
|
||||||
|
|
||||||
|
<div className="cookbook-cover">
|
||||||
|
<h1>{col.name}</h1>
|
||||||
|
{col.description && <p className="description">{col.description}</p>}
|
||||||
|
<p style={{ fontFamily: "system-ui, sans-serif", fontSize: "0.85em", color: "#888" }}>
|
||||||
|
{recipeEntries.length} recipe{recipeEntries.length !== 1 ? "s" : ""}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{recipeEntries.map(({ recipe }) => {
|
||||||
|
if (!recipe) return null;
|
||||||
|
const totalMins = (recipe.prepMins ?? 0) + (recipe.cookMins ?? 0);
|
||||||
|
return (
|
||||||
|
<article key={recipe.id}>
|
||||||
|
<h1>{recipe.title}</h1>
|
||||||
|
|
||||||
|
{recipe.description && <p className="description">{recipe.description}</p>}
|
||||||
|
|
||||||
|
<div className="meta">
|
||||||
|
{recipe.baseServings && <span>{formatMessage(m.recipe.servings, { count: recipe.baseServings })}</span>}
|
||||||
|
{recipe.prepMins && <span>{formatMessage(m.recipe.prep, { mins: recipe.prepMins })}</span>}
|
||||||
|
{recipe.cookMins && <span>{formatMessage(m.recipe.cook, { mins: recipe.cookMins })}</span>}
|
||||||
|
{totalMins > 0 && <span>{formatMessage(m.recipe.total, { mins: totalMins })}</span>}
|
||||||
|
{recipe.difficulty && <span>{recipe.difficulty.charAt(0).toUpperCase() + recipe.difficulty.slice(1)}</span>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{recipe.ingredients.length > 0 && (
|
||||||
|
<>
|
||||||
|
<h2>{m.recipe.ingredients}</h2>
|
||||||
|
<ul className="ingredients">
|
||||||
|
{recipe.ingredients.map((ing) => (
|
||||||
|
<li key={ing.id}>
|
||||||
|
<span className="qty">
|
||||||
|
{[hasQuantity(ing.quantity) ? ing.quantity : null, ing.unit].filter(Boolean).join(" ")}
|
||||||
|
</span>
|
||||||
|
{ing.rawName}
|
||||||
|
{ing.note && <span style={{ color: "#888" }}> ({ing.note})</span>}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{recipe.steps.length > 0 && (
|
||||||
|
<>
|
||||||
|
<h2>{m.recipe.instructions}</h2>
|
||||||
|
<ol className="steps">
|
||||||
|
{recipe.steps.map((step) => (
|
||||||
|
<li key={step.id}>
|
||||||
|
{step.instruction}
|
||||||
|
{step.timerSeconds && (
|
||||||
|
<span className="timer">⏱ {Math.floor(step.timerSeconds / 60)} min</span>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
<footer>{m.print.footer}</footer>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -22,7 +22,7 @@ export function CollectionsPageContent({ collections }: Props) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold tracking-tight">{t("title")}</h1>
|
<h1 className="text-3xl font-bold tracking-tight">{t("title")}</h1>
|
||||||
<p className="text-muted-foreground mt-1">{t("subtitle")}</p>
|
<p className="text-muted-foreground mt-1">{t("subtitle")}</p>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Clock, Users, ChefHat, Flame, Heart } from "lucide-react";
|
import { Clock, Users, ChefHat, Flame, Heart, Sparkles } from "lucide-react";
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { useLocale } from "@/lib/i18n/provider";
|
import { useLocale } from "@/lib/i18n/provider";
|
||||||
@@ -99,10 +99,36 @@ function TrendingTab() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ForYouTab() {
|
||||||
|
const { locale } = useLocale();
|
||||||
|
const t = useTranslations("feed");
|
||||||
|
const [recipes, setRecipes] = useState<FeedRecipe[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch("/api/v1/feed/for-you")
|
||||||
|
.then((r) => r.json() as Promise<{ data: FeedRecipe[] }>)
|
||||||
|
.then(({ data }) => setRecipes(data))
|
||||||
|
.catch(() => {})
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (loading) return <p className="text-sm text-muted-foreground">{t("loading")}</p>;
|
||||||
|
if (recipes.length === 0) return <p className="text-sm text-muted-foreground">{t("forYouEmpty")}</p>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{recipes.map((recipe) => (
|
||||||
|
<RecipeCard key={recipe.id} recipe={recipe} locale={locale} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function FeedPageContent({ followedCount, feedRecipes }: Props) {
|
export function FeedPageContent({ followedCount, feedRecipes }: Props) {
|
||||||
const t = useTranslations("feed");
|
const t = useTranslations("feed");
|
||||||
const { locale } = useLocale();
|
const { locale } = useLocale();
|
||||||
const [tab, setTab] = useState<"following" | "trending">("following");
|
const [tab, setTab] = useState<"following" | "trending" | "forYou">("following");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 max-w-2xl">
|
<div className="space-y-6 max-w-2xl">
|
||||||
@@ -131,6 +157,17 @@ export function FeedPageContent({ followedCount, feedRecipes }: Props) {
|
|||||||
<Flame className="h-3.5 w-3.5" />
|
<Flame className="h-3.5 w-3.5" />
|
||||||
{t("trending")}
|
{t("trending")}
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setTab("forYou")}
|
||||||
|
className={`pb-2 px-1 text-sm font-medium border-b-2 transition-colors flex items-center gap-1.5 ${
|
||||||
|
tab === "forYou"
|
||||||
|
? "border-primary text-foreground"
|
||||||
|
: "border-transparent text-muted-foreground hover:text-foreground"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Sparkles className="h-3.5 w-3.5" />
|
||||||
|
{t("forYou")}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{tab === "following" ? (
|
{tab === "following" ? (
|
||||||
@@ -147,8 +184,10 @@ export function FeedPageContent({ followedCount, feedRecipes }: Props) {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
) : (
|
) : tab === "trending" ? (
|
||||||
<TrendingTab />
|
<TrendingTab />
|
||||||
|
) : (
|
||||||
|
<ForYouTab />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,191 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { UserPlus, X } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
|
||||||
|
type Role = "viewer" | "editor";
|
||||||
|
|
||||||
|
interface Member {
|
||||||
|
id: string;
|
||||||
|
userId: string;
|
||||||
|
role: Role;
|
||||||
|
createdAt: string;
|
||||||
|
user: {
|
||||||
|
name: string;
|
||||||
|
username: string | null;
|
||||||
|
avatarUrl: string | null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
weekStart: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ShareMealPlanButton({ weekStart }: Props) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [role, setRole] = useState<Role>("viewer");
|
||||||
|
const [members, setMembers] = useState<Member[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [inviting, setInviting] = useState(false);
|
||||||
|
|
||||||
|
async function fetchMembers() {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/v1/meal-plans/${weekStart}/members`);
|
||||||
|
if (!res.ok) throw new Error("Failed to load members");
|
||||||
|
const data = await res.json() as Member[];
|
||||||
|
setMembers(data);
|
||||||
|
} catch {
|
||||||
|
toast.error("Could not load members");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleOpenChange(next: boolean) {
|
||||||
|
setOpen(next);
|
||||||
|
if (next) {
|
||||||
|
void fetchMembers();
|
||||||
|
} else {
|
||||||
|
setEmail("");
|
||||||
|
setRole("viewer");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleInvite() {
|
||||||
|
if (!email.trim()) {
|
||||||
|
toast.error("Enter an email address");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setInviting(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/v1/meal-plans/${weekStart}/members`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ email: email.trim(), role }),
|
||||||
|
});
|
||||||
|
if (res.status === 409) { toast.error("Already a member"); return; }
|
||||||
|
if (res.status === 404) { toast.error("User not found"); return; }
|
||||||
|
if (!res.ok) { toast.error("Could not invite user"); return; }
|
||||||
|
toast.success("Invitation sent");
|
||||||
|
setEmail("");
|
||||||
|
await fetchMembers();
|
||||||
|
} catch {
|
||||||
|
toast.error("Could not invite user");
|
||||||
|
} finally {
|
||||||
|
setInviting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRemove(memberId: string) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`/api/v1/meal-plans/${weekStart}/members?memberId=${memberId}`,
|
||||||
|
{ method: "DELETE" },
|
||||||
|
);
|
||||||
|
if (!res.ok) { toast.error("Could not remove member"); return; }
|
||||||
|
setMembers((prev) => prev.filter((m) => m.id !== memberId));
|
||||||
|
toast.success("Member removed");
|
||||||
|
} catch {
|
||||||
|
toast.error("Could not remove member");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button variant="outline" size="sm" onClick={() => handleOpenChange(true)}>
|
||||||
|
<UserPlus className="h-4 w-4" />
|
||||||
|
Share
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Share this week's plan</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Invite household members to view or edit this week's meal plan.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="flex gap-2 mt-2">
|
||||||
|
<Input
|
||||||
|
type="email"
|
||||||
|
placeholder="Email address"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
onKeyDown={(e) => { if (e.key === "Enter") void handleInvite(); }}
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
<Select value={role} onValueChange={(v) => setRole(v as Role)}>
|
||||||
|
<SelectTrigger className="w-28">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="viewer">Viewer</SelectItem>
|
||||||
|
<SelectItem value="editor">Editor</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Button onClick={() => void handleInvite()} disabled={inviting}>
|
||||||
|
Invite
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 space-y-2">
|
||||||
|
{loading && (
|
||||||
|
<p className="text-sm text-muted-foreground">Loading members…</p>
|
||||||
|
)}
|
||||||
|
{!loading && members.length === 0 && (
|
||||||
|
<p className="text-sm text-muted-foreground">No members yet.</p>
|
||||||
|
)}
|
||||||
|
{members.map((m) => (
|
||||||
|
<div
|
||||||
|
key={m.id}
|
||||||
|
className="flex items-center justify-between gap-2 rounded-md border px-3 py-2 text-sm"
|
||||||
|
>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<span className="font-medium truncate">{m.user.name}</span>
|
||||||
|
{m.user.username && (
|
||||||
|
<span className="text-muted-foreground ml-1">
|
||||||
|
@{m.user.username}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Badge variant={m.role === "editor" ? "default" : "secondary"}>
|
||||||
|
{m.role}
|
||||||
|
</Badge>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7 shrink-0"
|
||||||
|
onClick={() => void handleRemove(m.id)}
|
||||||
|
aria-label="Remove member"
|
||||||
|
>
|
||||||
|
<X className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { Trash2 } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
|
||||||
|
type Day = "mon" | "tue" | "wed" | "thu" | "fri" | "sat" | "sun";
|
||||||
|
type MealType = "breakfast" | "lunch" | "dinner" | "snack";
|
||||||
|
|
||||||
|
const DAYS: Day[] = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"];
|
||||||
|
const MEAL_TYPES: MealType[] = ["breakfast", "lunch", "dinner", "snack"];
|
||||||
|
|
||||||
|
type Entry = {
|
||||||
|
id: string;
|
||||||
|
day: Day;
|
||||||
|
mealType: MealType;
|
||||||
|
servings: number;
|
||||||
|
recipe: { id: string; title: string } | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type UserRecipe = { id: string; title: string };
|
||||||
|
|
||||||
|
export function SharedMealPlanView({
|
||||||
|
mealPlanId,
|
||||||
|
initialEntries,
|
||||||
|
userRecipes,
|
||||||
|
canEdit,
|
||||||
|
}: {
|
||||||
|
mealPlanId: string;
|
||||||
|
initialEntries: Entry[];
|
||||||
|
userRecipes: UserRecipe[];
|
||||||
|
canEdit: boolean;
|
||||||
|
}) {
|
||||||
|
const [entries, setEntries] = useState<Entry[]>(initialEntries);
|
||||||
|
const [addingCell, setAddingCell] = useState<string | null>(null);
|
||||||
|
|
||||||
|
function cellKey(day: Day, mealType: MealType) {
|
||||||
|
return `${day}-${mealType}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addEntry(day: Day, mealType: MealType, recipeId: string) {
|
||||||
|
const res = await fetch(`/api/v1/meal-plans/shared/${mealPlanId}/entries`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ day, mealType, recipeId, servings: 2 }),
|
||||||
|
});
|
||||||
|
if (!res.ok) { toast.error("Could not add recipe"); return; }
|
||||||
|
const { id } = await res.json() as { id: string };
|
||||||
|
const recipe = userRecipes.find((r) => r.id === recipeId) ?? null;
|
||||||
|
setEntries((prev) => [
|
||||||
|
...prev.filter((e) => !(e.day === day && e.mealType === mealType)),
|
||||||
|
{ id, day, mealType, servings: 2, recipe },
|
||||||
|
]);
|
||||||
|
setAddingCell(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeEntry(entry: Entry) {
|
||||||
|
const res = await fetch(`/api/v1/meal-plans/shared/${mealPlanId}/entries?entryId=${entry.id}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
});
|
||||||
|
if (!res.ok) { toast.error("Could not remove entry"); return; }
|
||||||
|
setEntries((prev) => prev.filter((e) => e.id !== entry.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full border-collapse text-sm min-w-[640px]">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th className="text-left p-2 text-muted-foreground font-medium"></th>
|
||||||
|
{DAYS.map((day) => (
|
||||||
|
<th key={day} className="text-left p-2 text-muted-foreground font-medium capitalize">{day}</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{MEAL_TYPES.map((mealType) => (
|
||||||
|
<tr key={mealType} className="border-t">
|
||||||
|
<td className="p-2 text-muted-foreground font-medium capitalize align-top">{mealType}</td>
|
||||||
|
{DAYS.map((day) => {
|
||||||
|
const entry = entries.find((e) => e.day === day && e.mealType === mealType);
|
||||||
|
const key = cellKey(day, mealType);
|
||||||
|
return (
|
||||||
|
<td key={key} className="p-2 align-top min-w-[120px]">
|
||||||
|
{entry ? (
|
||||||
|
<div className="flex items-start justify-between gap-1 rounded-lg border p-2">
|
||||||
|
<span className="text-xs">{entry.recipe?.title ?? "—"}</span>
|
||||||
|
{canEdit && (
|
||||||
|
<Button variant="ghost" size="icon" className="h-5 w-5 shrink-0" onClick={() => void removeEntry(entry)}>
|
||||||
|
<Trash2 className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : canEdit ? (
|
||||||
|
addingCell === key ? (
|
||||||
|
<Select onValueChange={(v) => void addEntry(day, mealType, v as string)}>
|
||||||
|
<SelectTrigger className="h-8 text-xs">
|
||||||
|
<SelectValue placeholder="Pick recipe" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{userRecipes.map((r) => (
|
||||||
|
<SelectItem key={r.id} value={r.id}>{r.title}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
className="w-full h-8 rounded-lg border border-dashed text-xs text-muted-foreground hover:bg-muted/30"
|
||||||
|
onClick={() => setAddingCell(key)}
|
||||||
|
>
|
||||||
|
+ Add
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-muted-foreground">—</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -20,9 +20,11 @@ type Item = {
|
|||||||
export function ShoppingListView({
|
export function ShoppingListView({
|
||||||
listId,
|
listId,
|
||||||
initialItems,
|
initialItems,
|
||||||
|
readOnly = false,
|
||||||
}: {
|
}: {
|
||||||
listId: string;
|
listId: string;
|
||||||
initialItems: Item[];
|
initialItems: Item[];
|
||||||
|
readOnly?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const t = useTranslations("mealPlan");
|
const t = useTranslations("mealPlan");
|
||||||
const tShopping = useTranslations("shoppingLists");
|
const tShopping = useTranslations("shoppingLists");
|
||||||
@@ -54,6 +56,7 @@ export function ShoppingListView({
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function toggleItem(item: Item) {
|
async function toggleItem(item: Item) {
|
||||||
|
if (readOnly) return;
|
||||||
const next = !item.checked;
|
const next = !item.checked;
|
||||||
setItems((prev) => prev.map((i) => i.id === item.id ? { ...i, checked: next } : i));
|
setItems((prev) => prev.map((i) => i.id === item.id ? { ...i, checked: next } : i));
|
||||||
await fetch(`/api/v1/shopping-lists/${listId}/items/${item.id}`, {
|
await fetch(`/api/v1/shopping-lists/${listId}/items/${item.id}`, {
|
||||||
@@ -97,7 +100,8 @@ export function ShoppingListView({
|
|||||||
<button
|
<button
|
||||||
key={item.id}
|
key={item.id}
|
||||||
onClick={() => toggleItem(item)}
|
onClick={() => toggleItem(item)}
|
||||||
className="w-full flex items-center gap-3 px-4 py-3 hover:bg-muted/30 text-left transition-colors"
|
disabled={readOnly}
|
||||||
|
className="w-full flex items-center gap-3 px-4 py-3 hover:bg-muted/30 text-left transition-colors disabled:cursor-default disabled:hover:bg-transparent"
|
||||||
>
|
>
|
||||||
<div className={cn(
|
<div className={cn(
|
||||||
"h-5 w-5 rounded border-2 flex items-center justify-center shrink-0 transition-colors",
|
"h-5 w-5 rounded border-2 flex items-center justify-center shrink-0 transition-colors",
|
||||||
|
|||||||
@@ -8,12 +8,12 @@ import { cn } from "@/lib/utils";
|
|||||||
export function PantryPageHeader() {
|
export function PantryPageHeader() {
|
||||||
const t = useTranslations("pantry");
|
const t = useTranslations("pantry");
|
||||||
return (
|
return (
|
||||||
<div className="flex items-start justify-between">
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold tracking-tight">{t("title")}</h1>
|
<h1 className="text-3xl font-bold tracking-tight">{t("title")}</h1>
|
||||||
<p className="text-muted-foreground mt-1">{t("subtitle")}</p>
|
<p className="text-muted-foreground mt-1">{t("subtitle")}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<Link href="/recipes/can-cook" className={cn(buttonVariants({ variant: "outline", size: "sm" }))}>
|
<Link href="/recipes/can-cook" className={cn(buttonVariants({ variant: "outline", size: "sm" }))}>
|
||||||
<ChefHat className="h-4 w-4" />
|
<ChefHat className="h-4 w-4" />
|
||||||
{t("canCook")}
|
{t("canCook")}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { Package } from "lucide-react";
|
import { Package, Clock } from "lucide-react";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Progress } from "@/components/ui/progress";
|
import { Progress } from "@/components/ui/progress";
|
||||||
import { buttonVariants } from "@/components/ui/button";
|
import { buttonVariants } from "@/components/ui/button";
|
||||||
@@ -19,6 +19,7 @@ type ScoredItem = {
|
|||||||
total: number;
|
total: number;
|
||||||
pct: number;
|
pct: number;
|
||||||
missing: string[];
|
missing: string[];
|
||||||
|
usesExpiring: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@@ -40,7 +41,15 @@ function RecipeRow({ s }: { s: ScoredItem }) {
|
|||||||
<div className="h-14 w-14 rounded-lg bg-muted shrink-0" />
|
<div className="h-14 w-14 rounded-lg bg-muted shrink-0" />
|
||||||
)}
|
)}
|
||||||
<div className="flex-1 min-w-0 space-y-1">
|
<div className="flex-1 min-w-0 space-y-1">
|
||||||
<p className="font-medium truncate">{s.recipe.title}</p>
|
<div className="flex items-center gap-2">
|
||||||
|
<p className="font-medium truncate">{s.recipe.title}</p>
|
||||||
|
{s.usesExpiring.length > 0 && (
|
||||||
|
<Badge variant="outline" className="shrink-0 text-orange-500 border-orange-500 gap-1">
|
||||||
|
<Clock className="h-3 w-3" />
|
||||||
|
{t("useItUp")}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Progress value={s.pct} className="h-1.5 flex-1 max-w-[120px]" />
|
<Progress value={s.pct} className="h-1.5 flex-1 max-w-[120px]" />
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
|
|||||||
@@ -111,7 +111,7 @@ export function RecipesHeader({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold tracking-tight">{t("title")}</h1>
|
<h1 className="text-3xl font-bold tracking-tight">{t("title")}</h1>
|
||||||
<p className="text-muted-foreground mt-1">
|
<p className="text-muted-foreground mt-1">
|
||||||
@@ -120,7 +120,7 @@ export function RecipesHeader({
|
|||||||
: t(count !== 1 ? "resultPlural" : "resultSingular", { count })}
|
: t(count !== 1 ? "resultPlural" : "resultSingular", { count })}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<Button variant="ghost" size="sm" onClick={() => setUrlOpen(true)}>
|
<Button variant="ghost" size="sm" onClick={() => setUrlOpen(true)}>
|
||||||
<Link2 className="h-4 w-4" />
|
<Link2 className="h-4 w-4" />
|
||||||
{t("importUrl")}
|
{t("importUrl")}
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { diffWords } from "diff";
|
||||||
|
|
||||||
|
export type DiffSnapshot = {
|
||||||
|
title: string;
|
||||||
|
description?: string | null;
|
||||||
|
ingredients: Array<{ rawName: string; quantity?: string | number | null; unit?: string | null; note?: string | null }>;
|
||||||
|
steps: Array<{ instruction: string; timerSeconds?: number | null }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function ingredientLine(i: DiffSnapshot["ingredients"][number]): string {
|
||||||
|
return [i.quantity, i.unit, i.rawName, i.note && `(${i.note})`].filter(Boolean).join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function stepLine(s: DiffSnapshot["steps"][number]): string {
|
||||||
|
return s.instruction;
|
||||||
|
}
|
||||||
|
|
||||||
|
function TextDiff({ before, after }: { before: string; after: string }) {
|
||||||
|
const parts = diffWords(before, after);
|
||||||
|
return (
|
||||||
|
<p className="text-sm leading-relaxed">
|
||||||
|
{parts.map((part, i) => (
|
||||||
|
<span
|
||||||
|
key={i}
|
||||||
|
className={
|
||||||
|
part.added
|
||||||
|
? "bg-green-500/20 text-green-700 dark:text-green-400"
|
||||||
|
: part.removed
|
||||||
|
? "bg-red-500/20 text-red-700 dark:text-red-400 line-through"
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{part.value}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ListDiff({ before, after }: { before: string[]; after: string[] }) {
|
||||||
|
const max = Math.max(before.length, after.length);
|
||||||
|
const rows = [];
|
||||||
|
for (let i = 0; i < max; i++) {
|
||||||
|
const b = before[i];
|
||||||
|
const a = after[i];
|
||||||
|
if (b === undefined) {
|
||||||
|
rows.push(
|
||||||
|
<div key={i} className="bg-green-500/10 rounded px-2 py-1 text-sm text-green-700 dark:text-green-400">
|
||||||
|
+ {a}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
} else if (a === undefined) {
|
||||||
|
rows.push(
|
||||||
|
<div key={i} className="bg-red-500/10 rounded px-2 py-1 text-sm text-red-700 dark:text-red-400 line-through">
|
||||||
|
− {b}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
} else if (b === a) {
|
||||||
|
rows.push(
|
||||||
|
<div key={i} className="px-2 py-1 text-sm text-muted-foreground">
|
||||||
|
{b}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
rows.push(
|
||||||
|
<div key={i} className="rounded px-2 py-1">
|
||||||
|
<TextDiff before={b} after={a} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return <div className="space-y-1">{rows}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function VersionDiffView({ before, after }: { before: DiffSnapshot; after: DiffSnapshot }) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<section className="space-y-2">
|
||||||
|
<h3 className="text-sm font-semibold text-muted-foreground">Title</h3>
|
||||||
|
<TextDiff before={before.title} after={after.title} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{(before.description || after.description) && (
|
||||||
|
<section className="space-y-2">
|
||||||
|
<h3 className="text-sm font-semibold text-muted-foreground">Description</h3>
|
||||||
|
<TextDiff before={before.description ?? ""} after={after.description ?? ""} />
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<section className="space-y-2">
|
||||||
|
<h3 className="text-sm font-semibold text-muted-foreground">Ingredients</h3>
|
||||||
|
<ListDiff before={before.ingredients.map(ingredientLine)} after={after.ingredients.map(ingredientLine)} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="space-y-2">
|
||||||
|
<h3 className="text-sm font-semibold text-muted-foreground">Steps</h3>
|
||||||
|
<ListDiff before={before.steps.map(stepLine)} after={after.steps.map(stepLine)} />
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { History, RotateCcw, ChevronDown } from "lucide-react";
|
import { History, RotateCcw, ChevronDown, GitCompare } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
@@ -12,7 +12,14 @@ import {
|
|||||||
SheetHeader,
|
SheetHeader,
|
||||||
SheetTitle,
|
SheetTitle,
|
||||||
} from "@/components/ui/sheet";
|
} from "@/components/ui/sheet";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
|
import { VersionDiffView, type DiffSnapshot } from "@/components/recipe/version-diff-view";
|
||||||
|
|
||||||
type VersionSummary = {
|
type VersionSummary = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -21,9 +28,13 @@ type VersionSummary = {
|
|||||||
createdAt: string;
|
createdAt: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type SnapshotData = {
|
type SnapshotData = DiffSnapshot & {
|
||||||
ingredients: unknown[];
|
description?: string | null;
|
||||||
steps: unknown[];
|
baseServings?: number;
|
||||||
|
difficulty?: string | null;
|
||||||
|
prepMins?: number | null;
|
||||||
|
cookMins?: number | null;
|
||||||
|
dietaryTags?: Record<string, boolean>;
|
||||||
};
|
};
|
||||||
|
|
||||||
type VersionDetail = {
|
type VersionDetail = {
|
||||||
@@ -39,7 +50,13 @@ function formatDate(dateStr: string): string {
|
|||||||
return date.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
|
return date.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function VersionHistoryButton({ recipeId }: { recipeId: string }) {
|
export function VersionHistoryButton({
|
||||||
|
recipeId,
|
||||||
|
currentSnapshot,
|
||||||
|
}: {
|
||||||
|
recipeId: string;
|
||||||
|
currentSnapshot: DiffSnapshot;
|
||||||
|
}) {
|
||||||
const t = useTranslations("recipe");
|
const t = useTranslations("recipe");
|
||||||
const tForm = useTranslations("recipeForm");
|
const tForm = useTranslations("recipeForm");
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -49,6 +66,7 @@ export function VersionHistoryButton({ recipeId }: { recipeId: string }) {
|
|||||||
const [expandedId, setExpandedId] = useState<string | null>(null);
|
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||||
const [expandedData, setExpandedData] = useState<Record<string, VersionDetail>>({});
|
const [expandedData, setExpandedData] = useState<Record<string, VersionDetail>>({});
|
||||||
const [restoringId, setRestoringId] = useState<string | null>(null);
|
const [restoringId, setRestoringId] = useState<string | null>(null);
|
||||||
|
const [comparingId, setComparingId] = useState<string | null>(null);
|
||||||
|
|
||||||
async function handleOpen(isOpen: boolean) {
|
async function handleOpen(isOpen: boolean) {
|
||||||
setOpen(isOpen);
|
setOpen(isOpen);
|
||||||
@@ -66,19 +84,27 @@ export function VersionHistoryButton({ recipeId }: { recipeId: string }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function ensureDetail(versionId: string): Promise<VersionDetail | null> {
|
||||||
|
if (expandedData[versionId]) return expandedData[versionId]!;
|
||||||
|
const res = await fetch(`/api/v1/recipes/${recipeId}/versions/${versionId}`);
|
||||||
|
if (!res.ok) return null;
|
||||||
|
const data = await res.json() as VersionDetail;
|
||||||
|
setExpandedData((prev) => ({ ...prev, [versionId]: data }));
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
async function handleExpand(version: VersionSummary) {
|
async function handleExpand(version: VersionSummary) {
|
||||||
if (expandedId === version.id) {
|
if (expandedId === version.id) {
|
||||||
setExpandedId(null);
|
setExpandedId(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setExpandedId(version.id);
|
setExpandedId(version.id);
|
||||||
if (!expandedData[version.id]) {
|
await ensureDetail(version.id);
|
||||||
const res = await fetch(`/api/v1/recipes/${recipeId}/versions/${version.id}`);
|
}
|
||||||
if (res.ok) {
|
|
||||||
const data = await res.json() as VersionDetail;
|
async function handleCompare(version: VersionSummary) {
|
||||||
setExpandedData((prev) => ({ ...prev, [version.id]: data }));
|
await ensureDetail(version.id);
|
||||||
}
|
setComparingId(version.id);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleRestore(version: VersionSummary) {
|
async function handleRestore(version: VersionSummary) {
|
||||||
@@ -151,6 +177,15 @@ export function VersionHistoryButton({ recipeId }: { recipeId: string }) {
|
|||||||
>
|
>
|
||||||
<ChevronDown className={`h-4 w-4 transition-transform ${isExpanded ? "rotate-180" : ""}`} />
|
<ChevronDown className={`h-4 w-4 transition-transform ${isExpanded ? "rotate-180" : ""}`} />
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="shrink-0"
|
||||||
|
onClick={() => void handleCompare(v)}
|
||||||
|
>
|
||||||
|
<GitCompare className="h-3 w-3" />
|
||||||
|
Compare
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -169,8 +204,8 @@ export function VersionHistoryButton({ recipeId }: { recipeId: string }) {
|
|||||||
<p>Loading...</p>
|
<p>Loading...</p>
|
||||||
) : (
|
) : (
|
||||||
<p>
|
<p>
|
||||||
{(detail.snapshotData.ingredients as unknown[]).length} ingredient{(detail.snapshotData.ingredients as unknown[]).length !== 1 ? "s" : ""},{" "}
|
{detail.snapshotData.ingredients.length} ingredient{detail.snapshotData.ingredients.length !== 1 ? "s" : ""},{" "}
|
||||||
{(detail.snapshotData.steps as unknown[]).length} step{(detail.snapshotData.steps as unknown[]).length !== 1 ? "s" : ""}
|
{detail.snapshotData.steps.length} step{detail.snapshotData.steps.length !== 1 ? "s" : ""}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -181,6 +216,19 @@ export function VersionHistoryButton({ recipeId }: { recipeId: string }) {
|
|||||||
</div>
|
</div>
|
||||||
</SheetContent>
|
</SheetContent>
|
||||||
</Sheet>
|
</Sheet>
|
||||||
|
|
||||||
|
<Dialog open={comparingId !== null} onOpenChange={(isOpen) => !isOpen && setComparingId(null)}>
|
||||||
|
<DialogContent className="max-w-2xl max-h-[85vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
{comparingId ? `Compare v${expandedData[comparingId]?.version} with current` : "Compare"}
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
{comparingId && expandedData[comparingId] && (
|
||||||
|
<VersionDiffView before={expandedData[comparingId]!.snapshotData} after={currentSnapshot} />
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { ShoppingBag, Copy, ExternalLink } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import type { GroceryExportPayload } from "@/lib/grocery-export";
|
||||||
|
import { groceryExportToText } from "@/lib/grocery-export";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
listId: string;
|
||||||
|
/** Set when NEXT_PUBLIC_GROCERY_PROVIDER=instacart — otherwise only "copy as text" is offered. */
|
||||||
|
instacartEnabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GroceryExportButton({ listId, instacartEnabled }: Props) {
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
async function fetchPayload(): Promise<GroceryExportPayload | null> {
|
||||||
|
const res = await fetch(`/api/v1/shopping-lists/${listId}/export`);
|
||||||
|
if (!res.ok) {
|
||||||
|
toast.error("Could not build export");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return res.json() as Promise<GroceryExportPayload>;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCopy() {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const payload = await fetchPayload();
|
||||||
|
if (!payload) return;
|
||||||
|
await navigator.clipboard.writeText(groceryExportToText(payload));
|
||||||
|
toast.success("List copied to clipboard");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleInstacart() {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/v1/shopping-lists/${listId}/export/instacart`, { method: "POST" });
|
||||||
|
if (!res.ok) {
|
||||||
|
toast.error("Instacart isn't configured yet");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { url } = await res.json() as { url: string };
|
||||||
|
window.open(url, "_blank");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger render={
|
||||||
|
<Button variant="outline" size="sm" disabled={loading}>
|
||||||
|
<ShoppingBag className="h-4 w-4" />
|
||||||
|
Send to grocery delivery
|
||||||
|
</Button>
|
||||||
|
} />
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem onClick={() => void handleCopy()}>
|
||||||
|
<Copy className="h-4 w-4" />
|
||||||
|
Copy list as text
|
||||||
|
</DropdownMenuItem>
|
||||||
|
{instacartEnabled && (
|
||||||
|
<DropdownMenuItem onClick={() => void handleInstacart()}>
|
||||||
|
<ExternalLink className="h-4 w-4" />
|
||||||
|
Send to Instacart
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { UserPlus, X } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
|
||||||
|
type Role = "viewer" | "editor";
|
||||||
|
|
||||||
|
interface Member {
|
||||||
|
id: string;
|
||||||
|
userId: string;
|
||||||
|
role: Role;
|
||||||
|
createdAt: string;
|
||||||
|
user: {
|
||||||
|
name: string;
|
||||||
|
username: string | null;
|
||||||
|
avatarUrl: string | null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
listId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ShareShoppingListButton({ listId }: Props) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [role, setRole] = useState<Role>("viewer");
|
||||||
|
const [members, setMembers] = useState<Member[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [inviting, setInviting] = useState(false);
|
||||||
|
|
||||||
|
async function fetchMembers() {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/v1/shopping-lists/${listId}/members`);
|
||||||
|
if (!res.ok) throw new Error("Failed to load members");
|
||||||
|
const data = await res.json() as Member[];
|
||||||
|
setMembers(data);
|
||||||
|
} catch {
|
||||||
|
toast.error("Could not load members");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleOpenChange(next: boolean) {
|
||||||
|
setOpen(next);
|
||||||
|
if (next) {
|
||||||
|
void fetchMembers();
|
||||||
|
} else {
|
||||||
|
setEmail("");
|
||||||
|
setRole("viewer");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleInvite() {
|
||||||
|
if (!email.trim()) {
|
||||||
|
toast.error("Enter an email address");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setInviting(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/v1/shopping-lists/${listId}/members`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ email: email.trim(), role }),
|
||||||
|
});
|
||||||
|
if (res.status === 409) { toast.error("Already a member"); return; }
|
||||||
|
if (res.status === 404) { toast.error("User not found"); return; }
|
||||||
|
if (!res.ok) { toast.error("Could not invite user"); return; }
|
||||||
|
toast.success("Invitation sent");
|
||||||
|
setEmail("");
|
||||||
|
await fetchMembers();
|
||||||
|
} catch {
|
||||||
|
toast.error("Could not invite user");
|
||||||
|
} finally {
|
||||||
|
setInviting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRemove(memberId: string) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`/api/v1/shopping-lists/${listId}/members?memberId=${memberId}`,
|
||||||
|
{ method: "DELETE" },
|
||||||
|
);
|
||||||
|
if (!res.ok) { toast.error("Could not remove member"); return; }
|
||||||
|
setMembers((prev) => prev.filter((m) => m.id !== memberId));
|
||||||
|
toast.success("Member removed");
|
||||||
|
} catch {
|
||||||
|
toast.error("Could not remove member");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button variant="outline" size="sm" onClick={() => handleOpenChange(true)}>
|
||||||
|
<UserPlus className="h-4 w-4" />
|
||||||
|
Share
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Share shopping list</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Invite household members to view or edit this list.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="flex gap-2 mt-2">
|
||||||
|
<Input
|
||||||
|
type="email"
|
||||||
|
placeholder="Email address"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
onKeyDown={(e) => { if (e.key === "Enter") void handleInvite(); }}
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
<Select value={role} onValueChange={(v) => setRole(v as Role)}>
|
||||||
|
<SelectTrigger className="w-28">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="viewer">Viewer</SelectItem>
|
||||||
|
<SelectItem value="editor">Editor</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Button onClick={() => void handleInvite()} disabled={inviting}>
|
||||||
|
Invite
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 space-y-2">
|
||||||
|
{loading && (
|
||||||
|
<p className="text-sm text-muted-foreground">Loading members…</p>
|
||||||
|
)}
|
||||||
|
{!loading && members.length === 0 && (
|
||||||
|
<p className="text-sm text-muted-foreground">No members yet.</p>
|
||||||
|
)}
|
||||||
|
{members.map((m) => (
|
||||||
|
<div
|
||||||
|
key={m.id}
|
||||||
|
className="flex items-center justify-between gap-2 rounded-md border px-3 py-2 text-sm"
|
||||||
|
>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<span className="font-medium truncate">{m.user.name}</span>
|
||||||
|
{m.user.username && (
|
||||||
|
<span className="text-muted-foreground ml-1">
|
||||||
|
@{m.user.username}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Badge variant={m.role === "editor" ? "default" : "secondary"}>
|
||||||
|
{m.role}
|
||||||
|
</Badge>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7 shrink-0"
|
||||||
|
onClick={() => void handleRemove(m.id)}
|
||||||
|
aria-label="Remove member"
|
||||||
|
>
|
||||||
|
<X className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -12,16 +12,24 @@ type ShoppingListItem = {
|
|||||||
checkedItems: number;
|
checkedItems: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
type Props = {
|
type SharedListItem = {
|
||||||
lists: ShoppingListItem[];
|
id: string;
|
||||||
|
name: string;
|
||||||
|
ownerName: string;
|
||||||
|
role: "viewer" | "editor";
|
||||||
};
|
};
|
||||||
|
|
||||||
export function ShoppingListsPageContent({ lists }: Props) {
|
type Props = {
|
||||||
|
lists: ShoppingListItem[];
|
||||||
|
sharedLists?: SharedListItem[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ShoppingListsPageContent({ lists, sharedLists = [] }: Props) {
|
||||||
const t = useTranslations("shoppingLists");
|
const t = useTranslations("shoppingLists");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold tracking-tight">{t("title")}</h1>
|
<h1 className="text-3xl font-bold tracking-tight">{t("title")}</h1>
|
||||||
<p className="text-muted-foreground mt-1">{t("subtitle")}</p>
|
<p className="text-muted-foreground mt-1">{t("subtitle")}</p>
|
||||||
@@ -58,6 +66,26 @@ export function ShoppingListsPageContent({ lists }: Props) {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{sharedLists.length > 0 && (
|
||||||
|
<div className="space-y-3 max-w-lg">
|
||||||
|
<h2 className="text-sm font-semibold text-muted-foreground">Shared with you</h2>
|
||||||
|
{sharedLists.map((list) => (
|
||||||
|
<Link
|
||||||
|
key={list.id}
|
||||||
|
href={`/shopping-lists/${list.id}`}
|
||||||
|
className="flex items-center justify-between rounded-xl border p-4 hover:shadow-sm transition-shadow"
|
||||||
|
>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h3 className="font-semibold">{list.name}</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{list.ownerName} · {list.role}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { buildPreferenceMap, scoreCandidate, rankForYou } from "../for-you-ranking";
|
||||||
|
|
||||||
|
describe("buildPreferenceMap", () => {
|
||||||
|
it("counts tags and true dietary-tag keys across liked recipes", () => {
|
||||||
|
const map = buildPreferenceMap([
|
||||||
|
{ tags: ["spicy", "quick"], dietaryTags: { vegan: true, glutenFree: false } },
|
||||||
|
{ tags: ["spicy"], dietaryTags: null },
|
||||||
|
]);
|
||||||
|
expect(map.get("spicy")).toBe(2);
|
||||||
|
expect(map.get("quick")).toBe(1);
|
||||||
|
expect(map.get("vegan")).toBe(1);
|
||||||
|
expect(map.get("glutenFree")).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns an empty map for no liked recipes", () => {
|
||||||
|
expect(buildPreferenceMap([]).size).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("scoreCandidate", () => {
|
||||||
|
it("sums preference weights for overlapping tags", () => {
|
||||||
|
const prefs = new Map([["spicy", 3], ["vegan", 1]]);
|
||||||
|
const score = scoreCandidate({ tags: ["spicy"], dietaryTags: { vegan: true } }, prefs);
|
||||||
|
expect(score).toBe(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("scores 0 when nothing overlaps", () => {
|
||||||
|
const prefs = new Map([["spicy", 3]]);
|
||||||
|
expect(scoreCandidate({ tags: ["sweet"], dietaryTags: null }, prefs)).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("rankForYou", () => {
|
||||||
|
const base = { dietaryTags: null };
|
||||||
|
|
||||||
|
it("sorts by score descending", () => {
|
||||||
|
const prefs = new Map([["spicy", 5]]);
|
||||||
|
const candidates = [
|
||||||
|
{ id: "a", tags: ["sweet"], createdAt: new Date("2024-01-01"), ...base },
|
||||||
|
{ id: "b", tags: ["spicy"], createdAt: new Date("2024-01-01"), ...base },
|
||||||
|
];
|
||||||
|
const ranked = rankForYou(candidates, prefs);
|
||||||
|
expect(ranked.map((r) => r.id)).toEqual(["b", "a"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("breaks ties by recency", () => {
|
||||||
|
const prefs = new Map<string, number>();
|
||||||
|
const candidates = [
|
||||||
|
{ id: "old", tags: [], createdAt: new Date("2024-01-01"), ...base },
|
||||||
|
{ id: "new", tags: [], createdAt: new Date("2024-06-01"), ...base },
|
||||||
|
];
|
||||||
|
const ranked = rankForYou(candidates, prefs);
|
||||||
|
expect(ranked.map((r) => r.id)).toEqual(["new", "old"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { buildGroceryExportPayload, groceryExportToText } from "../grocery-export";
|
||||||
|
|
||||||
|
describe("buildGroceryExportPayload", () => {
|
||||||
|
it("maps unchecked items and drops checked ones", () => {
|
||||||
|
const payload = buildGroceryExportPayload({
|
||||||
|
name: "Weekly groceries",
|
||||||
|
items: [
|
||||||
|
{ rawName: "Milk", quantity: "1", unit: "L", checked: false },
|
||||||
|
{ rawName: "Eggs", quantity: "12", unit: null, checked: true },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(payload.listName).toBe("Weekly groceries");
|
||||||
|
expect(payload.items).toEqual([{ name: "Milk", quantity: "1", unit: "L" }]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("groceryExportToText", () => {
|
||||||
|
it("renders a plain-text list with quantities", () => {
|
||||||
|
const text = groceryExportToText({
|
||||||
|
listName: "Weekly groceries",
|
||||||
|
items: [
|
||||||
|
{ name: "Milk", quantity: "1", unit: "L" },
|
||||||
|
{ name: "Bananas", quantity: null, unit: null },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(text).toBe("Weekly groceries\n\n1 L Milk\nBananas");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
|
||||||
|
const { mockPlanFindFirst, mockMemberFindFirst } = vi.hoisted(() => ({
|
||||||
|
mockPlanFindFirst: vi.fn(),
|
||||||
|
mockMemberFindFirst: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@epicure/db", () => ({
|
||||||
|
db: {
|
||||||
|
query: {
|
||||||
|
mealPlans: { findFirst: mockPlanFindFirst },
|
||||||
|
mealPlanMembers: { findFirst: mockMemberFindFirst },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
mealPlans: { id: "id", userId: "user_id" },
|
||||||
|
mealPlanMembers: { mealPlanId: "meal_plan_id", userId: "user_id" },
|
||||||
|
eq: vi.fn((a, b) => ({ a, b, op: "eq" })),
|
||||||
|
and: vi.fn((...args) => ({ args, op: "and" })),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { getMealPlanAccessById, canWriteMealPlan } = await import("../meal-plan-access");
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getMealPlanAccessById", () => {
|
||||||
|
it("returns null when the plan doesn't exist", async () => {
|
||||||
|
mockPlanFindFirst.mockResolvedValue(undefined);
|
||||||
|
expect(await getMealPlanAccessById("plan-1", "user-1")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("grants owner role to the plan's userId", async () => {
|
||||||
|
mockPlanFindFirst.mockResolvedValue({ id: "plan-1", userId: "user-1" });
|
||||||
|
const access = await getMealPlanAccessById("plan-1", "user-1");
|
||||||
|
expect(access?.role).toBe("owner");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("grants the member's assigned role", async () => {
|
||||||
|
mockPlanFindFirst.mockResolvedValue({ id: "plan-1", userId: "user-owner" });
|
||||||
|
mockMemberFindFirst.mockResolvedValue({ role: "viewer" });
|
||||||
|
const access = await getMealPlanAccessById("plan-1", "user-2");
|
||||||
|
expect(access?.role).toBe("viewer");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null when the user is neither owner nor a member", async () => {
|
||||||
|
mockPlanFindFirst.mockResolvedValue({ id: "plan-1", userId: "user-owner" });
|
||||||
|
mockMemberFindFirst.mockResolvedValue(undefined);
|
||||||
|
expect(await getMealPlanAccessById("plan-1", "user-2")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("canWriteMealPlan", () => {
|
||||||
|
it("allows owner and editor, denies viewer", () => {
|
||||||
|
expect(canWriteMealPlan("owner")).toBe(true);
|
||||||
|
expect(canWriteMealPlan("editor")).toBe(true);
|
||||||
|
expect(canWriteMealPlan("viewer")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
|
||||||
|
const { mockListFindFirst, mockMemberFindFirst } = vi.hoisted(() => ({
|
||||||
|
mockListFindFirst: vi.fn(),
|
||||||
|
mockMemberFindFirst: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@epicure/db", () => ({
|
||||||
|
db: {
|
||||||
|
query: {
|
||||||
|
shoppingLists: { findFirst: mockListFindFirst },
|
||||||
|
shoppingListMembers: { findFirst: mockMemberFindFirst },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
shoppingLists: { id: "id", userId: "user_id" },
|
||||||
|
shoppingListMembers: { listId: "list_id", userId: "user_id" },
|
||||||
|
eq: vi.fn((a, b) => ({ a, b, op: "eq" })),
|
||||||
|
and: vi.fn((...args) => ({ args, op: "and" })),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { getShoppingListAccess, canWriteShoppingList } = await import("../shopping-list-access");
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getShoppingListAccess", () => {
|
||||||
|
it("returns null when the list doesn't exist", async () => {
|
||||||
|
mockListFindFirst.mockResolvedValue(undefined);
|
||||||
|
expect(await getShoppingListAccess("list-1", "user-1")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("grants owner role to the list's userId", async () => {
|
||||||
|
mockListFindFirst.mockResolvedValue({ id: "list-1", userId: "user-1" });
|
||||||
|
const access = await getShoppingListAccess("list-1", "user-1");
|
||||||
|
expect(access?.role).toBe("owner");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("grants the member's assigned role", async () => {
|
||||||
|
mockListFindFirst.mockResolvedValue({ id: "list-1", userId: "user-owner" });
|
||||||
|
mockMemberFindFirst.mockResolvedValue({ role: "editor" });
|
||||||
|
const access = await getShoppingListAccess("list-1", "user-2");
|
||||||
|
expect(access?.role).toBe("editor");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null when the user is neither owner nor a member", async () => {
|
||||||
|
mockListFindFirst.mockResolvedValue({ id: "list-1", userId: "user-owner" });
|
||||||
|
mockMemberFindFirst.mockResolvedValue(undefined);
|
||||||
|
expect(await getShoppingListAccess("list-1", "user-2")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("canWriteShoppingList", () => {
|
||||||
|
it("allows owner and editor, denies viewer", () => {
|
||||||
|
expect(canWriteShoppingList("owner")).toBe(true);
|
||||||
|
expect(canWriteShoppingList("editor")).toBe(true);
|
||||||
|
expect(canWriteShoppingList("viewer")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,6 +4,7 @@ import { TierLimitError } from "../tiers";
|
|||||||
const mockDb = vi.hoisted(() => ({
|
const mockDb = vi.hoisted(() => ({
|
||||||
select: vi.fn(),
|
select: vi.fn(),
|
||||||
insert: vi.fn(),
|
insert: vi.fn(),
|
||||||
|
execute: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@epicure/db", () => ({
|
vi.mock("@epicure/db", () => ({
|
||||||
@@ -22,7 +23,7 @@ vi.mock("@epicure/db", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
// Import after mock
|
// Import after mock
|
||||||
const { checkTierLimit, incrementUsage } = await import("../tiers");
|
const { checkAndIncrementTierLimit, incrementUsage } = await import("../tiers");
|
||||||
|
|
||||||
function makeChain(finalValue: unknown) {
|
function makeChain(finalValue: unknown) {
|
||||||
const chain = {
|
const chain = {
|
||||||
@@ -54,7 +55,7 @@ describe("TierLimitError", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("checkTierLimit", () => {
|
describe("checkAndIncrementTierLimit", () => {
|
||||||
const tierDef = {
|
const tierDef = {
|
||||||
tier: "free",
|
tier: "free",
|
||||||
maxRecipes: 10,
|
maxRecipes: 10,
|
||||||
@@ -63,50 +64,32 @@ describe("checkTierLimit", () => {
|
|||||||
maxPublicRecipes: 3,
|
maxPublicRecipes: 3,
|
||||||
};
|
};
|
||||||
|
|
||||||
it("does not throw when usage is under limit", async () => {
|
it("does not throw when the atomic upsert returns a row (under limit)", async () => {
|
||||||
mockDb.select
|
mockDb.select.mockReturnValueOnce(makeChain([tierDef]));
|
||||||
.mockReturnValueOnce(makeChain([tierDef]))
|
mockDb.execute.mockResolvedValueOnce([{ aiCallsUsed: 4 }]);
|
||||||
.mockReturnValueOnce(makeChain([{ aiCallsUsed: 3, recipeCount: 2, storageUsedMb: 0 }]));
|
|
||||||
|
|
||||||
await expect(checkTierLimit("user1", "free", "aiCall")).resolves.toBeUndefined();
|
await expect(checkAndIncrementTierLimit("user1", "free", "aiCall")).resolves.toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("throws TierLimitError when aiCall limit reached", async () => {
|
it("throws TierLimitError when the upsert's WHERE clause excludes the row (limit reached)", async () => {
|
||||||
mockDb.select
|
mockDb.select.mockReturnValueOnce(makeChain([tierDef]));
|
||||||
.mockReturnValueOnce(makeChain([tierDef]))
|
mockDb.execute.mockResolvedValueOnce([]);
|
||||||
.mockReturnValueOnce(makeChain([{ aiCallsUsed: 5, recipeCount: 0, storageUsedMb: 0 }]));
|
|
||||||
|
|
||||||
await expect(checkTierLimit("user1", "free", "aiCall")).rejects.toThrow(TierLimitError);
|
await expect(checkAndIncrementTierLimit("user1", "free", "aiCall")).rejects.toThrow(TierLimitError);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("throws TierLimitError when recipe limit reached", async () => {
|
it("throws TierLimitError for recipe key when limit reached", async () => {
|
||||||
mockDb.select
|
mockDb.select.mockReturnValueOnce(makeChain([tierDef]));
|
||||||
.mockReturnValueOnce(makeChain([tierDef]))
|
mockDb.execute.mockResolvedValueOnce([]);
|
||||||
.mockReturnValueOnce(makeChain([{ aiCallsUsed: 0, recipeCount: 10, storageUsedMb: 0 }]));
|
|
||||||
|
|
||||||
await expect(checkTierLimit("user1", "free", "recipe")).rejects.toThrow(TierLimitError);
|
await expect(checkAndIncrementTierLimit("user1", "free", "recipe")).rejects.toThrow(TierLimitError);
|
||||||
});
|
|
||||||
|
|
||||||
it("does not throw when no usage row exists (treats as zero)", async () => {
|
|
||||||
mockDb.select
|
|
||||||
.mockReturnValueOnce(makeChain([tierDef]))
|
|
||||||
.mockReturnValueOnce(makeChain([]));
|
|
||||||
|
|
||||||
await expect(checkTierLimit("user1", "free", "aiCall")).resolves.toBeUndefined();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not throw when tier definition does not exist", async () => {
|
it("does not throw when tier definition does not exist", async () => {
|
||||||
mockDb.select.mockReturnValueOnce(makeChain([]));
|
mockDb.select.mockReturnValueOnce(makeChain([]));
|
||||||
|
|
||||||
await expect(checkTierLimit("user1", "free", "aiCall")).resolves.toBeUndefined();
|
await expect(checkAndIncrementTierLimit("user1", "free", "aiCall")).resolves.toBeUndefined();
|
||||||
});
|
expect(mockDb.execute).not.toHaveBeenCalled();
|
||||||
|
|
||||||
it("does not throw for storage key (no limit enforced)", async () => {
|
|
||||||
mockDb.select
|
|
||||||
.mockReturnValueOnce(makeChain([tierDef]))
|
|
||||||
.mockReturnValueOnce(makeChain([{ aiCallsUsed: 0, recipeCount: 0, storageUsedMb: 9999 }]));
|
|
||||||
|
|
||||||
await expect(checkTierLimit("user1", "free", "storage")).resolves.toBeUndefined();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { isPrivateAddress } from "../validate-webhook-url";
|
||||||
|
|
||||||
|
describe("isPrivateAddress", () => {
|
||||||
|
it("flags IPv4 private/reserved ranges", () => {
|
||||||
|
expect(isPrivateAddress("127.0.0.1")).toBe(true);
|
||||||
|
expect(isPrivateAddress("10.1.2.3")).toBe(true);
|
||||||
|
expect(isPrivateAddress("172.16.0.1")).toBe(true);
|
||||||
|
expect(isPrivateAddress("192.168.1.1")).toBe(true);
|
||||||
|
expect(isPrivateAddress("169.254.1.1")).toBe(true);
|
||||||
|
expect(isPrivateAddress("224.0.0.1")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows public IPv4 addresses", () => {
|
||||||
|
expect(isPrivateAddress("8.8.8.8")).toBe(false);
|
||||||
|
expect(isPrivateAddress("1.1.1.1")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails closed on malformed IPv4 octets", () => {
|
||||||
|
expect(isPrivateAddress("999.999.999.999")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("flags IPv6 loopback and unspecified addresses in any compression form", () => {
|
||||||
|
expect(isPrivateAddress("::1")).toBe(true);
|
||||||
|
expect(isPrivateAddress("::")).toBe(true);
|
||||||
|
expect(isPrivateAddress("0:0:0:0:0:0:0:1")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("flags IPv6 unique-local (fc00::/7) and link-local (fe80::/10) ranges", () => {
|
||||||
|
expect(isPrivateAddress("fc00::1")).toBe(true);
|
||||||
|
expect(isPrivateAddress("fd12:3456::1")).toBe(true);
|
||||||
|
expect(isPrivateAddress("fdff:ffff:ffff:ffff:ffff:ffff:ffff:ffff")).toBe(true);
|
||||||
|
expect(isPrivateAddress("fe80::1")).toBe(true);
|
||||||
|
expect(isPrivateAddress("fe00::1")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("recurses into IPv4-mapped IPv6 addresses, including non-compressed forms", () => {
|
||||||
|
expect(isPrivateAddress("::ffff:127.0.0.1")).toBe(true);
|
||||||
|
expect(isPrivateAddress("::ffff:10.0.0.5")).toBe(true);
|
||||||
|
expect(isPrivateAddress("::ffff:8.8.8.8")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails closed on a malformed IPv4-mapped address (out-of-range octets)", () => {
|
||||||
|
expect(isPrivateAddress("::ffff:999.999.999.999")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows public IPv6 addresses", () => {
|
||||||
|
expect(isPrivateAddress("2001:4860:4860::8888")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails closed on unparseable input", () => {
|
||||||
|
expect(isPrivateAddress("not-an-ip")).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -22,7 +22,7 @@ vi.mock("@epicure/db", () => ({
|
|||||||
|
|
||||||
let fetchCalls: { url: string; body: string; headers: Record<string, string> }[] = [];
|
let fetchCalls: { url: string; body: string; headers: Record<string, string> }[] = [];
|
||||||
|
|
||||||
global.fetch = vi.fn(async (url: RequestInfo, init?: RequestInit) => {
|
global.fetch = vi.fn(async (url: RequestInfo | URL, init?: RequestInit) => {
|
||||||
const body = (init?.body as string) ?? "";
|
const body = (init?.body as string) ?? "";
|
||||||
fetchCalls.push({
|
fetchCalls.push({
|
||||||
url: url as string,
|
url: url as string,
|
||||||
@@ -38,7 +38,7 @@ beforeEach(() => {
|
|||||||
fetchCalls = [];
|
fetchCalls = [];
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
mockInsert.mockReturnValue({ values: mockInsertValues });
|
mockInsert.mockReturnValue({ values: mockInsertValues });
|
||||||
global.fetch = vi.fn(async (url: RequestInfo, init?: RequestInit) => {
|
global.fetch = vi.fn(async (url: RequestInfo | URL, init?: RequestInit) => {
|
||||||
const body = (init?.body as string) ?? "";
|
const body = (init?.body as string) ?? "";
|
||||||
fetchCalls.push({
|
fetchCalls.push({
|
||||||
url: url as string,
|
url: url as string,
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ describe("withUserKey", () => {
|
|||||||
vi.mocked(mockUserAiKeysFindMany); // just ensure mock is ready
|
vi.mocked(mockUserAiKeysFindMany); // just ensure mock is ready
|
||||||
// withUserKey uses findFirst via userAiKeys
|
// withUserKey uses findFirst via userAiKeys
|
||||||
const mockFindFirst = vi.fn().mockResolvedValue({ encryptedKey: encKey });
|
const mockFindFirst = vi.fn().mockResolvedValue({ encryptedKey: encKey });
|
||||||
vi.mocked((await import("@epicure/db")).db.query.userAiKeys as unknown as { findFirst: typeof mockFindFirst }).findFirst = mockFindFirst;
|
((await import("@epicure/db")).db.query.userAiKeys as unknown as { findFirst: typeof mockFindFirst }).findFirst = mockFindFirst;
|
||||||
|
|
||||||
const config = await withUserKey("user1", { provider: "openai" });
|
const config = await withUserKey("user1", { provider: "openai" });
|
||||||
expect(config.apiKey).toBe("sk-user-key");
|
expect(config.apiKey).toBe("sk-user-key");
|
||||||
@@ -100,7 +100,7 @@ describe("withUserKey", () => {
|
|||||||
|
|
||||||
it("returns config unchanged when no user key for provider", async () => {
|
it("returns config unchanged when no user key for provider", async () => {
|
||||||
const mockFindFirst = vi.fn().mockResolvedValue(null);
|
const mockFindFirst = vi.fn().mockResolvedValue(null);
|
||||||
vi.mocked((await import("@epicure/db")).db.query.userAiKeys as unknown as { findFirst: typeof mockFindFirst }).findFirst = mockFindFirst;
|
((await import("@epicure/db")).db.query.userAiKeys as unknown as { findFirst: typeof mockFindFirst }).findFirst = mockFindFirst;
|
||||||
|
|
||||||
const config = await withUserKey("user1", { provider: "anthropic" });
|
const config = await withUserKey("user1", { provider: "anthropic" });
|
||||||
expect(config.apiKey).toBeUndefined();
|
expect(config.apiKey).toBeUndefined();
|
||||||
@@ -118,7 +118,7 @@ describe("getModelConfigForUseCase", () => {
|
|||||||
mealPlanModel: null,
|
mealPlanModel: null,
|
||||||
});
|
});
|
||||||
const mockFindFirst = vi.fn().mockResolvedValue(null); // no BYOK key
|
const mockFindFirst = vi.fn().mockResolvedValue(null); // no BYOK key
|
||||||
vi.mocked((await import("@epicure/db")).db.query.userAiKeys as unknown as { findFirst: typeof mockFindFirst }).findFirst = mockFindFirst;
|
((await import("@epicure/db")).db.query.userAiKeys as unknown as { findFirst: typeof mockFindFirst }).findFirst = mockFindFirst;
|
||||||
|
|
||||||
const config = await getModelConfigForUseCase("user1", "text");
|
const config = await getModelConfigForUseCase("user1", "text");
|
||||||
expect(config.provider).toBe("anthropic");
|
expect(config.provider).toBe("anthropic");
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { generateObject } from "ai";
|
import { generateObject } from "ai";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { resolveModel, type AiConfig } from "../factory";
|
import { resolveModel, type AiConfig } from "../factory";
|
||||||
|
import { dietaryTagsSchema, ingredientSchema, stepSchema } from "./recipe-schema";
|
||||||
|
|
||||||
const AdaptedRecipeSchema = z.object({
|
const AdaptedRecipeSchema = z.object({
|
||||||
title: z.string(),
|
title: z.string(),
|
||||||
@@ -9,25 +10,9 @@ const AdaptedRecipeSchema = z.object({
|
|||||||
prepMins: z.number().int().min(0).optional(),
|
prepMins: z.number().int().min(0).optional(),
|
||||||
cookMins: z.number().int().min(0).optional(),
|
cookMins: z.number().int().min(0).optional(),
|
||||||
difficulty: z.enum(["easy", "medium", "hard"]),
|
difficulty: z.enum(["easy", "medium", "hard"]),
|
||||||
dietaryTags: z.object({
|
dietaryTags: dietaryTagsSchema,
|
||||||
vegan: z.boolean().optional(),
|
ingredients: z.array(ingredientSchema(z.number())),
|
||||||
vegetarian: z.boolean().optional(),
|
steps: z.array(stepSchema),
|
||||||
glutenFree: z.boolean().optional(),
|
|
||||||
dairyFree: z.boolean().optional(),
|
|
||||||
nutFree: z.boolean().optional(),
|
|
||||||
halal: z.boolean().optional(),
|
|
||||||
kosher: z.boolean().optional(),
|
|
||||||
}),
|
|
||||||
ingredients: z.array(z.object({
|
|
||||||
rawName: z.string(),
|
|
||||||
quantity: z.number().optional(),
|
|
||||||
unit: z.string().optional(),
|
|
||||||
note: z.string().optional(),
|
|
||||||
})),
|
|
||||||
steps: z.array(z.object({
|
|
||||||
instruction: z.string(),
|
|
||||||
timerSeconds: z.number().int().optional(),
|
|
||||||
})),
|
|
||||||
adaptationNotes: z.string(),
|
adaptationNotes: z.string(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { generateObject } from "ai";
|
import { generateObject } from "ai";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { resolveModel, type AiConfig } from "../factory";
|
import { resolveModel, type AiConfig } from "../factory";
|
||||||
|
import { dietaryTagsSchema, ingredientSchema, stepSchema } from "./recipe-schema";
|
||||||
|
|
||||||
const RecipeOutputSchema = z.object({
|
const RecipeOutputSchema = z.object({
|
||||||
title: z.string(),
|
title: z.string(),
|
||||||
@@ -9,25 +10,9 @@ const RecipeOutputSchema = z.object({
|
|||||||
prepMins: z.number().int().min(0).optional(),
|
prepMins: z.number().int().min(0).optional(),
|
||||||
cookMins: z.number().int().min(0).optional(),
|
cookMins: z.number().int().min(0).optional(),
|
||||||
difficulty: z.enum(["easy", "medium", "hard"]),
|
difficulty: z.enum(["easy", "medium", "hard"]),
|
||||||
dietaryTags: z.object({
|
dietaryTags: dietaryTagsSchema,
|
||||||
vegan: z.boolean().optional(),
|
ingredients: z.array(ingredientSchema(z.number())),
|
||||||
vegetarian: z.boolean().optional(),
|
steps: z.array(stepSchema),
|
||||||
glutenFree: z.boolean().optional(),
|
|
||||||
dairyFree: z.boolean().optional(),
|
|
||||||
nutFree: z.boolean().optional(),
|
|
||||||
halal: z.boolean().optional(),
|
|
||||||
kosher: z.boolean().optional(),
|
|
||||||
}),
|
|
||||||
ingredients: z.array(z.object({
|
|
||||||
rawName: z.string(),
|
|
||||||
quantity: z.number().optional(),
|
|
||||||
unit: z.string().optional(),
|
|
||||||
note: z.string().optional(),
|
|
||||||
})),
|
|
||||||
steps: z.array(z.object({
|
|
||||||
instruction: z.string(),
|
|
||||||
timerSeconds: z.number().int().optional(),
|
|
||||||
})),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export type GeneratedRecipe = z.infer<typeof RecipeOutputSchema>;
|
export type GeneratedRecipe = z.infer<typeof RecipeOutputSchema>;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { generateObject } from "ai";
|
import { generateObject } from "ai";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { resolveModel, type AiConfig } from "../factory";
|
import { resolveModel, type AiConfig } from "../factory";
|
||||||
|
import { dietaryTagsSchema, ingredientSchema, stepSchema } from "./recipe-schema";
|
||||||
|
|
||||||
const ImportedRecipeSchema = z.object({
|
const ImportedRecipeSchema = z.object({
|
||||||
title: z.string(),
|
title: z.string(),
|
||||||
@@ -9,25 +10,9 @@ const ImportedRecipeSchema = z.object({
|
|||||||
prepMins: z.number().int().min(0).optional(),
|
prepMins: z.number().int().min(0).optional(),
|
||||||
cookMins: z.number().int().min(0).optional(),
|
cookMins: z.number().int().min(0).optional(),
|
||||||
difficulty: z.enum(["easy", "medium", "hard"]).optional(),
|
difficulty: z.enum(["easy", "medium", "hard"]).optional(),
|
||||||
dietaryTags: z.object({
|
dietaryTags: dietaryTagsSchema.optional(),
|
||||||
vegan: z.boolean().optional(),
|
ingredients: z.array(ingredientSchema(z.string())),
|
||||||
vegetarian: z.boolean().optional(),
|
steps: z.array(stepSchema),
|
||||||
glutenFree: z.boolean().optional(),
|
|
||||||
dairyFree: z.boolean().optional(),
|
|
||||||
nutFree: z.boolean().optional(),
|
|
||||||
halal: z.boolean().optional(),
|
|
||||||
kosher: z.boolean().optional(),
|
|
||||||
}).optional(),
|
|
||||||
ingredients: z.array(z.object({
|
|
||||||
rawName: z.string(),
|
|
||||||
quantity: z.string().optional(),
|
|
||||||
unit: z.string().optional(),
|
|
||||||
note: z.string().optional(),
|
|
||||||
})),
|
|
||||||
steps: z.array(z.object({
|
|
||||||
instruction: z.string(),
|
|
||||||
timerSeconds: z.number().int().optional(),
|
|
||||||
})),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export type ImportedRecipe = z.infer<typeof ImportedRecipeSchema>;
|
export type ImportedRecipe = z.infer<typeof ImportedRecipeSchema>;
|
||||||
|
|||||||
@@ -1,63 +1,8 @@
|
|||||||
import { generateObject } from "ai";
|
import { generateObject } from "ai";
|
||||||
import dns from "node:dns/promises";
|
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { resolveModel, type AiConfig } from "../factory";
|
import { resolveModel, type AiConfig } from "../factory";
|
||||||
|
import { validateWebhookUrl } from "@/lib/validate-webhook-url";
|
||||||
/**
|
import { dietaryTagsSchema, ingredientSchema, stepSchema } from "./recipe-schema";
|
||||||
* Resolves the hostname in `rawUrl` and returns an error string if the
|
|
||||||
* URL targets a private/reserved address range (SSRF guard), or null if safe.
|
|
||||||
*/
|
|
||||||
async function validateImportUrl(rawUrl: string): Promise<string | null> {
|
|
||||||
let url: URL;
|
|
||||||
try {
|
|
||||||
url = new URL(rawUrl);
|
|
||||||
} catch {
|
|
||||||
return "Invalid URL";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (url.protocol !== "https:" && url.protocol !== "http:") {
|
|
||||||
return "URL must use http or https";
|
|
||||||
}
|
|
||||||
|
|
||||||
let addresses: string[];
|
|
||||||
try {
|
|
||||||
const results = await dns.lookup(url.hostname, { all: true, family: 0 });
|
|
||||||
addresses = results.map((r) => r.address);
|
|
||||||
} catch {
|
|
||||||
return "Unable to resolve hostname";
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const addr of addresses) {
|
|
||||||
if (isPrivateAddress(addr)) {
|
|
||||||
return "URL must not point to a private or reserved address";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isPrivateAddress(ip: string): boolean {
|
|
||||||
const v4 = ip.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
|
|
||||||
if (v4) {
|
|
||||||
const [, a, b, c] = v4.map(Number) as [number, number, number, number, number];
|
|
||||||
if (a === 127) return true;
|
|
||||||
if (a === 10) return true;
|
|
||||||
if (a === 172 && b >= 16 && b <= 31) return true;
|
|
||||||
if (a === 192 && b === 168) return true;
|
|
||||||
if (a === 169 && b === 254) return true;
|
|
||||||
if (a >= 224) return true;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
const lower = ip.toLowerCase();
|
|
||||||
if (lower === "::1") return true;
|
|
||||||
if (lower === "::") return true;
|
|
||||||
if (lower.startsWith("fe80:")) return true;
|
|
||||||
if (lower.startsWith("fc") || lower.startsWith("fd")) return true;
|
|
||||||
const v4mapped = lower.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/);
|
|
||||||
if (v4mapped) return isPrivateAddress(v4mapped[1]!);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
const ImportedRecipeSchema = z.object({
|
const ImportedRecipeSchema = z.object({
|
||||||
title: z.string(),
|
title: z.string(),
|
||||||
@@ -66,31 +11,15 @@ const ImportedRecipeSchema = z.object({
|
|||||||
prepMins: z.number().int().min(0).optional(),
|
prepMins: z.number().int().min(0).optional(),
|
||||||
cookMins: z.number().int().min(0).optional(),
|
cookMins: z.number().int().min(0).optional(),
|
||||||
difficulty: z.enum(["easy", "medium", "hard"]).optional(),
|
difficulty: z.enum(["easy", "medium", "hard"]).optional(),
|
||||||
dietaryTags: z.object({
|
dietaryTags: dietaryTagsSchema.optional(),
|
||||||
vegan: z.boolean().optional(),
|
ingredients: z.array(ingredientSchema(z.string())),
|
||||||
vegetarian: z.boolean().optional(),
|
steps: z.array(stepSchema),
|
||||||
glutenFree: z.boolean().optional(),
|
|
||||||
dairyFree: z.boolean().optional(),
|
|
||||||
nutFree: z.boolean().optional(),
|
|
||||||
halal: z.boolean().optional(),
|
|
||||||
kosher: z.boolean().optional(),
|
|
||||||
}).optional(),
|
|
||||||
ingredients: z.array(z.object({
|
|
||||||
rawName: z.string(),
|
|
||||||
quantity: z.string().optional(),
|
|
||||||
unit: z.string().optional(),
|
|
||||||
note: z.string().optional(),
|
|
||||||
})),
|
|
||||||
steps: z.array(z.object({
|
|
||||||
instruction: z.string(),
|
|
||||||
timerSeconds: z.number().int().optional(),
|
|
||||||
})),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export type ImportedRecipe = z.infer<typeof ImportedRecipeSchema>;
|
export type ImportedRecipe = z.infer<typeof ImportedRecipeSchema>;
|
||||||
|
|
||||||
export async function importFromUrl(url: string, config?: AiConfig): Promise<ImportedRecipe> {
|
export async function importFromUrl(url: string, config?: AiConfig): Promise<ImportedRecipe> {
|
||||||
const ssrfError = await validateImportUrl(url);
|
const ssrfError = await validateWebhookUrl(url);
|
||||||
if (ssrfError) throw new Error(ssrfError);
|
if (ssrfError) throw new Error(ssrfError);
|
||||||
|
|
||||||
const res = await fetch(url, {
|
const res = await fetch(url, {
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export const dietaryTagsSchema = z.object({
|
||||||
|
vegan: z.boolean().optional(),
|
||||||
|
vegetarian: z.boolean().optional(),
|
||||||
|
glutenFree: z.boolean().optional(),
|
||||||
|
dairyFree: z.boolean().optional(),
|
||||||
|
nutFree: z.boolean().optional(),
|
||||||
|
halal: z.boolean().optional(),
|
||||||
|
kosher: z.boolean().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const stepSchema = z.object({
|
||||||
|
instruction: z.string(),
|
||||||
|
timerSeconds: z.number().int().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export function ingredientSchema<Q extends z.ZodTypeAny>(quantity: Q) {
|
||||||
|
return z.object({
|
||||||
|
rawName: z.string(),
|
||||||
|
quantity: quantity.optional(),
|
||||||
|
unit: z.string().optional(),
|
||||||
|
note: z.string().optional(),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
export type TaggedRecipe = {
|
||||||
|
id: string;
|
||||||
|
tags: string[];
|
||||||
|
dietaryTags: Record<string, boolean> | null;
|
||||||
|
createdAt: Date;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Collapses a recipe's tags + true dietary-tag keys into one flat tag list. */
|
||||||
|
function tagSet(recipe: Pick<TaggedRecipe, "tags" | "dietaryTags">): string[] {
|
||||||
|
const dietary = Object.entries(recipe.dietaryTags ?? {})
|
||||||
|
.filter(([, v]) => v)
|
||||||
|
.map(([k]) => k);
|
||||||
|
return [...recipe.tags, ...dietary];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Builds a tag → frequency map from the recipes a user has favorited/highly rated. */
|
||||||
|
export function buildPreferenceMap(likedRecipes: Array<Pick<TaggedRecipe, "tags" | "dietaryTags">>): Map<string, number> {
|
||||||
|
const map = new Map<string, number>();
|
||||||
|
for (const recipe of likedRecipes) {
|
||||||
|
for (const tag of tagSet(recipe)) {
|
||||||
|
map.set(tag, (map.get(tag) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scores a candidate recipe by how many of its tags overlap with the user's
|
||||||
|
* preference map (weighted by how often that tag shows up in their history).
|
||||||
|
* Recency is used only as a tiebreaker (see rankForYou) — an empty preference
|
||||||
|
* map scores everything 0, which the caller should treat as "fall back to
|
||||||
|
* trending" rather than a meaningful ranking.
|
||||||
|
*/
|
||||||
|
export function scoreCandidate(candidate: Pick<TaggedRecipe, "tags" | "dietaryTags">, preferences: Map<string, number>): number {
|
||||||
|
return tagSet(candidate).reduce((sum, tag) => sum + (preferences.get(tag) ?? 0), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rankForYou<T extends TaggedRecipe>(candidates: T[], preferences: Map<string, number>): T[] {
|
||||||
|
return [...candidates]
|
||||||
|
.map((c) => ({ c, score: scoreCandidate(c, preferences) }))
|
||||||
|
.sort((a, b) => b.score - a.score || b.c.createdAt.getTime() - a.c.createdAt.getTime())
|
||||||
|
.map(({ c }) => c);
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
export type GroceryExportItem = {
|
||||||
|
name: string;
|
||||||
|
quantity: string | null;
|
||||||
|
unit: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GroceryExportPayload = {
|
||||||
|
listName: string;
|
||||||
|
items: GroceryExportItem[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type ShoppingListForExport = {
|
||||||
|
name: string;
|
||||||
|
items: Array<{ rawName: string; quantity: string | null; unit: string | null; checked: boolean }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Maps a shopping list into a provider-agnostic export shape any grocery-delivery adapter can consume. */
|
||||||
|
export function buildGroceryExportPayload(list: ShoppingListForExport): GroceryExportPayload {
|
||||||
|
return {
|
||||||
|
listName: list.name,
|
||||||
|
items: list.items
|
||||||
|
.filter((i) => !i.checked)
|
||||||
|
.map((i) => ({ name: i.rawName, quantity: i.quantity, unit: i.unit })),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function groceryExportToText(payload: GroceryExportPayload): string {
|
||||||
|
const lines = payload.items.map((i) => {
|
||||||
|
const qty = [i.quantity, i.unit].filter(Boolean).join(" ");
|
||||||
|
return qty ? `${qty} ${i.name}` : i.name;
|
||||||
|
});
|
||||||
|
return [payload.listName, "", ...lines].join("\n");
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import type { GroceryExportPayload } from "@/lib/grocery-export";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stub adapter for the Instacart "Recipe & Shopping List" partner API.
|
||||||
|
*
|
||||||
|
* Not wired to a live endpoint — Instacart requires a signed partnership
|
||||||
|
* agreement and a per-integration API key before any request can succeed.
|
||||||
|
* This documents the request shape so activation is a config change, not a
|
||||||
|
* rewrite, once `INSTACART_API_KEY` is issued.
|
||||||
|
*
|
||||||
|
* Real endpoint (per Instacart Developer Platform docs, subject to change):
|
||||||
|
* POST https://connect.instacart.com/idp/v1/products/products_link
|
||||||
|
* Authorization: Bearer <INSTACART_API_KEY>
|
||||||
|
* Body: { title: string, link_type: "shopping_list", line_items: [{ name, quantity, unit }] }
|
||||||
|
* Response contains a `products_link_url` the user is redirected to.
|
||||||
|
*/
|
||||||
|
export async function createInstacartShoppingListLink(
|
||||||
|
payload: GroceryExportPayload
|
||||||
|
): Promise<{ url: string } | null> {
|
||||||
|
const apiKey = process.env["INSTACART_API_KEY"];
|
||||||
|
if (!apiKey) return null;
|
||||||
|
|
||||||
|
throw new Error(
|
||||||
|
"Instacart integration is stubbed — INSTACART_API_KEY is set but no live API call is wired up yet. " +
|
||||||
|
`Would send list "${payload.listName}" with ${payload.items.length} item(s).`
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { db, mealPlans, mealPlanMembers, eq, and } from "@epicure/db";
|
||||||
|
|
||||||
|
export type MealPlanRole = "owner" | "editor" | "viewer";
|
||||||
|
|
||||||
|
export type MealPlanAccess = {
|
||||||
|
plan: typeof mealPlans.$inferSelect;
|
||||||
|
role: MealPlanRole;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Resolves a user's access to a meal plan by its id — owner, or member with their assigned role. Null if no access. */
|
||||||
|
export async function getMealPlanAccessById(
|
||||||
|
mealPlanId: string,
|
||||||
|
userId: string
|
||||||
|
): Promise<MealPlanAccess | null> {
|
||||||
|
const plan = await db.query.mealPlans.findFirst({ where: eq(mealPlans.id, mealPlanId) });
|
||||||
|
if (!plan) return null;
|
||||||
|
if (plan.userId === userId) return { plan, role: "owner" };
|
||||||
|
|
||||||
|
const member = await db.query.mealPlanMembers.findFirst({
|
||||||
|
where: and(eq(mealPlanMembers.mealPlanId, mealPlanId), eq(mealPlanMembers.userId, userId)),
|
||||||
|
});
|
||||||
|
if (!member) return null;
|
||||||
|
|
||||||
|
return { plan, role: member.role };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canWriteMealPlan(role: MealPlanRole): boolean {
|
||||||
|
return role === "owner" || role === "editor";
|
||||||
|
}
|
||||||
+10
-1
@@ -107,6 +107,15 @@ export function generateOpenApiSpec(): object {
|
|||||||
pagination: z.object({ page: z.number(), limit: z.number(), total: z.number(), pages: z.number() }),
|
pagination: z.object({ page: z.number(), limit: z.number(), total: z.number(), pages: z.number() }),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const PaginatedCollections = z.object({
|
||||||
|
data: z.array(CollectionRef),
|
||||||
|
total: z.number(),
|
||||||
|
limit: z.number(),
|
||||||
|
offset: z.number(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const LimitOffset = z.object({ limit: z.coerce.number().default(20), offset: z.coerce.number().default(0) });
|
||||||
|
|
||||||
const idParam = z.object({ id: z.string() });
|
const idParam = z.object({ id: z.string() });
|
||||||
|
|
||||||
registry.registerPath({ method: "get", path: "/api/v1/recipes", summary: "List recipes", security, request: { query: z.object({ page: z.coerce.number().default(1), limit: z.coerce.number().default(20), visibility: z.enum(["private", "unlisted", "public"]).optional(), q: z.string().optional(), difficulty: z.enum(["easy", "medium", "hard"]).optional() }) }, responses: { 200: { description: "Paginated", content: { "application/json": { schema: PaginatedRecipes } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
|
registry.registerPath({ method: "get", path: "/api/v1/recipes", summary: "List recipes", security, request: { query: z.object({ page: z.coerce.number().default(1), limit: z.coerce.number().default(20), visibility: z.enum(["private", "unlisted", "public"]).optional(), q: z.string().optional(), difficulty: z.enum(["easy", "medium", "hard"]).optional() }) }, responses: { 200: { description: "Paginated", content: { "application/json": { schema: PaginatedRecipes } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
|
||||||
@@ -121,7 +130,7 @@ export function generateOpenApiSpec(): object {
|
|||||||
registry.registerPath({ method: "post", path: "/api/v1/ai/generate", summary: "Generate recipe from prompt", description: "Rate-limited: 10 req/min.", security, request: { body: { content: { "application/json": { schema: z.object({ prompt: z.string().min(1) }) } }, required: true } }, responses: { 200: { description: "Generated", content: { "application/json": { schema: AiGeneratedRef } } }, 429: { description: "Rate limited", content: { "application/json": { schema: ApiErrorRef } } } } });
|
registry.registerPath({ method: "post", path: "/api/v1/ai/generate", summary: "Generate recipe from prompt", description: "Rate-limited: 10 req/min.", security, request: { body: { content: { "application/json": { schema: z.object({ prompt: z.string().min(1) }) } }, required: true } }, responses: { 200: { description: "Generated", content: { "application/json": { schema: AiGeneratedRef } } }, 429: { description: "Rate limited", content: { "application/json": { schema: ApiErrorRef } } } } });
|
||||||
registry.registerPath({ method: "post", path: "/api/v1/ai/import-url", summary: "Import recipe from URL", description: "Rate-limited: 10 req/min.", security, request: { body: { content: { "application/json": { schema: z.object({ url: z.string().url() }) } }, required: true } }, responses: { 200: { description: "Imported", content: { "application/json": { schema: AiGeneratedRef } } }, 429: { description: "Rate limited", content: { "application/json": { schema: ApiErrorRef } } } } });
|
registry.registerPath({ method: "post", path: "/api/v1/ai/import-url", summary: "Import recipe from URL", description: "Rate-limited: 10 req/min.", security, request: { body: { content: { "application/json": { schema: z.object({ url: z.string().url() }) } }, required: true } }, responses: { 200: { description: "Imported", content: { "application/json": { schema: AiGeneratedRef } } }, 429: { description: "Rate limited", content: { "application/json": { schema: ApiErrorRef } } } } });
|
||||||
registry.registerPath({ method: "get", path: "/api/v1/feed", summary: "Activity feed (pull-based)", security, request: { query: Pagination }, responses: { 200: { description: "Feed", content: { "application/json": { schema: z.array(RecipeRef) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
|
registry.registerPath({ method: "get", path: "/api/v1/feed", summary: "Activity feed (pull-based)", security, request: { query: Pagination }, responses: { 200: { description: "Feed", content: { "application/json": { schema: z.array(RecipeRef) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
|
||||||
registry.registerPath({ method: "get", path: "/api/v1/collections", summary: "List collections", security, request: { query: Pagination }, responses: { 200: { description: "Collections", content: { "application/json": { schema: z.array(CollectionRef) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
|
registry.registerPath({ method: "get", path: "/api/v1/collections", summary: "List collections", security, request: { query: LimitOffset }, responses: { 200: { description: "Paginated collections", content: { "application/json": { schema: PaginatedCollections } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
|
||||||
registry.registerPath({ method: "get", path: "/api/v1/meal-plans/{weekStart}", summary: "Get meal plan for week", security, request: { params: z.object({ weekStart: z.string().describe("ISO date YYYY-MM-DD (Monday)") }) }, responses: { 200: { description: "Meal plan", content: { "application/json": { schema: MealPlanRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
|
registry.registerPath({ method: "get", path: "/api/v1/meal-plans/{weekStart}", summary: "Get meal plan for week", security, request: { params: z.object({ weekStart: z.string().describe("ISO date YYYY-MM-DD (Monday)") }) }, responses: { 200: { description: "Meal plan", content: { "application/json": { schema: MealPlanRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
|
||||||
registry.registerPath({ method: "get", path: "/api/v1/pantry", summary: "List pantry items", security, request: { query: Pagination }, responses: { 200: { description: "Items", content: { "application/json": { schema: z.array(PantryItemRef) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
|
registry.registerPath({ method: "get", path: "/api/v1/pantry", summary: "List pantry items", security, request: { query: Pagination }, responses: { 200: { description: "Items", content: { "application/json": { schema: z.array(PantryItemRef) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
|
||||||
registry.registerPath({ method: "get", path: "/api/v1/shopping-lists", summary: "List shopping lists", security, request: { query: Pagination }, responses: { 200: { description: "Lists", content: { "application/json": { schema: z.array(ShoppingListRef) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
|
registry.registerPath({ method: "get", path: "/api/v1/shopping-lists", summary: "List shopping lists", security, request: { query: Pagination }, responses: { 200: { description: "Lists", content: { "application/json": { schema: z.array(ShoppingListRef) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { db, shoppingLists, shoppingListMembers, eq, and } from "@epicure/db";
|
||||||
|
|
||||||
|
export type ShoppingListRole = "owner" | "editor" | "viewer";
|
||||||
|
|
||||||
|
export type ShoppingListAccess = {
|
||||||
|
list: typeof shoppingLists.$inferSelect;
|
||||||
|
role: ShoppingListRole;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Resolves a user's access to a shopping list — owner, or member with their assigned role. Null if no access. */
|
||||||
|
export async function getShoppingListAccess(
|
||||||
|
listId: string,
|
||||||
|
userId: string
|
||||||
|
): Promise<ShoppingListAccess | null> {
|
||||||
|
const list = await db.query.shoppingLists.findFirst({ where: eq(shoppingLists.id, listId) });
|
||||||
|
if (!list) return null;
|
||||||
|
if (list.userId === userId) return { list, role: "owner" };
|
||||||
|
|
||||||
|
const member = await db.query.shoppingListMembers.findFirst({
|
||||||
|
where: and(eq(shoppingListMembers.listId, listId), eq(shoppingListMembers.userId, userId)),
|
||||||
|
});
|
||||||
|
if (!member) return null;
|
||||||
|
|
||||||
|
return { list, role: member.role };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canWriteShoppingList(role: ShoppingListRole): boolean {
|
||||||
|
return role === "owner" || role === "editor";
|
||||||
|
}
|
||||||
+1
-34
@@ -1,6 +1,6 @@
|
|||||||
import { db } from "@epicure/db";
|
import { db } from "@epicure/db";
|
||||||
import { tierDefinitions, userUsage } from "@epicure/db";
|
import { tierDefinitions, userUsage } from "@epicure/db";
|
||||||
import { eq, and, sql } from "@epicure/db";
|
import { eq, sql } from "@epicure/db";
|
||||||
|
|
||||||
function currentMonth() {
|
function currentMonth() {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
@@ -69,39 +69,6 @@ export async function checkAndIncrementTierLimit(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @deprecated Use checkAndIncrementTierLimit for recipe/aiCall keys to avoid TOCTOU races. */
|
|
||||||
export async function checkTierLimit(
|
|
||||||
userId: string,
|
|
||||||
userTier: "free" | "pro",
|
|
||||||
key: LimitKey
|
|
||||||
): Promise<void> {
|
|
||||||
const [tierDef] = await db
|
|
||||||
.select()
|
|
||||||
.from(tierDefinitions)
|
|
||||||
.where(eq(tierDefinitions.tier, userTier));
|
|
||||||
|
|
||||||
if (!tierDef) return;
|
|
||||||
|
|
||||||
const month = currentMonth();
|
|
||||||
const [usage] = await db
|
|
||||||
.select()
|
|
||||||
.from(userUsage)
|
|
||||||
.where(and(eq(userUsage.userId, userId), eq(userUsage.month, month)));
|
|
||||||
|
|
||||||
const current = usage ?? {
|
|
||||||
aiCallsUsed: 0,
|
|
||||||
recipeCount: 0,
|
|
||||||
storageUsedMb: 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (key === "recipe" && current.recipeCount >= tierDef.maxRecipes) {
|
|
||||||
throw new TierLimitError("recipe", userTier);
|
|
||||||
}
|
|
||||||
if (key === "aiCall" && current.aiCallsUsed >= tierDef.aiCallsPerMonth) {
|
|
||||||
throw new TierLimitError("aiCall", userTier);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function incrementUsage(
|
export async function incrementUsage(
|
||||||
userId: string,
|
userId: string,
|
||||||
key: LimitKey,
|
key: LimitKey,
|
||||||
|
|||||||
@@ -1,24 +1,91 @@
|
|||||||
import dns from "node:dns/promises";
|
import dns from "node:dns/promises";
|
||||||
|
import net from "node:net";
|
||||||
|
|
||||||
function isPrivateAddress(ip: string): boolean {
|
function isPrivateV4(a: number, b: number): boolean {
|
||||||
const v4 = ip.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
|
if (a === 127) return true;
|
||||||
if (v4) {
|
if (a === 10) return true;
|
||||||
const [, a, b, c] = v4.map(Number) as [number, number, number, number, number];
|
if (a === 172 && b >= 16 && b <= 31) return true;
|
||||||
if (a === 127) return true;
|
if (a === 192 && b === 168) return true;
|
||||||
if (a === 10) return true;
|
if (a === 169 && b === 254) return true;
|
||||||
if (a === 172 && b >= 16 && b <= 31) return true;
|
if (a >= 224) return true;
|
||||||
if (a === 192 && b === 168) return true;
|
return false;
|
||||||
if (a === 169 && b === 254) return true;
|
}
|
||||||
if (a >= 224) return true;
|
|
||||||
return false;
|
/** Expands a valid IPv6 address (any compression form) into 8 16-bit groups as a BigInt. */
|
||||||
|
function ipv6ToBigInt(ip: string): bigint | null {
|
||||||
|
if (net.isIPv6(ip) !== true) return null;
|
||||||
|
|
||||||
|
const [head, tail] = ip.split("::");
|
||||||
|
const headParts = head ? head.split(":") : [];
|
||||||
|
const tailParts = tail ? tail.split(":") : [];
|
||||||
|
|
||||||
|
// An embedded IPv4 tail (e.g. "::ffff:127.0.0.1") occupies the last two hextets.
|
||||||
|
const expand = (parts: string[]): string[] => {
|
||||||
|
const last = parts[parts.length - 1];
|
||||||
|
if (last && last.includes(".")) {
|
||||||
|
const octets = last.split(".").map(Number);
|
||||||
|
if (octets.length !== 4 || octets.some((o) => !Number.isInteger(o) || o < 0 || o > 255)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const hex1 = ((octets[0]! << 8) | octets[1]!).toString(16);
|
||||||
|
const hex2 = ((octets[2]! << 8) | octets[3]!).toString(16);
|
||||||
|
return [...parts.slice(0, -1), hex1, hex2];
|
||||||
|
}
|
||||||
|
return parts;
|
||||||
|
};
|
||||||
|
|
||||||
|
const expandedHead = expand(headParts);
|
||||||
|
const expandedTail = expand(tailParts);
|
||||||
|
|
||||||
|
let groups: string[];
|
||||||
|
if (ip.includes("::")) {
|
||||||
|
const missing = 8 - (expandedHead.length + expandedTail.length);
|
||||||
|
if (missing < 0) return null;
|
||||||
|
groups = [...expandedHead, ...Array(missing).fill("0"), ...expandedTail];
|
||||||
|
} else {
|
||||||
|
groups = expandedHead;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (groups.length !== 8) return null;
|
||||||
|
|
||||||
|
let result = BigInt(0);
|
||||||
|
for (const g of groups) {
|
||||||
|
const val = parseInt(g || "0", 16);
|
||||||
|
if (Number.isNaN(val)) return null;
|
||||||
|
result = (result << BigInt(16)) | BigInt(val);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isPrivateAddress(ip: string): boolean {
|
||||||
|
const v4 = ip.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
|
||||||
|
if (v4) {
|
||||||
|
const octets = v4.slice(1, 5).map(Number);
|
||||||
|
if (octets.some((o) => o > 255)) return true; // malformed, fail closed
|
||||||
|
const [a, b] = octets as [number, number];
|
||||||
|
return isPrivateV4(a, b);
|
||||||
|
}
|
||||||
|
|
||||||
|
const addr = ipv6ToBigInt(ip);
|
||||||
|
if (addr === null) return true; // unparseable, fail closed
|
||||||
|
|
||||||
|
if (addr === BigInt(0) || addr === BigInt(1)) return true; // :: and ::1
|
||||||
|
|
||||||
|
const fc00 = BigInt(0xfc00) << BigInt(112);
|
||||||
|
const fe80 = BigInt(0xfe80) << BigInt(112);
|
||||||
|
const mask7 = BigInt(0xfe00) << BigInt(112); // /7 mask for fc00::/7 (top 7 bits of the address)
|
||||||
|
const mask10 = BigInt(0xffc0) << BigInt(112); // /10 mask for fe80::/10 (top 10 bits of the address)
|
||||||
|
if ((addr & mask7) === (fc00 & mask7)) return true; // unique local fc00::/7
|
||||||
|
if ((addr & mask10) === (fe80 & mask10)) return true; // link-local fe80::/10
|
||||||
|
|
||||||
|
// IPv4-mapped ::ffff:0:0/96
|
||||||
|
if (addr >> BigInt(32) === BigInt(0xffff)) {
|
||||||
|
const embedded = addr & BigInt(0xffffffff);
|
||||||
|
const a = Number((embedded >> BigInt(24)) & BigInt(0xff));
|
||||||
|
const b = Number((embedded >> BigInt(16)) & BigInt(0xff));
|
||||||
|
return isPrivateV4(a, b);
|
||||||
}
|
}
|
||||||
|
|
||||||
const lower = ip.toLowerCase();
|
|
||||||
if (lower === "::1" || lower === "::") return true;
|
|
||||||
if (lower.startsWith("fe80:")) return true;
|
|
||||||
if (lower.startsWith("fc") || lower.startsWith("fd")) return true;
|
|
||||||
const v4mapped = lower.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/);
|
|
||||||
if (v4mapped) return isPrivateAddress(v4mapped[1]!);
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -327,7 +327,8 @@
|
|||||||
"partialMatches": "Partial matches",
|
"partialMatches": "Partial matches",
|
||||||
"noMatches": "No strong matches found",
|
"noMatches": "No strong matches found",
|
||||||
"missing": "Missing",
|
"missing": "Missing",
|
||||||
"ingredientProgress": "{matched}/{total} ingredients"
|
"ingredientProgress": "{matched}/{total} ingredients",
|
||||||
|
"useItUp": "Use it up"
|
||||||
},
|
},
|
||||||
"mealPlan": {
|
"mealPlan": {
|
||||||
"title": "Meal Plan",
|
"title": "Meal Plan",
|
||||||
@@ -386,6 +387,8 @@
|
|||||||
"following": "Following",
|
"following": "Following",
|
||||||
"trending": "Trending",
|
"trending": "Trending",
|
||||||
"trendingEmpty": "No trending recipes this week.",
|
"trendingEmpty": "No trending recipes this week.",
|
||||||
|
"forYou": "For You",
|
||||||
|
"forYouEmpty": "Favorite or rate some recipes to get personalized picks here.",
|
||||||
"loading": "Loading…"
|
"loading": "Loading…"
|
||||||
},
|
},
|
||||||
"shoppingLists": {
|
"shoppingLists": {
|
||||||
|
|||||||
@@ -315,7 +315,8 @@
|
|||||||
"partialMatches": "Correspondances partielles",
|
"partialMatches": "Correspondances partielles",
|
||||||
"noMatches": "Aucune correspondance trouvée",
|
"noMatches": "Aucune correspondance trouvée",
|
||||||
"missing": "Manquant",
|
"missing": "Manquant",
|
||||||
"ingredientProgress": "{matched}/{total} ingrédients"
|
"ingredientProgress": "{matched}/{total} ingrédients",
|
||||||
|
"useItUp": "À utiliser vite"
|
||||||
},
|
},
|
||||||
"mealPlan": {
|
"mealPlan": {
|
||||||
"title": "Planning repas",
|
"title": "Planning repas",
|
||||||
@@ -374,6 +375,8 @@
|
|||||||
"following": "Abonnements",
|
"following": "Abonnements",
|
||||||
"trending": "Tendances",
|
"trending": "Tendances",
|
||||||
"trendingEmpty": "Aucune recette tendance cette semaine.",
|
"trendingEmpty": "Aucune recette tendance cette semaine.",
|
||||||
|
"forYou": "Pour vous",
|
||||||
|
"forYouEmpty": "Ajoutez des favoris ou notez des recettes pour des suggestions personnalisées.",
|
||||||
"loading": "Chargement…"
|
"loading": "Chargement…"
|
||||||
},
|
},
|
||||||
"shoppingLists": {
|
"shoppingLists": {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "eslint",
|
"lint": "eslint",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
"test:coverage": "vitest run --coverage"
|
"test:coverage": "vitest run --coverage"
|
||||||
@@ -26,6 +27,7 @@
|
|||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
|
"diff": "^9.0.0",
|
||||||
"drizzle-orm": "^0.44.7",
|
"drizzle-orm": "^0.44.7",
|
||||||
"ioredis": "^5.11.1",
|
"ioredis": "^5.11.1",
|
||||||
"lucide-react": "^1.21.0",
|
"lucide-react": "^1.21.0",
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 192 192" width="192" height="192">
|
||||||
|
<rect width="192" height="192" rx="36" fill="#18181b"/>
|
||||||
|
<circle cx="96" cy="96" r="34" fill="none" stroke="#fafafa" stroke-width="8"/>
|
||||||
|
<g fill="#fafafa">
|
||||||
|
<rect x="46" y="44" width="8" height="104" rx="4"/>
|
||||||
|
<rect x="138" y="44" width="8" height="104" rx="4"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 375 B |
@@ -0,0 +1,8 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
|
||||||
|
<rect width="512" height="512" rx="96" fill="#18181b"/>
|
||||||
|
<circle cx="256" cy="256" r="90" fill="none" stroke="#fafafa" stroke-width="20"/>
|
||||||
|
<g fill="#fafafa">
|
||||||
|
<rect x="122" y="118" width="20" height="276" rx="10"/>
|
||||||
|
<rect x="370" y="118" width="20" height="276" rx="10"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 385 B |
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"name": "Epicure",
|
||||||
|
"short_name": "Epicure",
|
||||||
|
"description": "Your personal AI-powered recipe book.",
|
||||||
|
"start_url": "/",
|
||||||
|
"display": "standalone",
|
||||||
|
"background_color": "#ffffff",
|
||||||
|
"theme_color": "#18181b",
|
||||||
|
"icons": [
|
||||||
|
{ "src": "/icon-192.svg", "sizes": "192x192", "type": "image/svg+xml", "purpose": "any" },
|
||||||
|
{ "src": "/icon-192.svg", "sizes": "192x192", "type": "image/svg+xml", "purpose": "maskable" },
|
||||||
|
{ "src": "/icon-512.svg", "sizes": "512x512", "type": "image/svg+xml", "purpose": "any" },
|
||||||
|
{ "src": "/icon-512.svg", "sizes": "512x512", "type": "image/svg+xml", "purpose": "maskable" }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -7,6 +7,9 @@
|
|||||||
"exports": {
|
"exports": {
|
||||||
".": "./src/index.ts"
|
".": "./src/index.ts"
|
||||||
},
|
},
|
||||||
|
"scripts": {
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"zod": "^3.25.67"
|
"zod": "^3.25.67"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -12,13 +12,15 @@
|
|||||||
"generate": "drizzle-kit generate",
|
"generate": "drizzle-kit generate",
|
||||||
"migrate": "drizzle-kit migrate",
|
"migrate": "drizzle-kit migrate",
|
||||||
"studio": "drizzle-kit studio",
|
"studio": "drizzle-kit studio",
|
||||||
"seed": "tsx src/seed.ts"
|
"seed": "tsx src/seed.ts",
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"drizzle-orm": "^0.44.7",
|
"drizzle-orm": "^0.44.7",
|
||||||
"postgres": "^3.4.7"
|
"postgres": "^3.4.7"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/node": "^20.19.43",
|
||||||
"drizzle-kit": "^0.31.1",
|
"drizzle-kit": "^0.31.1",
|
||||||
"tsx": "^4.20.3",
|
"tsx": "^4.20.3",
|
||||||
"typescript": "^5.8.3"
|
"typescript": "^5.8.3"
|
||||||
|
|||||||
@@ -2,4 +2,4 @@ export { db } from "./client";
|
|||||||
export type { Db } from "./client";
|
export type { Db } from "./client";
|
||||||
export * from "./schema";
|
export * from "./schema";
|
||||||
// Re-export query helpers so all callers use the same drizzle-orm instance
|
// Re-export query helpers so all callers use the same drizzle-orm instance
|
||||||
export { eq, and, or, desc, asc, gt, lt, gte, lte, ne, inArray, isNull, isNotNull, sql, count, avg, sum, min, max, ilike, like } from "drizzle-orm";
|
export { eq, and, or, desc, asc, gt, lt, gte, lte, ne, inArray, notInArray, isNull, isNotNull, sql, count, avg, sum, min, max, ilike, like } from "drizzle-orm";
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
CREATE INDEX "recipes_dietary_tags_gin" ON "recipes" USING gin ("dietary_tags");--> statement-breakpoint
|
||||||
|
CREATE INDEX "collection_members_user_idx" ON "collection_members" USING btree ("user_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "collections_user_idx" ON "collections" USING btree ("user_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "cooking_history_user_idx" ON "cooking_history" USING btree ("user_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "favorites_user_idx" ON "favorites" USING btree ("user_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "ratings_user_idx" ON "ratings" USING btree ("user_id");
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE "users" ADD COLUMN "stripe_customer_id" text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "users" ADD CONSTRAINT "users_stripe_customer_id_unique" UNIQUE("stripe_customer_id");
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
CREATE TABLE "meal_plan_members" (
|
||||||
|
"id" text PRIMARY KEY NOT NULL,
|
||||||
|
"meal_plan_id" text NOT NULL,
|
||||||
|
"user_id" text NOT NULL,
|
||||||
|
"role" "collection_member_role" DEFAULT 'viewer' NOT NULL,
|
||||||
|
"created_at" timestamp DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "shopping_list_members" (
|
||||||
|
"id" text PRIMARY KEY NOT NULL,
|
||||||
|
"list_id" text NOT NULL,
|
||||||
|
"user_id" text NOT NULL,
|
||||||
|
"role" "collection_member_role" DEFAULT 'viewer' NOT NULL,
|
||||||
|
"created_at" timestamp DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "meal_plan_members" ADD CONSTRAINT "meal_plan_members_meal_plan_id_meal_plans_id_fk" FOREIGN KEY ("meal_plan_id") REFERENCES "public"."meal_plans"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "meal_plan_members" ADD CONSTRAINT "meal_plan_members_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "shopping_list_members" ADD CONSTRAINT "shopping_list_members_list_id_shopping_lists_id_fk" FOREIGN KEY ("list_id") REFERENCES "public"."shopping_lists"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "shopping_list_members" ADD CONSTRAINT "shopping_list_members_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
CREATE INDEX "meal_plan_members_plan_idx" ON "meal_plan_members" USING btree ("meal_plan_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "meal_plan_members_user_idx" ON "meal_plan_members" USING btree ("user_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "shopping_list_members_list_idx" ON "shopping_list_members" USING btree ("list_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "shopping_list_members_user_idx" ON "shopping_list_members" USING btree ("user_id");
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -85,6 +85,27 @@
|
|||||||
"when": 1782896400709,
|
"when": 1782896400709,
|
||||||
"tag": "0011_premium_agent_zero",
|
"tag": "0011_premium_agent_zero",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 12,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1782977005184,
|
||||||
|
"tag": "0012_sloppy_tigra",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 13,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1782977520719,
|
||||||
|
"tag": "0013_damp_richard_fisk",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 14,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1782984288632,
|
||||||
|
"tag": "0014_late_marvex",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -7,11 +7,13 @@ import {
|
|||||||
date,
|
date,
|
||||||
decimal,
|
decimal,
|
||||||
pgEnum,
|
pgEnum,
|
||||||
|
index,
|
||||||
} from "drizzle-orm/pg-core";
|
} from "drizzle-orm/pg-core";
|
||||||
import { relations } from "drizzle-orm";
|
import { relations } from "drizzle-orm";
|
||||||
import { users } from "./users";
|
import { users } from "./users";
|
||||||
import { recipes } from "./recipes";
|
import { recipes } from "./recipes";
|
||||||
import { ingredients } from "./recipes";
|
import { ingredients } from "./recipes";
|
||||||
|
import { collectionMemberRoleEnum } from "./social";
|
||||||
|
|
||||||
export const mealTypeEnum = pgEnum("meal_type", ["breakfast", "lunch", "dinner", "snack"]);
|
export const mealTypeEnum = pgEnum("meal_type", ["breakfast", "lunch", "dinner", "snack"]);
|
||||||
export const weekdayEnum = pgEnum("weekday", ["mon", "tue", "wed", "thu", "fri", "sat", "sun"]);
|
export const weekdayEnum = pgEnum("weekday", ["mon", "tue", "wed", "thu", "fri", "sat", "sun"]);
|
||||||
@@ -63,9 +65,32 @@ export const shoppingListItems = pgTable("shopping_list_items", {
|
|||||||
checked: boolean("checked").notNull().default(false),
|
checked: boolean("checked").notNull().default(false),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const shoppingListMembers = pgTable("shopping_list_members", {
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
listId: text("list_id").notNull().references(() => shoppingLists.id, { onDelete: "cascade" }),
|
||||||
|
userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
|
||||||
|
role: collectionMemberRoleEnum("role").notNull().default("viewer"),
|
||||||
|
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||||
|
}, (t) => [
|
||||||
|
index("shopping_list_members_list_idx").on(t.listId),
|
||||||
|
index("shopping_list_members_user_idx").on(t.userId),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const mealPlanMembers = pgTable("meal_plan_members", {
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
mealPlanId: text("meal_plan_id").notNull().references(() => mealPlans.id, { onDelete: "cascade" }),
|
||||||
|
userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
|
||||||
|
role: collectionMemberRoleEnum("role").notNull().default("viewer"),
|
||||||
|
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||||
|
}, (t) => [
|
||||||
|
index("meal_plan_members_plan_idx").on(t.mealPlanId),
|
||||||
|
index("meal_plan_members_user_idx").on(t.userId),
|
||||||
|
]);
|
||||||
|
|
||||||
export const mealPlansRelations = relations(mealPlans, ({ one, many }) => ({
|
export const mealPlansRelations = relations(mealPlans, ({ one, many }) => ({
|
||||||
user: one(users, { fields: [mealPlans.userId], references: [users.id] }),
|
user: one(users, { fields: [mealPlans.userId], references: [users.id] }),
|
||||||
entries: many(mealPlanEntries),
|
entries: many(mealPlanEntries),
|
||||||
|
members: many(mealPlanMembers),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const mealPlanEntriesRelations = relations(mealPlanEntries, ({ one }) => ({
|
export const mealPlanEntriesRelations = relations(mealPlanEntries, ({ one }) => ({
|
||||||
@@ -73,11 +98,22 @@ export const mealPlanEntriesRelations = relations(mealPlanEntries, ({ one }) =>
|
|||||||
recipe: one(recipes, { fields: [mealPlanEntries.recipeId], references: [recipes.id] }),
|
recipe: one(recipes, { fields: [mealPlanEntries.recipeId], references: [recipes.id] }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
export const mealPlanMembersRelations = relations(mealPlanMembers, ({ one }) => ({
|
||||||
|
mealPlan: one(mealPlans, { fields: [mealPlanMembers.mealPlanId], references: [mealPlans.id] }),
|
||||||
|
user: one(users, { fields: [mealPlanMembers.userId], references: [users.id] }),
|
||||||
|
}));
|
||||||
|
|
||||||
export const shoppingListsRelations = relations(shoppingLists, ({ one, many }) => ({
|
export const shoppingListsRelations = relations(shoppingLists, ({ one, many }) => ({
|
||||||
user: one(users, { fields: [shoppingLists.userId], references: [users.id] }),
|
user: one(users, { fields: [shoppingLists.userId], references: [users.id] }),
|
||||||
items: many(shoppingListItems),
|
items: many(shoppingListItems),
|
||||||
|
members: many(shoppingListMembers),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const shoppingListItemsRelations = relations(shoppingListItems, ({ one }) => ({
|
export const shoppingListItemsRelations = relations(shoppingListItems, ({ one }) => ({
|
||||||
list: one(shoppingLists, { fields: [shoppingListItems.listId], references: [shoppingLists.id] }),
|
list: one(shoppingLists, { fields: [shoppingListItems.listId], references: [shoppingLists.id] }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
export const shoppingListMembersRelations = relations(shoppingListMembers, ({ one }) => ({
|
||||||
|
list: one(shoppingLists, { fields: [shoppingListMembers.listId], references: [shoppingLists.id] }),
|
||||||
|
user: one(users, { fields: [shoppingListMembers.userId], references: [users.id] }),
|
||||||
|
}));
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ export const recipes = pgTable("recipes", {
|
|||||||
}, (t) => [
|
}, (t) => [
|
||||||
index("recipes_author_idx").on(t.authorId),
|
index("recipes_author_idx").on(t.authorId),
|
||||||
index("recipes_visibility_idx").on(t.visibility),
|
index("recipes_visibility_idx").on(t.visibility),
|
||||||
|
index("recipes_dietary_tags_gin").using("gin", t.dietaryTags),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export const ingredients = pgTable("ingredients", {
|
export const ingredients = pgTable("ingredients", {
|
||||||
|
|||||||
@@ -25,13 +25,17 @@ export const ratings = pgTable("ratings", {
|
|||||||
reviewText: text("review_text"),
|
reviewText: text("review_text"),
|
||||||
createdAt: timestamp("created_at").notNull().defaultNow(),
|
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||||
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
||||||
});
|
}, (t) => [
|
||||||
|
index("ratings_user_idx").on(t.userId),
|
||||||
|
]);
|
||||||
|
|
||||||
export const favorites = pgTable("favorites", {
|
export const favorites = pgTable("favorites", {
|
||||||
userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
|
userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
|
||||||
recipeId: text("recipe_id").notNull().references(() => recipes.id, { onDelete: "cascade" }),
|
recipeId: text("recipe_id").notNull().references(() => recipes.id, { onDelete: "cascade" }),
|
||||||
createdAt: timestamp("created_at").notNull().defaultNow(),
|
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||||
});
|
}, (t) => [
|
||||||
|
index("favorites_user_idx").on(t.userId),
|
||||||
|
]);
|
||||||
|
|
||||||
export const comments = pgTable("comments", {
|
export const comments = pgTable("comments", {
|
||||||
id: text("id").primaryKey(),
|
id: text("id").primaryKey(),
|
||||||
@@ -53,7 +57,9 @@ export const collections = pgTable("collections", {
|
|||||||
isPublic: boolean("is_public").notNull().default(false),
|
isPublic: boolean("is_public").notNull().default(false),
|
||||||
createdAt: timestamp("created_at").notNull().defaultNow(),
|
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||||
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
||||||
});
|
}, (t) => [
|
||||||
|
index("collections_user_idx").on(t.userId),
|
||||||
|
]);
|
||||||
|
|
||||||
export const collectionRecipes = pgTable("collection_recipes", {
|
export const collectionRecipes = pgTable("collection_recipes", {
|
||||||
collectionId: text("collection_id").notNull().references(() => collections.id, { onDelete: "cascade" }),
|
collectionId: text("collection_id").notNull().references(() => collections.id, { onDelete: "cascade" }),
|
||||||
@@ -68,7 +74,9 @@ export const cookingHistory = pgTable("cooking_history", {
|
|||||||
cookedAt: timestamp("cooked_at").notNull().defaultNow(),
|
cookedAt: timestamp("cooked_at").notNull().defaultNow(),
|
||||||
servings: integer("servings"),
|
servings: integer("servings"),
|
||||||
notes: text("notes"),
|
notes: text("notes"),
|
||||||
});
|
}, (t) => [
|
||||||
|
index("cooking_history_user_idx").on(t.userId),
|
||||||
|
]);
|
||||||
|
|
||||||
export const feedItems = pgTable("feed_items", {
|
export const feedItems = pgTable("feed_items", {
|
||||||
id: text("id").primaryKey(),
|
id: text("id").primaryKey(),
|
||||||
@@ -104,7 +112,9 @@ export const collectionMembers = pgTable("collection_members", {
|
|||||||
userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
|
userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
|
||||||
role: collectionMemberRoleEnum("role").notNull().default("viewer"),
|
role: collectionMemberRoleEnum("role").notNull().default("viewer"),
|
||||||
createdAt: timestamp("created_at").notNull().defaultNow(),
|
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||||
});
|
}, (t) => [
|
||||||
|
index("collection_members_user_idx").on(t.userId),
|
||||||
|
]);
|
||||||
|
|
||||||
export const commentReactionTypeEnum = pgEnum("comment_reaction_type", ["like", "love", "laugh", "wow", "sad", "fire"]);
|
export const commentReactionTypeEnum = pgEnum("comment_reaction_type", ["like", "love", "laugh", "wow", "sad", "fire"]);
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export const users = pgTable("users", {
|
|||||||
username: text("username").unique(),
|
username: text("username").unique(),
|
||||||
role: userRoleEnum("role").notNull().default("user"),
|
role: userRoleEnum("role").notNull().default("user"),
|
||||||
tier: tierEnum("tier").notNull().default("free"),
|
tier: tierEnum("tier").notNull().default("free"),
|
||||||
|
stripeCustomerId: text("stripe_customer_id").unique(),
|
||||||
unitPref: unitPrefEnum("unit_pref").notNull().default("metric"),
|
unitPref: unitPrefEnum("unit_pref").notNull().default("metric"),
|
||||||
locale: text("locale").notNull().default("en"),
|
locale: text("locale").notNull().default("en"),
|
||||||
createdAt: timestamp("created_at").notNull().defaultNow(),
|
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
"extends": "../../tsconfig.base.json",
|
"extends": "../../tsconfig.base.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"outDir": "./dist",
|
"outDir": "./dist",
|
||||||
"rootDir": "./src"
|
"rootDir": "./src",
|
||||||
|
"types": ["node"]
|
||||||
},
|
},
|
||||||
"include": ["src"]
|
"include": ["src"]
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+12
@@ -56,6 +56,9 @@ importers:
|
|||||||
cmdk:
|
cmdk:
|
||||||
specifier: ^1.1.1
|
specifier: ^1.1.1
|
||||||
version: 1.1.1(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
version: 1.1.1(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||||
|
diff:
|
||||||
|
specifier: ^9.0.0
|
||||||
|
version: 9.0.0
|
||||||
drizzle-orm:
|
drizzle-orm:
|
||||||
specifier: ^0.44.7
|
specifier: ^0.44.7
|
||||||
version: 0.44.7(@opentelemetry/api@1.9.1)(kysely@0.29.2)(postgres@3.4.9)
|
version: 0.44.7(@opentelemetry/api@1.9.1)(kysely@0.29.2)(postgres@3.4.9)
|
||||||
@@ -176,6 +179,9 @@ importers:
|
|||||||
specifier: ^3.4.7
|
specifier: ^3.4.7
|
||||||
version: 3.4.9
|
version: 3.4.9
|
||||||
devDependencies:
|
devDependencies:
|
||||||
|
'@types/node':
|
||||||
|
specifier: ^20.19.43
|
||||||
|
version: 20.19.43
|
||||||
drizzle-kit:
|
drizzle-kit:
|
||||||
specifier: ^0.31.1
|
specifier: ^0.31.1
|
||||||
version: 0.31.10
|
version: 0.31.10
|
||||||
@@ -2982,6 +2988,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==}
|
resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==}
|
||||||
engines: {node: '>=0.3.1'}
|
engines: {node: '>=0.3.1'}
|
||||||
|
|
||||||
|
diff@9.0.0:
|
||||||
|
resolution: {integrity: sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw==}
|
||||||
|
engines: {node: '>=0.3.1'}
|
||||||
|
|
||||||
doctrine@2.1.0:
|
doctrine@2.1.0:
|
||||||
resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==}
|
resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
@@ -7865,6 +7875,8 @@ snapshots:
|
|||||||
|
|
||||||
diff@8.0.4: {}
|
diff@8.0.4: {}
|
||||||
|
|
||||||
|
diff@9.0.0: {}
|
||||||
|
|
||||||
doctrine@2.1.0:
|
doctrine@2.1.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
esutils: 2.0.3
|
esutils: 2.0.3
|
||||||
|
|||||||
Reference in New Issue
Block a user