feat: notifications system, rate limiting, fix recipe visibility 404, follow race
Part of the social-feature backlog (follow, comments, reactions, ratings, feed, threading) audited earlier — see conversation. - notifications table: follow/comment/reply/reaction/rating events, replaces the fully-dead feed_items table (feed_item_type enum existed but had zero references anywhere in the codebase). - Bell UI in the nav with unread badge, mark-all-read, 30s poll. - Rate limiting on comment posting (20/min), follow/unfollow (30/min), and comment reactions (60/min) — previously unthrottled. - /recipes/[id] queried by (id, authorId=session.user) only, so any recipe not owned by the viewer 404'd regardless of visibility. Widen the query to include public/unlisted recipes and gate the owner-only actions (edit, delete, version history, translate, AI content generation) behind an isOwner check. - user_follows had no primary key/unique constraint, so the follow route's onConflictDoNothing() was a silent no-op — concurrent follow clicks could insert duplicate rows and inflate follower counts. Add a composite primary key on (follower_id, following_id). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -17,7 +17,7 @@ import { NutritionPanel } from "@/components/recipe/nutrition-panel";
|
||||
import { GenerateContentButton } from "@/components/recipe/generate-content-button";
|
||||
import { auth } from "@/lib/auth/server";
|
||||
import { db, recipes, ratings, favorites, avg } from "@epicure/db";
|
||||
import { and, eq, count } from "@epicure/db";
|
||||
import { and, eq, or, count, inArray } from "@epicure/db";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
@@ -53,7 +53,10 @@ export default async function RecipePage({ params }: Params) {
|
||||
|
||||
const [recipe, ratingData, favoriteData] = await Promise.all([
|
||||
db.query.recipes.findFirst({
|
||||
where: and(eq(recipes.id, id), eq(recipes.authorId, session.user.id)),
|
||||
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) },
|
||||
@@ -66,6 +69,8 @@ export default async function RecipePage({ params }: Params) {
|
||||
|
||||
if (!recipe) notFound();
|
||||
|
||||
const isOwner = recipe.authorId === session.user.id;
|
||||
|
||||
const avgScore = ratingData[0]?.avgScore ? parseFloat(ratingData[0].avgScore) : null;
|
||||
const ratingCount = ratingData[0]?.total ?? 0;
|
||||
const isFavorited = !!favoriteData;
|
||||
@@ -121,7 +126,7 @@ export default async function RecipePage({ params }: Params) {
|
||||
}))}
|
||||
/>
|
||||
)}
|
||||
{(!recipe.language || recipe.language !== (session.user as { locale?: string }).locale) && (
|
||||
{isOwner && (!recipe.language || recipe.language !== (session.user as { locale?: string }).locale) && (
|
||||
<TranslateButton recipeId={id} />
|
||||
)}
|
||||
{recipe.ingredients.length > 0 && (
|
||||
@@ -151,32 +156,36 @@ export default async function RecipePage({ params }: Params) {
|
||||
/>
|
||||
<ShareRecipeButton recipeId={id} visibility={recipe.visibility} />
|
||||
<PrintButton 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>
|
||||
<TooltipTrigger render={
|
||||
<Link href={`/recipes/${id}/edit`} className={cn(buttonVariants({ variant: "ghost", size: "icon" }))}>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Link>
|
||||
} />
|
||||
<TooltipContent>{m.recipe.edit}</TooltipContent>
|
||||
</Tooltip>
|
||||
<DeleteRecipeButton recipeId={id} />
|
||||
{isOwner && (
|
||||
<>
|
||||
<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>
|
||||
<TooltipTrigger render={
|
||||
<Link href={`/recipes/${id}/edit`} className={cn(buttonVariants({ variant: "ghost", size: "icon" }))}>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Link>
|
||||
} />
|
||||
<TooltipContent>{m.recipe.edit}</TooltipContent>
|
||||
</Tooltip>
|
||||
<DeleteRecipeButton recipeId={id} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
|
||||
@@ -246,17 +255,19 @@ export default async function RecipePage({ params }: Params) {
|
||||
{recipe.ingredients.length === 0 && recipe.steps.length === 0 && (
|
||||
<div className="flex flex-col items-center gap-4 py-12 text-center">
|
||||
<p className="text-muted-foreground">No ingredients or steps yet.</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<GenerateContentButton
|
||||
recipeId={id}
|
||||
title={recipe.title}
|
||||
description={recipe.description}
|
||||
/>
|
||||
<Link href={`/recipes/${id}/edit`} className={cn(buttonVariants({ variant: "outline", size: "sm" }))}>
|
||||
<Pencil className="h-4 w-4" />
|
||||
Edit manually
|
||||
</Link>
|
||||
</div>
|
||||
{isOwner && (
|
||||
<div className="flex items-center gap-2">
|
||||
<GenerateContentButton
|
||||
recipeId={id}
|
||||
title={recipe.title}
|
||||
description={recipe.description}
|
||||
/>
|
||||
<Link href={`/recipes/${id}/edit`} className={cn(buttonVariants({ variant: "outline", size: "sm" }))}>
|
||||
<Pencil className="h-4 w-4" />
|
||||
Edit manually
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user