feat: push+email notifications, recipe notes, fork/clone, pantry-aware lists, GDPR export

Five S-sized items from HANDOFF.md's new-features backlog, all wiring up
previously-orphaned infra:

- createNotification now sends web push + email for every notification type
  (follow/comment/reply/reaction/rating/mention), not just comments
- Personal recipe notes: private per-user notes on any viewable recipe
  (recipeNotes table had zero API/UI before this)
- Recipe fork/clone: deep-copies a viewable recipe into your own library as
  a private draft, linked via recipeVariations, respects tier quota
- Pantry-aware shopping lists: meal-plan-generated lists now subtract
  on-hand pantry quantities (ingredientId match, falling back to normalized
  name match) and flag partial/ambiguous matches instead of guessing
- GDPR data export: downloadable JSON of a user's own content and activity
  across every relevant table, secrets/internal tables excluded

New migrations 0025 (unique index for recipe-notes upsert) and 0026
(shopping_list_items.in_pantry) generated, left unapplied like 0023/0024.
Verified with typecheck, lint, and a full local `docker build`.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Arnaud
2026-07-10 07:50:58 +02:00
parent d035378520
commit 45b886e398
23 changed files with 9813 additions and 23 deletions
+24 -2
View File
@@ -14,10 +14,11 @@ import { PrintButton } from "@/components/recipe/print-button";
import { ShareRecipeButton } from "@/components/recipe/share-recipe-button";
import { VersionHistoryButton } from "@/components/recipe/version-history-button";
import { DeleteRecipeButton } from "@/components/recipe/delete-recipe-button";
import { ForkRecipeButton } from "@/components/recipe/fork-recipe-button";
import { NutritionPanel } from "@/components/recipe/nutrition-panel";
import { GenerateContentButton } from "@/components/recipe/generate-content-button";
import { auth } from "@/lib/auth/server";
import { db, recipes, ratings, favorites, avg } from "@epicure/db";
import { db, recipes, ratings, favorites, recipeVariations, recipeNotes, avg } from "@epicure/db";
import { and, eq, or, count, inArray } from "@epicure/db";
import { Badge } from "@/components/ui/badge";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
@@ -28,6 +29,7 @@ import { ServingScaler } from "@/components/recipe/serving-scaler";
import { FavoriteButton } from "@/components/social/favorite-button";
import { RatingStars } from "@/components/social/rating-stars";
import { CookedItReview } from "@/components/social/cooked-it-review";
import { RecipeNotes } from "@/components/recipe/recipe-notes";
import { CommentsSection } from "@/components/social/comments-section";
import { getPublicUrl } from "@/lib/storage";
import { cn } from "@/lib/utils";
@@ -57,7 +59,7 @@ export default async function RecipePage({ params }: Params) {
const VISIBILITY_LABEL = m.recipe.visibility;
const DIETARY_LABELS = m.recipe.dietary;
const [recipe, ratingData, favoriteData, myRating] = await Promise.all([
const [recipe, ratingData, favoriteData, myRating, forkedFrom, myNote] = await Promise.all([
db.query.recipes.findFirst({
where: and(
eq(recipes.id, id),
@@ -73,6 +75,14 @@ export default async function RecipePage({ params }: Params) {
db.select({ avgScore: avg(ratings.score), total: count() }).from(ratings).where(eq(ratings.recipeId, id)),
db.query.favorites.findFirst({ where: and(eq(favorites.userId, session.user.id), eq(favorites.recipeId, id)) }),
db.query.ratings.findFirst({ where: and(eq(ratings.recipeId, id), eq(ratings.userId, session.user.id)) }),
db.query.recipeVariations.findFirst({
where: eq(recipeVariations.childRecipeId, id),
with: { parent: { columns: { id: true, title: true } } },
}),
db.query.recipeNotes.findFirst({
where: and(eq(recipeNotes.recipeId, id), eq(recipeNotes.userId, session.user.id)),
columns: { content: true },
}),
]);
if (!recipe) notFound();
@@ -111,6 +121,14 @@ export default async function RecipePage({ params }: Params) {
{formatMessage(m.recipe.byAuthor, { name: recipe.author.name })}
</Link>
)}
{forkedFrom && (
<Link
href={`/recipes/${forkedFrom.parent.id}`}
className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground"
>
{formatMessage(m.recipe.forkedFrom, { title: forkedFrom.parent.title })}
</Link>
)}
<TooltipProvider>
<div className="flex items-center gap-2 overflow-x-auto pb-0.5 [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden">
{recipe.steps.length > 0 && (
@@ -176,6 +194,7 @@ export default async function RecipePage({ params }: Params) {
order: s.order,
}))}
/>
{!isOwner && <ForkRecipeButton recipeId={id} />}
<ShareRecipeButton recipeId={id} visibility={recipe.visibility} />
<PrintButton recipeId={id} />
<ExportMarkdownButton
@@ -410,6 +429,9 @@ export default async function RecipePage({ params }: Params) {
</>
)}
<Separator />
<RecipeNotes recipeId={id} initialContent={myNote?.content ?? ""} />
<RecipeChatPanel recipeId={id} recipeTitle={recipe.title} />
</div>
);
@@ -4,7 +4,6 @@ import { db, recipes, comments, users, userBlocks, eq, and, inArray, isNull, sql
import { requireSession } from "@/lib/api-auth";
import { applyRateLimit } from "@/lib/rate-limit";
import { dispatchWebhook } from "@/lib/webhooks";
import { sendPushNotification } from "@/lib/push";
import { createNotification } from "@/lib/notifications";
import { isBlockedEitherWay } from "@/lib/blocks";
import { extractMentionedUsernames } from "@/lib/mentions";
@@ -126,14 +125,10 @@ export async function POST(req: NextRequest, { params }: Params) {
content: parsed.data.content,
});
// Dispatch to recipe author's webhooks (not to the commenter themselves)
// Dispatch to recipe author's webhooks (not to the commenter themselves).
// Push/email to the author are handled by createNotification's "comment" call below.
if (recipe.authorId !== session!.user.id) {
void dispatchWebhook(recipe.authorId, "comment.added", { commentId, recipeId: id, recipeTitle: recipe.title });
void sendPushNotification(recipe.authorId, {
title: "New comment on your recipe",
body: `${session!.user.name} commented on "${recipe.title}"`,
url: `/recipes/${id}`,
});
}
if (parent && parent.userId !== session!.user.id) {
@@ -0,0 +1,94 @@
import { NextRequest, NextResponse } from "next/server";
import { db, recipes, recipeIngredients, recipeSteps, recipeVariations } from "@epicure/db";
import { eq, and, or, inArray } from "@epicure/db";
import { requireSessionOrApiKey } from "@/lib/api-auth";
import { checkAndIncrementTierLimit, TierLimitError } from "@/lib/tiers";
type Params = { params: Promise<{ id: string }> };
export async function POST(req: NextRequest, { params }: Params) {
const { session, response } = await requireSessionOrApiKey(req, { rateLimit: { limit: 20, windowSeconds: 60 } });
if (response) return response;
const { id } = await params;
const source = await db.query.recipes.findFirst({
where: and(
eq(recipes.id, id),
or(eq(recipes.authorId, session!.user.id), inArray(recipes.visibility, ["public", "unlisted"]))
),
with: {
ingredients: { orderBy: (t, { asc }) => asc(t.order) },
steps: { orderBy: (t, { asc }) => asc(t.order) },
},
});
if (!source) return NextResponse.json({ error: "Not found" }, { status: 404 });
try {
await checkAndIncrementTierLimit(session!.user.id, session!.user.tier as "free" | "pro", "recipe");
} catch (err) {
if (err instanceof TierLimitError) {
return NextResponse.json({ error: "Recipe limit reached for your tier" }, { status: 403 });
}
throw err;
}
const newId = crypto.randomUUID();
const now = new Date();
await db.transaction(async (tx) => {
await tx.insert(recipes).values({
id: newId,
authorId: session!.user.id,
title: source.title,
description: source.description,
baseServings: source.baseServings,
visibility: "private",
difficulty: source.difficulty,
prepMins: source.prepMins,
cookMins: source.cookMins,
tags: source.tags,
dietaryTags: source.dietaryTags ?? {},
aiGenerated: false,
language: source.language,
createdAt: now,
updatedAt: now,
});
if (source.ingredients.length > 0) {
await tx.insert(recipeIngredients).values(
source.ingredients.map((ing) => ({
id: crypto.randomUUID(),
recipeId: newId,
rawName: ing.rawName,
quantity: ing.quantity,
unit: ing.unit,
note: ing.note,
order: ing.order,
}))
);
}
if (source.steps.length > 0) {
await tx.insert(recipeSteps).values(
source.steps.map((step) => ({
id: crypto.randomUUID(),
recipeId: newId,
instruction: step.instruction,
timerSeconds: step.timerSeconds,
order: step.order,
}))
);
}
await tx.insert(recipeVariations).values({
id: crypto.randomUUID(),
parentRecipeId: source.id,
childRecipeId: newId,
description: null,
aiGenerated: false,
createdAt: now,
});
});
return NextResponse.json({ id: newId }, { status: 201 });
}
@@ -0,0 +1,76 @@
import { NextRequest, NextResponse } from "next/server";
import { db, recipes, recipeNotes, eq, and, or, inArray } from "@epicure/db";
import { z } from "zod";
import { requireSessionOrApiKey } from "@/lib/api-auth";
type Params = { params: Promise<{ id: string }> };
const PutSchema = z.object({
content: z.string().max(5000),
});
async function assertRecipeAccessible(recipeId: string, userId: string) {
const recipe = await db.query.recipes.findFirst({
where: and(
eq(recipes.id, recipeId),
or(eq(recipes.authorId, userId), inArray(recipes.visibility, ["public", "unlisted"]))
),
columns: { id: true },
});
return recipe;
}
export async function GET(req: NextRequest, { params }: Params) {
const { session, response } = await requireSessionOrApiKey(req, { rateLimit: { limit: 60, windowSeconds: 60 } });
if (response) return response;
const { id } = await params;
const recipe = await assertRecipeAccessible(id, session!.user.id);
if (!recipe) return NextResponse.json({ error: "Not found" }, { status: 404 });
const note = await db.query.recipeNotes.findFirst({
where: and(eq(recipeNotes.recipeId, id), eq(recipeNotes.userId, session!.user.id)),
columns: { content: true, updatedAt: true },
});
return NextResponse.json({ note: note ?? null });
}
export async function PUT(req: NextRequest, { params }: Params) {
const { session, response } = await requireSessionOrApiKey(req, { rateLimit: { limit: 60, windowSeconds: 60 } });
if (response) return response;
const { id } = await params;
const recipe = await assertRecipeAccessible(id, session!.user.id);
if (!recipe) return NextResponse.json({ error: "Not found" }, { status: 404 });
const parsed = PutSchema.safeParse(await req.json());
if (!parsed.success) {
return NextResponse.json({ error: "Validation error", issues: parsed.error.issues }, { status: 400 });
}
const content = parsed.data.content.trim();
const userId = session!.user.id;
if (content.length === 0) {
await db
.delete(recipeNotes)
.where(and(eq(recipeNotes.recipeId, id), eq(recipeNotes.userId, userId)));
return NextResponse.json({ note: null });
}
await db
.insert(recipeNotes)
.values({ id: crypto.randomUUID(), recipeId: id, userId, content, updatedAt: new Date() })
.onConflictDoUpdate({
target: [recipeNotes.recipeId, recipeNotes.userId],
set: { content, updatedAt: new Date() },
});
const note = await db.query.recipeNotes.findFirst({
where: and(eq(recipeNotes.recipeId, id), eq(recipeNotes.userId, userId)),
columns: { content: true, updatedAt: true },
});
return NextResponse.json({ note });
}
@@ -53,6 +53,6 @@ export async function POST(req: NextRequest, { params }: Params) {
reviewText: parsed.data.reviewText,
photoKey: parsed.data.photoKey,
});
void createNotification({ userId: recipe.authorId, type: "rating", actorId: session!.user.id, recipeId: id });
void createNotification({ userId: recipe.authorId, type: "rating", actorId: session!.user.id, recipeId: id, score: parsed.data.score });
return NextResponse.json({ created: true }, { status: 201 });
}
+19 -6
View File
@@ -1,7 +1,8 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { db, shoppingLists, shoppingListItems, mealPlans, mealPlanEntries, recipeIngredients, eq, and, desc, inArray } from "@epicure/db";
import { db, shoppingLists, shoppingListItems, mealPlans, mealPlanEntries, recipeIngredients, pantryItems, eq, and, desc, inArray } from "@epicure/db";
import { requireSession } from "@/lib/api-auth";
import { applyPantryToItems } from "@/lib/pantry-shopping-match";
const CreateSchema = z.object({
name: z.string().min(1).max(100),
@@ -38,7 +39,7 @@ export async function POST(req: NextRequest) {
const data = parsed.data;
const listId = crypto.randomUUID();
let items: Array<{ rawName: string; quantity?: string; unit?: string; aisle?: string }> = data.items ?? [];
let items: Array<{ rawName: string; quantity?: string; unit?: string; aisle?: string; inPantry?: boolean }> = data.items ?? [];
if (data.fromMealPlanWeek) {
const plan = await db.query.mealPlans.findFirst({
@@ -54,14 +55,25 @@ export async function POST(req: NextRequest) {
});
// Simple merge by rawName (case-insensitive)
const merged = new Map<string, { rawName: string; quantity?: string; unit?: string }>();
const merged = new Map<string, { rawName: string; quantity?: string; unit?: string; ingredientId: string | null }>();
for (const ing of ings) {
const key = ing.rawName.toLowerCase();
if (!merged.has(key)) {
merged.set(key, { rawName: ing.rawName, quantity: ing.quantity ?? undefined, unit: ing.unit ?? undefined });
merged.set(key, {
rawName: ing.rawName,
quantity: ing.quantity ?? undefined,
unit: ing.unit ?? undefined,
ingredientId: ing.ingredientId,
});
}
}
items = Array.from(merged.values());
const mergedItems = Array.from(merged.values());
// Reduce/flag quantities already covered by the user's pantry. Conservative: never silently
// drops an item — fully-covered items are still inserted, flagged `inPantry`, so nothing
// disappears from view without the user seeing it.
const pantry = await db.query.pantryItems.findMany({ where: eq(pantryItems.userId, session!.user.id) });
items = applyPantryToItems(mergedItems, pantry);
}
}
}
@@ -82,7 +94,8 @@ export async function POST(req: NextRequest) {
quantity: item.quantity,
unit: item.unit,
aisle: item.aisle,
checked: false,
checked: item.inPantry === true && !item.quantity,
inPantry: item.inPantry ?? false,
}))
);
}
@@ -0,0 +1,239 @@
import { NextResponse } from "next/server";
import {
db,
eq,
or,
users,
recipes,
recipeIngredients,
recipeSteps,
recipePhotos,
recipeNotes,
ratings,
favorites,
comments,
collections,
collectionRecipes,
collectionFavorites,
cookingHistory,
notifications,
userFollows,
mealPlans,
mealPlanEntries,
pantryItems,
shoppingLists,
shoppingListItems,
webhooks,
apiKeys,
pushSubscriptions,
userNutritionGoals,
userAllergens,
userModelPrefs,
userUsage,
messages,
conversations,
} from "@epicure/db";
import { requireSession } from "@/lib/api-auth";
import { applyRateLimit } from "@/lib/rate-limit";
export async function GET() {
const { session, response } = await requireSession();
if (response) return response;
const userId = session!.user.id;
const limited = await applyRateLimit(`rl:export:${userId}`, 3, 3600);
if (limited) return limited;
const [
profileRows,
userRecipes,
userRatings,
userComments,
userNotes,
userFavorites,
following,
followers,
userCollections,
userCookingHistory,
userNotifications,
userMealPlans,
userPantryItems,
userShoppingLists,
userWebhooks,
userApiKeys,
userPushSubscriptions,
userNutritionGoalsRows,
userAllergenRows,
userMessages,
userModelPrefsRows,
userUsageRows,
] = await Promise.all([
db
.select({
id: users.id,
email: users.email,
name: users.name,
username: users.username,
bio: users.bio,
privateBio: users.privateBio,
avatarUrl: users.avatarUrl,
role: users.role,
tier: users.tier,
unitPref: users.unitPref,
locale: users.locale,
createdAt: users.createdAt,
updatedAt: users.updatedAt,
})
.from(users)
.where(eq(users.id, userId))
.limit(1),
db.select().from(recipes).where(eq(recipes.authorId, userId)),
db.select().from(ratings).where(eq(ratings.userId, userId)),
db.select().from(comments).where(eq(comments.userId, userId)),
db.select().from(recipeNotes).where(eq(recipeNotes.userId, userId)),
db.select().from(favorites).where(eq(favorites.userId, userId)),
db
.select({ followingId: userFollows.followingId, createdAt: userFollows.createdAt })
.from(userFollows)
.where(eq(userFollows.followerId, userId)),
db
.select({ followerId: userFollows.followerId, createdAt: userFollows.createdAt })
.from(userFollows)
.where(eq(userFollows.followingId, userId)),
db.select().from(collections).where(eq(collections.userId, userId)),
db.select().from(cookingHistory).where(eq(cookingHistory.userId, userId)),
db.select().from(notifications).where(eq(notifications.userId, userId)),
db.select().from(mealPlans).where(eq(mealPlans.userId, userId)),
db.select().from(pantryItems).where(eq(pantryItems.userId, userId)),
db.select().from(shoppingLists).where(eq(shoppingLists.userId, userId)),
db
.select({
id: webhooks.id,
url: webhooks.url,
events: webhooks.events,
active: webhooks.active,
createdAt: webhooks.createdAt,
})
.from(webhooks)
.where(eq(webhooks.userId, userId)),
db
.select({
id: apiKeys.id,
name: apiKeys.name,
lastUsedAt: apiKeys.lastUsedAt,
createdAt: apiKeys.createdAt,
})
.from(apiKeys)
.where(eq(apiKeys.userId, userId)),
db
.select({ id: pushSubscriptions.id, endpoint: pushSubscriptions.endpoint, createdAt: pushSubscriptions.createdAt })
.from(pushSubscriptions)
.where(eq(pushSubscriptions.userId, userId)),
db.select().from(userNutritionGoals).where(eq(userNutritionGoals.userId, userId)),
db.select().from(userAllergens).where(eq(userAllergens.userId, userId)),
db
.select({
id: messages.id,
conversationId: messages.conversationId,
content: messages.content,
createdAt: messages.createdAt,
})
.from(messages)
.where(eq(messages.senderId, userId)),
db.select().from(userModelPrefs).where(eq(userModelPrefs.userId, userId)),
db.select().from(userUsage).where(eq(userUsage.userId, userId)),
]);
const profile = profileRows[0] ?? null;
// Recipe sub-data (ingredients/steps/photos) scoped to this user's own recipes.
const recipeIds = userRecipes.map((r) => r.id);
const [ingredientsRows, stepsRows, photosRows] = recipeIds.length
? await Promise.all([
db.select().from(recipeIngredients).where(or(...recipeIds.map((id) => eq(recipeIngredients.recipeId, id)))),
db.select().from(recipeSteps).where(or(...recipeIds.map((id) => eq(recipeSteps.recipeId, id)))),
db.select().from(recipePhotos).where(or(...recipeIds.map((id) => eq(recipePhotos.recipeId, id)))),
])
: [[], [], []];
const mealPlanIds = userMealPlans.map((p) => p.id);
const mealPlanEntriesRows = mealPlanIds.length
? await db.select().from(mealPlanEntries).where(or(...mealPlanIds.map((id) => eq(mealPlanEntries.mealPlanId, id))))
: [];
const shoppingListIds = userShoppingLists.map((l) => l.id);
const shoppingListItemsRows = shoppingListIds.length
? await db.select().from(shoppingListItems).where(or(...shoppingListIds.map((id) => eq(shoppingListItems.listId, id))))
: [];
const collectionIds = userCollections.map((c) => c.id);
const [collectionRecipesRows, collectionFavoritesRows] = collectionIds.length
? await Promise.all([
db.select().from(collectionRecipes).where(or(...collectionIds.map((id) => eq(collectionRecipes.collectionId, id)))),
db.select().from(collectionFavorites).where(eq(collectionFavorites.userId, userId)),
])
: [[], await db.select().from(collectionFavorites).where(eq(collectionFavorites.userId, userId))];
const conversationIds = [...new Set(userMessages.map((m) => m.conversationId))];
const conversationRows = conversationIds.length
? await db.select().from(conversations).where(or(...conversationIds.map((id) => eq(conversations.id, id))))
: [];
const exportData = {
exportedAt: new Date().toISOString(),
profile,
recipes: userRecipes.map((r) => ({
...r,
ingredients: ingredientsRows.filter((i) => i.recipeId === r.id),
steps: stepsRows.filter((s) => s.recipeId === r.id),
photos: photosRows.filter((p) => p.recipeId === r.id),
})),
ratings: userRatings,
comments: userComments,
recipeNotes: userNotes,
favorites: userFavorites,
social: {
following,
followers,
},
collections: userCollections.map((c) => ({
...c,
recipeIds: collectionRecipesRows.filter((cr) => cr.collectionId === c.id).map((cr) => cr.recipeId),
})),
favoriteCollections: collectionFavoritesRows,
cookingHistory: userCookingHistory,
notifications: userNotifications,
mealPlans: userMealPlans.map((p) => ({
...p,
entries: mealPlanEntriesRows.filter((e) => e.mealPlanId === p.id),
})),
pantryItems: userPantryItems,
shoppingLists: userShoppingLists.map((l) => ({
...l,
items: shoppingListItemsRows.filter((i) => i.listId === l.id),
})),
webhooks: userWebhooks,
apiKeys: userApiKeys,
pushSubscriptions: userPushSubscriptions,
nutritionGoals: userNutritionGoalsRows,
allergens: userAllergenRows,
modelPreferences: userModelPrefsRows,
usage: userUsageRows,
messages: userMessages.map((m) => {
const conversation = conversationRows.find((c) => c.id === m.conversationId);
const otherUserId = conversation ? (conversation.userAId === userId ? conversation.userBId : conversation.userAId) : null;
return { ...m, otherUserId };
}),
};
const dateStr = new Date().toISOString().slice(0, 10);
return new NextResponse(JSON.stringify(exportData, null, 2), {
status: 200,
headers: {
"Content-Type": "application/json",
"Content-Disposition": `attachment; filename="epicure-data-export-${dateStr}.json"`,
},
});
}
@@ -15,6 +15,7 @@ type Item = {
unit: string | null;
aisle: string | null;
checked: boolean;
inPantry?: boolean;
};
export function ShoppingListView({
@@ -119,6 +120,11 @@ export function ShoppingListView({
<span className={cn("flex-1 text-sm", item.checked && "line-through text-muted-foreground")}>
{item.rawName}
</span>
{item.inPantry && (
<span className="text-[10px] font-medium uppercase tracking-wide text-emerald-600 dark:text-emerald-400 bg-emerald-500/10 rounded px-1.5 py-0.5 shrink-0">
{tShopping("alreadyInPantry")}
</span>
)}
{(hasQuantity(item.quantity) || item.unit) && (
<span className={cn("text-xs text-muted-foreground tabular-nums shrink-0", item.checked && "opacity-50")}>
{hasQuantity(item.quantity) ? item.quantity : ""}{item.unit ? ` ${item.unit}` : ""}
@@ -0,0 +1,47 @@
"use client";
import { useState } from "react";
import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation";
import { GitFork, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { toast } from "sonner";
export function ForkRecipeButton({ recipeId }: { recipeId: string }) {
const t = useTranslations("recipe");
const router = useRouter();
const [forking, setForking] = useState(false);
async function handleFork() {
setForking(true);
try {
const res = await fetch(`/api/v1/recipes/${recipeId}/fork`, { method: "POST" });
if (!res.ok) {
const data = await res.json() as { error?: string };
throw new Error(
res.status === 403 ? t("forkLimitReached") : (data.error ?? t("forkFailed"))
);
}
const data = await res.json() as { id: string };
toast.success(t("forked"));
router.push(`/recipes/${data.id}`);
} catch (err) {
toast.error(err instanceof Error ? err.message : t("forkFailed"));
setForking(false);
}
}
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger render={
<Button variant="ghost" size="icon" onClick={() => { void handleFork(); }} disabled={forking} aria-label={t("forkTooltip")}>
{forking ? <Loader2 className="h-4 w-4 animate-spin" /> : <GitFork className="h-4 w-4" />}
</Button>
} />
<TooltipContent>{t("forkTooltip")}</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
@@ -0,0 +1,68 @@
"use client";
import { useState } from "react";
import { useTranslations } from "next-intl";
import { Lock } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
export function RecipeNotes({
recipeId,
initialContent,
}: {
recipeId: string;
initialContent: string;
}) {
const t = useTranslations("recipeNotes");
const [content, setContent] = useState(initialContent);
const [savedContent, setSavedContent] = useState(initialContent);
const [saving, setSaving] = useState(false);
const dirty = content !== savedContent;
async function save() {
setSaving(true);
try {
const res = await fetch(`/api/v1/recipes/${recipeId}/notes`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ content }),
});
if (!res.ok) {
toast.error(t("saveFailed"));
return;
}
const data = (await res.json()) as { note: { content: string } | null };
const newContent = data.note?.content ?? "";
setContent(newContent);
setSavedContent(newContent);
toast.success(t("saved"));
} finally {
setSaving(false);
}
}
return (
<div className="rounded-lg border border-dashed p-4 space-y-3">
<div className="flex items-center gap-2 text-sm font-medium">
<Lock className="h-4 w-4 text-muted-foreground" />
{t("title")}
</div>
<p className="text-xs text-muted-foreground">{t("visibilityHint")}</p>
<Textarea
value={content}
onChange={(e) => setContent(e.target.value)}
onBlur={() => dirty && !saving && save()}
placeholder={t("placeholder")}
maxLength={5000}
rows={3}
/>
<div className="flex justify-end">
<Button type="button" size="sm" variant="outline" disabled={!dirty || saving} onClick={save}>
{saving ? t("saving") : t("save")}
</Button>
</div>
</div>
);
}
+15 -1
View File
@@ -2,7 +2,7 @@
import { useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Button, buttonVariants } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useTranslations } from "next-intl";
@@ -138,6 +138,20 @@ export function SecurityForm({ currentEmail }: { currentEmail: string }) {
</Button>
</form>
</section>
<section className="rounded-xl border p-6 space-y-4">
<div>
<h2 className="font-semibold text-lg">{t("downloadData")}</h2>
<p className="text-sm text-muted-foreground mt-1">{t("downloadDataDescription")}</p>
</div>
<a
href="/api/v1/users/me/export"
download
className={buttonVariants({ variant: "outline" })}
>
{t("downloadDataButton")}
</a>
</section>
</div>
);
}
+9
View File
@@ -95,6 +95,15 @@ export function resetPasswordHtml(url: string) {
);
}
export function notificationEmailHtml(title: string, body: string, url: string) {
return layout(
`${title} — Epicure`,
`<h1 style="margin:0 0 8px;font-size:24px;">${title}</h1>
<p style="margin:0 0 4px;color:#57534e;line-height:1.6;">${body}</p>
${btn(url, "View on Epicure")}`
);
}
export function welcomeHtml(name: string) {
return layout(
"Welcome to Epicure",
+73 -3
View File
@@ -1,16 +1,24 @@
import { db, notifications } from "@epicure/db";
import { db, notifications, users, recipes, eq } from "@epicure/db";
import { randomUUID } from "crypto";
import { sendPushNotification } from "./push";
import { sendEmail, notificationEmailHtml } from "./email";
import { getMessages, formatMessage } from "./i18n/server";
type NotificationType = "follow" | "comment" | "reply" | "reaction" | "rating" | "mention";
export async function createNotification(opts: {
type CreateNotificationOpts = {
userId: string;
type: NotificationType;
actorId: string;
recipeId?: string;
commentId?: string;
}): Promise<void> {
/** Rating score (1-5), only relevant for type "rating" — included in the push/email copy. */
score?: number;
};
export async function createNotification(opts: CreateNotificationOpts): Promise<void> {
if (opts.userId === opts.actorId) return; // never notify yourself
await db.insert(notifications).values({
id: randomUUID(),
userId: opts.userId,
@@ -19,4 +27,66 @@ export async function createNotification(opts: {
recipeId: opts.recipeId,
commentId: opts.commentId,
});
// Push + email are best-effort side effects — never let a slow/failing SMTP
// or push provider delay or break the caller (which already does `void
// createNotification(...)`). Fire-and-forget from here too.
void dispatchAlerts(opts).catch((err) => {
console.error("[notifications] failed to dispatch push/email", err);
});
}
async function dispatchAlerts(opts: CreateNotificationOpts): Promise<void> {
const [actor, recipient, recipe] = await Promise.all([
db.query.users.findFirst({
where: eq(users.id, opts.actorId),
columns: { name: true, username: true },
}),
db.query.users.findFirst({
where: eq(users.id, opts.userId),
columns: { email: true, locale: true },
}),
opts.recipeId
? db.query.recipes.findFirst({
where: eq(recipes.id, opts.recipeId),
columns: { title: true },
})
: Promise.resolve(undefined),
]);
if (!actor || !recipient) return;
const messages = getMessages(recipient.locale);
const n = messages.notifications as Record<string, unknown>;
const detail = (n["detail"] ?? {}) as Record<string, string>;
const pushTitle = (n["pushTitle"] ?? {}) as Record<string, string>;
const template = detail[opts.type] ?? (n[opts.type] as string | undefined);
if (!template) return;
const body = formatMessage(template, {
name: actor.name,
title: recipe?.title ?? "",
stars: opts.score != null ? String(opts.score) : "",
});
const title = pushTitle[opts.type] ?? "Epicure";
const url = opts.type === "follow"
? (actor.username ? `/u/${actor.username}` : "/")
: opts.recipeId ? `/recipes/${opts.recipeId}` : "/";
void sendPushNotification(opts.userId, { title, body, url }).catch((err) => {
console.error("[notifications] push failed", err);
});
if (recipient.email) {
const baseUrl = process.env["BETTER_AUTH_URL"] ?? "http://localhost:3000";
void sendEmail({
to: recipient.email,
subject: title,
html: notificationEmailHtml(title, body, `${baseUrl}${url}`),
}).catch((err) => {
console.error("[notifications] email failed", err);
});
}
}
+110
View File
@@ -0,0 +1,110 @@
/**
* Conservative pantry-awareness for shopping list generation.
*
* Matching strategy:
* 1. Reliable path: if both the shopping item and a pantry item reference the same
* normalized `ingredientId`, they're the same ingredient.
* 2. Fallback: normalize free-text `rawName` (case/whitespace/simple plural) and match exactly.
* No fuzzy/AI matching — when normalization doesn't produce an exact match, treat as unmatched.
*
* Quantity handling: only subtract pantry quantity from the needed quantity when both
* are parseable numbers in the same (normalized) unit. Otherwise leave the needed quantity
* untouched and just flag `inPantry` so the user can decide for themselves.
*/
export type PantrySourceItem = {
ingredientId: string | null;
rawName: string;
quantity: string | null;
unit: string | null;
};
export type ShoppingSourceItem = {
rawName: string;
quantity?: string;
unit?: string;
ingredientId?: string | null;
};
export type PantryAdjustedItem = {
rawName: string;
quantity?: string;
unit?: string;
inPantry: boolean;
};
function normalizeName(name: string): string {
const trimmed = name.toLowerCase().trim().replace(/\s+/g, " ");
// Simple plural trim — strip a single trailing "s" for words longer than 3 chars.
return trimmed.length > 3 && trimmed.endsWith("s") ? trimmed.slice(0, -1) : trimmed;
}
function normalizeUnit(unit: string | null | undefined): string {
return (unit ?? "").toLowerCase().trim();
}
function formatQuantity(n: number): string {
return Number.isInteger(n) ? String(n) : n.toFixed(2).replace(/\.?0+$/, "");
}
/**
* Reduces (or flags) each shopping item's needed quantity based on what's already in the pantry.
* Never removes an item outright when the match is uncertain — always returns one entry per input item.
*/
export function applyPantryToItems(
items: ShoppingSourceItem[],
pantry: PantrySourceItem[]
): PantryAdjustedItem[] {
const pantryByIngredientId = new Map<string, PantrySourceItem[]>();
const pantryByName = new Map<string, PantrySourceItem[]>();
for (const p of pantry) {
if (p.ingredientId) {
const list = pantryByIngredientId.get(p.ingredientId) ?? [];
list.push(p);
pantryByIngredientId.set(p.ingredientId, list);
}
const nameKey = normalizeName(p.rawName);
const list = pantryByName.get(nameKey) ?? [];
list.push(p);
pantryByName.set(nameKey, list);
}
return items.map((item) => {
let matches: PantrySourceItem[] | undefined;
if (item.ingredientId && pantryByIngredientId.has(item.ingredientId)) {
matches = pantryByIngredientId.get(item.ingredientId);
} else {
matches = pantryByName.get(normalizeName(item.rawName));
}
if (!matches || matches.length === 0) {
return { rawName: item.rawName, quantity: item.quantity, unit: item.unit, inPantry: false };
}
const neededQty = item.quantity ? parseFloat(item.quantity) : NaN;
const neededUnit = normalizeUnit(item.unit);
if (!isNaN(neededQty) && neededQty > 0) {
// Only sum pantry entries whose unit matches the needed unit (including both-empty).
const compatible = matches.filter((m) => normalizeUnit(m.unit) === neededUnit);
const parseableQuantities = compatible
.map((m) => (m.quantity ? parseFloat(m.quantity) : NaN))
.filter((n) => !isNaN(n));
if (compatible.length > 0 && parseableQuantities.length === compatible.length) {
const havingQty = parseableQuantities.reduce((sum, n) => sum + n, 0);
const remaining = neededQty - havingQty;
if (remaining <= 0) {
// Fully covered — still surfaced in the list (flagged), not silently dropped.
return { rawName: item.rawName, quantity: undefined, unit: item.unit, inPantry: true };
}
return { rawName: item.rawName, quantity: formatQuantity(remaining), unit: item.unit, inPantry: true };
}
}
// Matched the ingredient, but quantities/units aren't reliably comparable — flag only.
return { rawName: item.rawName, quantity: item.quantity, unit: item.unit, inPantry: true };
});
}
+31 -1
View File
@@ -29,7 +29,19 @@
"reply": "{name} replied to your comment",
"reaction": "{name} reacted to your comment",
"rating": "{name} rated your recipe",
"mention": "{name} mentioned you in a comment"
"mention": "{name} mentioned you in a comment",
"detail": {
"comment": "{name} commented on your recipe \"{title}\"",
"rating": "{name} rated your recipe \"{title}\" {stars} stars"
},
"pushTitle": {
"follow": "New follower",
"comment": "New comment",
"reply": "New reply",
"reaction": "New reaction",
"rating": "New rating",
"mention": "You were mentioned"
}
},
"recipe": {
"byAuthor": "by {name}",
@@ -81,6 +93,11 @@
"pairingBulkSuccess": "{count} recipes generated — find them in your library",
"pairingDialogTitle": "Complete the meal",
"pairingDialogDescription": "AI-suggested dishes that pair well with this recipe. Generate any of them as a new recipe.",
"forkTooltip": "Fork this recipe",
"forked": "Recipe forked to your library",
"forkFailed": "Failed to fork recipe",
"forkLimitReached": "Recipe limit reached for your tier",
"forkedFrom": "Forked from {title}",
"pairingFindingLabel": "Finding perfect pairings…",
"pairingSuggestButton": "Suggest pairings",
"pairingRegenerate": "Regenerate",
@@ -508,6 +525,15 @@
"tagLabel": "Tag",
"clearFilters": "Clear filters"
},
"recipeNotes": {
"title": "Your private notes",
"visibilityHint": "Only visible to you — never shared with the recipe's author or anyone else.",
"placeholder": "e.g. I used less salt than the recipe calls for, great with rice instead of pasta…",
"save": "Save",
"saving": "Saving…",
"saved": "Note saved",
"saveFailed": "Failed to save note"
},
"recipeForm": {
"titleLabel": "Title *",
"titleRequired": "Title is required",
@@ -690,6 +716,7 @@
"copiedToClipboard": "List copied to clipboard",
"exportBuildFailed": "Could not build export",
"instacartNotConfigured": "Instacart isn't configured yet",
"alreadyInPantry": "Already in pantry",
"items": "{checked}/{total} items",
"itemsChecked": "{checked}/{total} items checked",
"fromMealPlan": " · From meal plan",
@@ -872,6 +899,9 @@
"changePasswordButton": "Change password",
"passwordChanged": "Password changed successfully.",
"passwordChangeFailed": "Failed to change password.",
"downloadData": "Download my data",
"downloadDataDescription": "Get a JSON export of all the personal data Epicure holds about you — your profile, recipes, activity, and settings.",
"downloadDataButton": "Download my data",
"webhookCreateFailed": "Failed to create webhook",
"webhookDeleteFailed": "Failed to delete webhook",
"webhookUpdateFailed": "Failed to update webhook",
+31 -1
View File
@@ -29,7 +29,19 @@
"reply": "{name} a répondu à votre commentaire",
"reaction": "{name} a réagi à votre commentaire",
"rating": "{name} a noté votre recette",
"mention": "{name} vous a mentionné dans un commentaire"
"mention": "{name} vous a mentionné dans un commentaire",
"detail": {
"comment": "{name} a commenté votre recette « {title} »",
"rating": "{name} a noté votre recette « {title} » {stars} étoiles"
},
"pushTitle": {
"follow": "Nouvel abonné",
"comment": "Nouveau commentaire",
"reply": "Nouvelle réponse",
"reaction": "Nouvelle réaction",
"rating": "Nouvelle note",
"mention": "Vous avez été mentionné"
}
},
"recipe": {
"byAuthor": "par {name}",
@@ -82,6 +94,11 @@
"pairingBulkSuccess": "{count} recettes générées — retrouvez-les dans votre bibliothèque",
"pairingDialogTitle": "Compléter le repas",
"pairingDialogDescription": "Plats suggérés par l'IA qui accompagnent bien cette recette. Générez-en n'importe lequel comme nouvelle recette.",
"forkTooltip": "Copier cette recette",
"forked": "Recette copiée dans votre bibliothèque",
"forkFailed": "Échec de la copie de la recette",
"forkLimitReached": "Limite de recettes atteinte pour votre offre",
"forkedFrom": "Copiée depuis {title}",
"pairingFindingLabel": "Recherche des meilleurs accords…",
"pairingSuggestButton": "Suggérer des accompagnements",
"pairingRegenerate": "Régénérer",
@@ -499,6 +516,15 @@
"tagLabel": "Tag",
"clearFilters": "Effacer les filtres"
},
"recipeNotes": {
"title": "Vos notes personnelles",
"visibilityHint": "Visible uniquement par vous — jamais partagé avec l'auteur de la recette ou quiconque d'autre.",
"placeholder": "ex. : j'ai mis moins de sel que ce que la recette indique, très bon avec du riz à la place des pâtes…",
"save": "Enregistrer",
"saving": "Enregistrement…",
"saved": "Note enregistrée",
"saveFailed": "Échec de l'enregistrement de la note"
},
"recipeForm": {
"titleLabel": "Titre *",
"titleRequired": "Le titre est requis",
@@ -678,6 +704,7 @@
"copiedToClipboard": "Liste copiée dans le presse-papiers",
"exportBuildFailed": "Impossible de générer l'export",
"instacartNotConfigured": "Instacart n'est pas encore configuré",
"alreadyInPantry": "Déjà au garde-manger",
"items": "{checked}/{total} articles",
"itemsChecked": "{checked}/{total} articles cochés",
"fromMealPlan": " · Depuis le planning repas",
@@ -860,6 +887,9 @@
"changePasswordButton": "Changer le mot de passe",
"passwordChanged": "Mot de passe modifié avec succès.",
"passwordChangeFailed": "Échec de la modification du mot de passe.",
"downloadData": "Télécharger mes données",
"downloadDataDescription": "Obtenez un export JSON de toutes les données personnelles qu'Epicure détient sur vous — votre profil, vos recettes, votre activité et vos paramètres.",
"downloadDataButton": "Télécharger mes données",
"webhookCreateFailed": "Échec de la création du webhook",
"webhookDeleteFailed": "Échec de la suppression du webhook",
"webhookUpdateFailed": "Échec de la mise à jour du webhook",
@@ -0,0 +1 @@
CREATE UNIQUE INDEX "recipe_notes_recipe_user_idx" ON "recipe_notes" USING btree ("recipe_id","user_id");
@@ -0,0 +1 @@
ALTER TABLE "shopping_list_items" ADD COLUMN "in_pantry" boolean DEFAULT false NOT NULL;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -176,6 +176,20 @@
"when": 1783623700772,
"tag": "0024_moaning_roughhouse",
"breakpoints": true
},
{
"idx": 25,
"version": "7",
"when": 1783661174093,
"tag": "0025_wonderful_micromacro",
"breakpoints": true
},
{
"idx": 26,
"version": "7",
"when": 1783661460616,
"tag": "0026_sharp_rictor",
"breakpoints": true
}
]
}
+1
View File
@@ -72,6 +72,7 @@ export const shoppingListItems = pgTable("shopping_list_items", {
unit: text("unit"),
aisle: text("aisle"),
checked: boolean("checked").notNull().default(false),
inPantry: boolean("in_pantry").notNull().default(false),
});
export const shoppingListMembers = pgTable("shopping_list_members", {
+4 -1
View File
@@ -8,6 +8,7 @@ import {
jsonb,
pgEnum,
index,
uniqueIndex,
} from "drizzle-orm/pg-core";
import { relations } from "drizzle-orm";
import { users } from "./users";
@@ -101,7 +102,9 @@ export const recipeNotes = pgTable("recipe_notes", {
content: text("content").notNull(),
createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow(),
});
}, (t) => [
uniqueIndex("recipe_notes_recipe_user_idx").on(t.recipeId, t.userId),
]);
export const recipeVariations = pgTable("recipe_variations", {
id: text("id").primaryKey(),