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 { GenerateContentButton } from "@/components/recipe/generate-content-button";
|
||||||
import { auth } from "@/lib/auth/server";
|
import { auth } from "@/lib/auth/server";
|
||||||
import { db, recipes, ratings, favorites, avg } from "@epicure/db";
|
import { db, recipes, ratings, favorites, avg } from "@epicure/db";
|
||||||
import { and, eq, count } from "@epicure/db";
|
import { and, eq, or, count, inArray } from "@epicure/db";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { buttonVariants } from "@/components/ui/button";
|
import { buttonVariants } from "@/components/ui/button";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
@@ -53,7 +53,10 @@ export default async function RecipePage({ params }: Params) {
|
|||||||
|
|
||||||
const [recipe, ratingData, favoriteData] = await Promise.all([
|
const [recipe, ratingData, favoriteData] = await Promise.all([
|
||||||
db.query.recipes.findFirst({
|
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: {
|
with: {
|
||||||
ingredients: { orderBy: (t, { asc }) => asc(t.order) },
|
ingredients: { orderBy: (t, { asc }) => asc(t.order) },
|
||||||
steps: { 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();
|
if (!recipe) notFound();
|
||||||
|
|
||||||
|
const isOwner = recipe.authorId === session.user.id;
|
||||||
|
|
||||||
const avgScore = ratingData[0]?.avgScore ? parseFloat(ratingData[0].avgScore) : null;
|
const avgScore = ratingData[0]?.avgScore ? parseFloat(ratingData[0].avgScore) : null;
|
||||||
const ratingCount = ratingData[0]?.total ?? 0;
|
const ratingCount = ratingData[0]?.total ?? 0;
|
||||||
const isFavorited = !!favoriteData;
|
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} />
|
<TranslateButton recipeId={id} />
|
||||||
)}
|
)}
|
||||||
{recipe.ingredients.length > 0 && (
|
{recipe.ingredients.length > 0 && (
|
||||||
@@ -151,6 +156,8 @@ export default async function RecipePage({ params }: Params) {
|
|||||||
/>
|
/>
|
||||||
<ShareRecipeButton recipeId={id} visibility={recipe.visibility} />
|
<ShareRecipeButton recipeId={id} visibility={recipe.visibility} />
|
||||||
<PrintButton recipeId={id} />
|
<PrintButton recipeId={id} />
|
||||||
|
{isOwner && (
|
||||||
|
<>
|
||||||
<VersionHistoryButton
|
<VersionHistoryButton
|
||||||
recipeId={id}
|
recipeId={id}
|
||||||
currentSnapshot={{
|
currentSnapshot={{
|
||||||
@@ -177,6 +184,8 @@ export default async function RecipePage({ params }: Params) {
|
|||||||
<TooltipContent>{m.recipe.edit}</TooltipContent>
|
<TooltipContent>{m.recipe.edit}</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<DeleteRecipeButton recipeId={id} />
|
<DeleteRecipeButton recipeId={id} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
|
|
||||||
@@ -246,6 +255,7 @@ export default async function RecipePage({ params }: Params) {
|
|||||||
{recipe.ingredients.length === 0 && recipe.steps.length === 0 && (
|
{recipe.ingredients.length === 0 && recipe.steps.length === 0 && (
|
||||||
<div className="flex flex-col items-center gap-4 py-12 text-center">
|
<div className="flex flex-col items-center gap-4 py-12 text-center">
|
||||||
<p className="text-muted-foreground">No ingredients or steps yet.</p>
|
<p className="text-muted-foreground">No ingredients or steps yet.</p>
|
||||||
|
{isOwner && (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<GenerateContentButton
|
<GenerateContentButton
|
||||||
recipeId={id}
|
recipeId={id}
|
||||||
@@ -257,6 +267,7 @@ export default async function RecipePage({ params }: Params) {
|
|||||||
Edit manually
|
Edit manually
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { db, notifications, eq, and } from "@epicure/db";
|
||||||
|
import { requireSession } from "@/lib/api-auth";
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
const { session, response } = await requireSession();
|
||||||
|
if (response) return response;
|
||||||
|
|
||||||
|
const body = (await req.json().catch(() => ({}))) as { id?: string };
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(notifications)
|
||||||
|
.set({ read: true })
|
||||||
|
.where(
|
||||||
|
body.id
|
||||||
|
? and(eq(notifications.userId, session!.user.id), eq(notifications.id, body.id))
|
||||||
|
: eq(notifications.userId, session!.user.id)
|
||||||
|
);
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { db, notifications, users, eq, and, desc, count } from "@epicure/db";
|
||||||
|
import { requireSession } from "@/lib/api-auth";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const { session, response } = await requireSession();
|
||||||
|
if (response) return response;
|
||||||
|
|
||||||
|
const [rows, unread] = await Promise.all([
|
||||||
|
db
|
||||||
|
.select({
|
||||||
|
id: notifications.id,
|
||||||
|
type: notifications.type,
|
||||||
|
recipeId: notifications.recipeId,
|
||||||
|
commentId: notifications.commentId,
|
||||||
|
read: notifications.read,
|
||||||
|
createdAt: notifications.createdAt,
|
||||||
|
actorId: notifications.actorId,
|
||||||
|
actorName: users.name,
|
||||||
|
actorUsername: users.username,
|
||||||
|
actorAvatarUrl: users.avatarUrl,
|
||||||
|
})
|
||||||
|
.from(notifications)
|
||||||
|
.innerJoin(users, eq(notifications.actorId, users.id))
|
||||||
|
.where(eq(notifications.userId, session!.user.id))
|
||||||
|
.orderBy(desc(notifications.createdAt))
|
||||||
|
.limit(30),
|
||||||
|
db
|
||||||
|
.select({ total: count() })
|
||||||
|
.from(notifications)
|
||||||
|
.where(and(eq(notifications.userId, session!.user.id), eq(notifications.read, false))),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return NextResponse.json({ notifications: rows, unreadCount: unread[0]?.total ?? 0 });
|
||||||
|
}
|
||||||
@@ -2,6 +2,8 @@ import { NextRequest, NextResponse } from "next/server";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { db, comments, commentReactions, eq, and, count } from "@epicure/db";
|
import { db, comments, commentReactions, eq, and, count } from "@epicure/db";
|
||||||
import { requireSession } from "@/lib/api-auth";
|
import { requireSession } from "@/lib/api-auth";
|
||||||
|
import { applyRateLimit } from "@/lib/rate-limit";
|
||||||
|
import { createNotification } from "@/lib/notifications";
|
||||||
|
|
||||||
const ReactionSchema = z.object({
|
const ReactionSchema = z.object({
|
||||||
type: z.enum(["like", "love", "laugh", "wow", "sad", "fire"]),
|
type: z.enum(["like", "love", "laugh", "wow", "sad", "fire"]),
|
||||||
@@ -49,6 +51,10 @@ export async function GET(req: NextRequest, { params }: Params) {
|
|||||||
export async function POST(req: NextRequest, { params }: Params) {
|
export async function POST(req: NextRequest, { params }: Params) {
|
||||||
const { session, response } = await requireSession();
|
const { session, response } = await requireSession();
|
||||||
if (response) return response;
|
if (response) return response;
|
||||||
|
|
||||||
|
const limited = await applyRateLimit(`rl:reaction:${session!.user.id}`, 60, 60);
|
||||||
|
if (limited) return limited;
|
||||||
|
|
||||||
const { commentId } = await params;
|
const { commentId } = await params;
|
||||||
|
|
||||||
// Verify comment exists
|
// Verify comment exists
|
||||||
@@ -85,6 +91,7 @@ export async function POST(req: NextRequest, { params }: Params) {
|
|||||||
type,
|
type,
|
||||||
});
|
});
|
||||||
added = true;
|
added = true;
|
||||||
|
void createNotification({ userId: comment.userId, type: "reaction", actorId: userId, recipeId: comment.recipeId, commentId });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Return updated counts
|
// Return updated counts
|
||||||
|
|||||||
@@ -2,8 +2,10 @@ import { NextRequest, NextResponse } from "next/server";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { db, recipes, comments, users, eq, and } from "@epicure/db";
|
import { db, recipes, comments, users, eq, and } from "@epicure/db";
|
||||||
import { requireSession } from "@/lib/api-auth";
|
import { requireSession } from "@/lib/api-auth";
|
||||||
|
import { applyRateLimit } from "@/lib/rate-limit";
|
||||||
import { dispatchWebhook } from "@/lib/webhooks";
|
import { dispatchWebhook } from "@/lib/webhooks";
|
||||||
import { sendPushNotification } from "@/lib/push";
|
import { sendPushNotification } from "@/lib/push";
|
||||||
|
import { createNotification } from "@/lib/notifications";
|
||||||
|
|
||||||
const Schema = z.object({
|
const Schema = z.object({
|
||||||
content: z.string().min(1).max(5000),
|
content: z.string().min(1).max(5000),
|
||||||
@@ -44,6 +46,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 limited = await applyRateLimit(`rl:comment:${session!.user.id}`, 20, 60);
|
||||||
|
if (limited) return limited;
|
||||||
|
|
||||||
const recipe = await db.query.recipes.findFirst({ where: eq(recipes.id, id) });
|
const recipe = await db.query.recipes.findFirst({ where: eq(recipes.id, id) });
|
||||||
if (!recipe || (recipe.visibility === "private" && recipe.authorId !== session!.user.id)) {
|
if (!recipe || (recipe.visibility === "private" && recipe.authorId !== session!.user.id)) {
|
||||||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||||
@@ -53,8 +58,9 @@ export async function POST(req: NextRequest, { params }: Params) {
|
|||||||
const parsed = Schema.safeParse(body);
|
const parsed = Schema.safeParse(body);
|
||||||
if (!parsed.success) return NextResponse.json({ error: "Validation error" }, { status: 400 });
|
if (!parsed.success) return NextResponse.json({ error: "Validation error" }, { status: 400 });
|
||||||
|
|
||||||
|
let parent: { id: string; userId: string } | undefined;
|
||||||
if (parsed.data.parentId) {
|
if (parsed.data.parentId) {
|
||||||
const parent = await db.query.comments.findFirst({
|
parent = await db.query.comments.findFirst({
|
||||||
where: and(eq(comments.id, parsed.data.parentId), eq(comments.recipeId, id)),
|
where: and(eq(comments.id, parsed.data.parentId), eq(comments.recipeId, id)),
|
||||||
});
|
});
|
||||||
if (!parent) return NextResponse.json({ error: "Parent comment not found" }, { status: 404 });
|
if (!parent) return NextResponse.json({ error: "Parent comment not found" }, { status: 404 });
|
||||||
@@ -78,5 +84,13 @@ export async function POST(req: NextRequest, { params }: Params) {
|
|||||||
url: `/recipes/${id}`,
|
url: `/recipes/${id}`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (parent && parent.userId !== session!.user.id) {
|
||||||
|
void createNotification({ userId: parent.userId, type: "reply", actorId: session!.user.id, recipeId: id, commentId });
|
||||||
|
}
|
||||||
|
if (recipe.authorId !== parent?.userId) {
|
||||||
|
void createNotification({ userId: recipe.authorId, type: "comment", actorId: session!.user.id, recipeId: id, commentId });
|
||||||
|
}
|
||||||
|
|
||||||
return NextResponse.json({ id: commentId }, { status: 201 });
|
return NextResponse.json({ id: commentId }, { status: 201 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { db, recipes, ratings, eq, and } from "@epicure/db";
|
import { db, recipes, ratings, eq, and } from "@epicure/db";
|
||||||
import { requireSession } from "@/lib/api-auth";
|
import { requireSession } from "@/lib/api-auth";
|
||||||
|
import { createNotification } from "@/lib/notifications";
|
||||||
|
|
||||||
const Schema = z.object({
|
const Schema = z.object({
|
||||||
score: z.number().int().min(1).max(5),
|
score: z.number().int().min(1).max(5),
|
||||||
@@ -45,5 +46,6 @@ export async function POST(req: NextRequest, { params }: Params) {
|
|||||||
score: parsed.data.score,
|
score: parsed.data.score,
|
||||||
reviewText: parsed.data.reviewText,
|
reviewText: parsed.data.reviewText,
|
||||||
});
|
});
|
||||||
|
void createNotification({ userId: recipe.authorId, type: "rating", actorId: session!.user.id, recipeId: id });
|
||||||
return NextResponse.json({ created: true }, { status: 201 });
|
return NextResponse.json({ created: true }, { status: 201 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,18 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { db, users, userFollows, eq, and } from "@epicure/db";
|
import { db, users, userFollows, eq, and } from "@epicure/db";
|
||||||
import { requireSession } from "@/lib/api-auth";
|
import { requireSession } from "@/lib/api-auth";
|
||||||
|
import { applyRateLimit } from "@/lib/rate-limit";
|
||||||
|
import { createNotification } from "@/lib/notifications";
|
||||||
|
|
||||||
type Params = { params: Promise<{ username: string }> };
|
type Params = { params: Promise<{ username: string }> };
|
||||||
|
|
||||||
export async function POST(_req: NextRequest, { params }: Params) {
|
export async function POST(_req: NextRequest, { params }: Params) {
|
||||||
const { session, response } = await requireSession();
|
const { session, response } = await requireSession();
|
||||||
if (response) return response;
|
if (response) return response;
|
||||||
|
|
||||||
|
const limited = await applyRateLimit(`rl:follow:${session!.user.id}`, 30, 60);
|
||||||
|
if (limited) return limited;
|
||||||
|
|
||||||
const { username } = await params;
|
const { username } = await params;
|
||||||
|
|
||||||
const target = await db.query.users.findFirst({ where: eq(users.username, username) });
|
const target = await db.query.users.findFirst({ where: eq(users.username, username) });
|
||||||
@@ -17,6 +23,8 @@ export async function POST(_req: NextRequest, { params }: Params) {
|
|||||||
.values({ followerId: session!.user.id, followingId: target.id })
|
.values({ followerId: session!.user.id, followingId: target.id })
|
||||||
.onConflictDoNothing();
|
.onConflictDoNothing();
|
||||||
|
|
||||||
|
void createNotification({ userId: target.id, type: "follow", actorId: session!.user.id });
|
||||||
|
|
||||||
return NextResponse.json({ following: true });
|
return NextResponse.json({ following: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
SheetTrigger,
|
SheetTrigger,
|
||||||
} from "@/components/ui/sheet";
|
} from "@/components/ui/sheet";
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||||
|
import { NotificationBell } from "@/components/social/notification-bell";
|
||||||
import { authClient } from "@/lib/auth/client";
|
import { authClient } from "@/lib/auth/client";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
|
|
||||||
@@ -100,6 +101,7 @@ export function Nav() {
|
|||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
<div className="ml-auto flex items-center gap-2">
|
<div className="ml-auto flex items-center gap-2">
|
||||||
|
<NotificationBell />
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger className="rounded-full outline-none focus-visible:ring-2 focus-visible:ring-ring">
|
<DropdownMenuTrigger className="rounded-full outline-none focus-visible:ring-2 focus-visible:ring-ring">
|
||||||
<Avatar className="h-8 w-8">
|
<Avatar className="h-8 w-8">
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { Bell } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
type Notification = {
|
||||||
|
id: string;
|
||||||
|
type: "follow" | "comment" | "reply" | "reaction" | "rating" | "mention";
|
||||||
|
recipeId: string | null;
|
||||||
|
commentId: string | null;
|
||||||
|
read: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
actorId: string;
|
||||||
|
actorName: string;
|
||||||
|
actorUsername: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function notificationHref(n: Notification): string {
|
||||||
|
if (n.type === "follow") return n.actorUsername ? `/u/${n.actorUsername}` : "#";
|
||||||
|
if (n.recipeId) return `/recipes/${n.recipeId}`;
|
||||||
|
return "#";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NotificationBell() {
|
||||||
|
const t = useTranslations("notifications");
|
||||||
|
const [notifications, setNotifications] = useState<Notification[]>([]);
|
||||||
|
const [unreadCount, setUnreadCount] = useState(0);
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/v1/notifications");
|
||||||
|
if (!res.ok) return;
|
||||||
|
const data = (await res.json()) as { notifications: Notification[]; unreadCount: number };
|
||||||
|
setNotifications(data.notifications);
|
||||||
|
setUnreadCount(data.unreadCount);
|
||||||
|
} catch {
|
||||||
|
// silent — bell just stays at last known state
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load();
|
||||||
|
const interval = setInterval(() => { void load(); }, 30_000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function markAllRead() {
|
||||||
|
setUnreadCount(0);
|
||||||
|
setNotifications((prev) => prev.map((n) => ({ ...n, read: true })));
|
||||||
|
await fetch("/api/v1/notifications/read", { method: "POST" });
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenu open={open} onOpenChange={(next) => { setOpen(next); if (next) void load(); }}>
|
||||||
|
<DropdownMenuTrigger render={<Button variant="ghost" size="icon" className="relative" />}>
|
||||||
|
<Bell className="h-4 w-4" />
|
||||||
|
{unreadCount > 0 && (
|
||||||
|
<Badge
|
||||||
|
variant="destructive"
|
||||||
|
className="absolute -top-1 -right-1 h-4 min-w-4 px-1 text-[10px] leading-none flex items-center justify-center"
|
||||||
|
>
|
||||||
|
{unreadCount > 9 ? "9+" : unreadCount}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
<span className="sr-only">{t("title")}</span>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="w-80">
|
||||||
|
<div className="flex items-center justify-between px-2 py-1.5">
|
||||||
|
<span className="text-sm font-medium">{t("title")}</span>
|
||||||
|
{unreadCount > 0 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => { void markAllRead(); }}
|
||||||
|
className="text-xs text-muted-foreground hover:text-foreground underline underline-offset-4"
|
||||||
|
>
|
||||||
|
{t("markAllRead")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{notifications.length === 0 && (
|
||||||
|
<p className="px-2 py-4 text-sm text-muted-foreground text-center">{t("empty")}</p>
|
||||||
|
)}
|
||||||
|
<div className="max-h-96 overflow-y-auto">
|
||||||
|
{notifications.map((n) => (
|
||||||
|
<DropdownMenuItem key={n.id} render={<Link href={notificationHref(n)} />}>
|
||||||
|
<div className={cn("flex items-start gap-2 py-1", !n.read && "font-medium")}>
|
||||||
|
{!n.read && <span className="mt-1.5 h-1.5 w-1.5 rounded-full bg-primary shrink-0" />}
|
||||||
|
<span className={cn("text-sm", n.read && "text-muted-foreground")}>
|
||||||
|
{t(n.type, { name: n.actorName })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { db, notifications } from "@epicure/db";
|
||||||
|
import { randomUUID } from "crypto";
|
||||||
|
|
||||||
|
type NotificationType = "follow" | "comment" | "reply" | "reaction" | "rating" | "mention";
|
||||||
|
|
||||||
|
export async function createNotification(opts: {
|
||||||
|
userId: string;
|
||||||
|
type: NotificationType;
|
||||||
|
actorId: string;
|
||||||
|
recipeId?: string;
|
||||||
|
commentId?: string;
|
||||||
|
}): Promise<void> {
|
||||||
|
if (opts.userId === opts.actorId) return; // never notify yourself
|
||||||
|
await db.insert(notifications).values({
|
||||||
|
id: randomUUID(),
|
||||||
|
userId: opts.userId,
|
||||||
|
type: opts.type,
|
||||||
|
actorId: opts.actorId,
|
||||||
|
recipeId: opts.recipeId,
|
||||||
|
commentId: opts.commentId,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -14,7 +14,18 @@
|
|||||||
"signOut": "Sign out",
|
"signOut": "Sign out",
|
||||||
"apiKeys": "API keys",
|
"apiKeys": "API keys",
|
||||||
"webhooks": "Webhooks",
|
"webhooks": "Webhooks",
|
||||||
"language": "Language"
|
"language": "Language",
|
||||||
|
"notifications": "Notifications"
|
||||||
|
},
|
||||||
|
"notifications": {
|
||||||
|
"title": "Notifications",
|
||||||
|
"empty": "No notifications yet.",
|
||||||
|
"markAllRead": "Mark all as read",
|
||||||
|
"follow": "{name} followed you",
|
||||||
|
"comment": "{name} commented on your recipe",
|
||||||
|
"reply": "{name} replied to your comment",
|
||||||
|
"reaction": "{name} reacted to your comment",
|
||||||
|
"rating": "{name} rated your recipe"
|
||||||
},
|
},
|
||||||
"recipe": {
|
"recipe": {
|
||||||
"share": "Share",
|
"share": "Share",
|
||||||
|
|||||||
@@ -14,7 +14,18 @@
|
|||||||
"signOut": "Se déconnecter",
|
"signOut": "Se déconnecter",
|
||||||
"apiKeys": "Clés API",
|
"apiKeys": "Clés API",
|
||||||
"webhooks": "Webhooks",
|
"webhooks": "Webhooks",
|
||||||
"language": "Langue"
|
"language": "Langue",
|
||||||
|
"notifications": "Notifications"
|
||||||
|
},
|
||||||
|
"notifications": {
|
||||||
|
"title": "Notifications",
|
||||||
|
"empty": "Aucune notification pour l'instant.",
|
||||||
|
"markAllRead": "Tout marquer comme lu",
|
||||||
|
"follow": "{name} vous suit désormais",
|
||||||
|
"comment": "{name} a commenté votre recette",
|
||||||
|
"reply": "{name} a répondu à votre commentaire",
|
||||||
|
"reaction": "{name} a réagi à votre commentaire",
|
||||||
|
"rating": "{name} a noté votre recette"
|
||||||
},
|
},
|
||||||
"recipe": {
|
"recipe": {
|
||||||
"share": "Partager",
|
"share": "Partager",
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "user_follows" ADD CONSTRAINT "user_follows_follower_id_following_id_pk" PRIMARY KEY("follower_id","following_id");
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
DROP TABLE "feed_items" CASCADE;--> statement-breakpoint
|
||||||
|
DROP TYPE "public"."feed_item_type";
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
CREATE TYPE "public"."notification_type" AS ENUM('follow', 'comment', 'reply', 'reaction', 'rating', 'mention');--> statement-breakpoint
|
||||||
|
CREATE TABLE "notifications" (
|
||||||
|
"id" text PRIMARY KEY NOT NULL,
|
||||||
|
"user_id" text NOT NULL,
|
||||||
|
"type" "notification_type" NOT NULL,
|
||||||
|
"actor_id" text NOT NULL,
|
||||||
|
"recipe_id" text,
|
||||||
|
"comment_id" text,
|
||||||
|
"read" boolean DEFAULT false NOT NULL,
|
||||||
|
"created_at" timestamp DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "notifications" ADD CONSTRAINT "notifications_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "notifications" ADD CONSTRAINT "notifications_actor_id_users_id_fk" FOREIGN KEY ("actor_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "notifications" ADD CONSTRAINT "notifications_recipe_id_recipes_id_fk" FOREIGN KEY ("recipe_id") REFERENCES "public"."recipes"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "notifications" ADD CONSTRAINT "notifications_comment_id_comments_id_fk" FOREIGN KEY ("comment_id") REFERENCES "public"."comments"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
CREATE INDEX "notifications_user_idx" ON "notifications" USING btree ("user_id","created_at");--> statement-breakpoint
|
||||||
|
CREATE INDEX "notifications_user_unread_idx" ON "notifications" USING btree ("user_id","read");
|
||||||
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
@@ -113,6 +113,27 @@
|
|||||||
"when": 1783105214683,
|
"when": 1783105214683,
|
||||||
"tag": "0015_aromatic_lester",
|
"tag": "0015_aromatic_lester",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 16,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1783107462796,
|
||||||
|
"tag": "0016_broken_nemesis",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 17,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1783108057675,
|
||||||
|
"tag": "0017_shallow_malcolm_colcord",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 18,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1783108229117,
|
||||||
|
"tag": "0018_mighty_butterfly",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -11,10 +11,13 @@ import { relations } from "drizzle-orm";
|
|||||||
import { users } from "./users";
|
import { users } from "./users";
|
||||||
import { recipes } from "./recipes";
|
import { recipes } from "./recipes";
|
||||||
|
|
||||||
export const feedItemTypeEnum = pgEnum("feed_item_type", [
|
export const notificationTypeEnum = pgEnum("notification_type", [
|
||||||
"new_recipe",
|
"follow",
|
||||||
"new_follow",
|
"comment",
|
||||||
"recipe_rated",
|
"reply",
|
||||||
|
"reaction",
|
||||||
|
"rating",
|
||||||
|
"mention",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export const ratings = pgTable("ratings", {
|
export const ratings = pgTable("ratings", {
|
||||||
@@ -78,17 +81,25 @@ export const cookingHistory = pgTable("cooking_history", {
|
|||||||
index("cooking_history_user_idx").on(t.userId),
|
index("cooking_history_user_idx").on(t.userId),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export const feedItems = pgTable("feed_items", {
|
export const notifications = pgTable("notifications", {
|
||||||
id: text("id").primaryKey(),
|
id: text("id").primaryKey(),
|
||||||
userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
|
userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }), // recipient
|
||||||
type: feedItemTypeEnum("type").notNull(),
|
type: notificationTypeEnum("type").notNull(),
|
||||||
actorId: text("actor_id").notNull().references(() => users.id, { onDelete: "cascade" }),
|
actorId: text("actor_id").notNull().references(() => users.id, { onDelete: "cascade" }),
|
||||||
subjectId: text("subject_id").notNull(),
|
recipeId: text("recipe_id").references(() => recipes.id, { onDelete: "cascade" }),
|
||||||
|
commentId: text("comment_id").references(() => comments.id, { onDelete: "cascade" }),
|
||||||
|
read: boolean("read").notNull().default(false),
|
||||||
createdAt: timestamp("created_at").notNull().defaultNow(),
|
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||||
}, (t) => [
|
}, (t) => [
|
||||||
index("feed_items_user_idx").on(t.userId, t.createdAt),
|
index("notifications_user_idx").on(t.userId, t.createdAt),
|
||||||
|
index("notifications_user_unread_idx").on(t.userId, t.read),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
export const notificationsRelations = relations(notifications, ({ one }) => ({
|
||||||
|
actor: one(users, { fields: [notifications.actorId], references: [users.id] }),
|
||||||
|
recipe: one(recipes, { fields: [notifications.recipeId], references: [recipes.id] }),
|
||||||
|
}));
|
||||||
|
|
||||||
export const ratingsRelations = relations(ratings, ({ one }) => ({
|
export const ratingsRelations = relations(ratings, ({ one }) => ({
|
||||||
recipe: one(recipes, { fields: [ratings.recipeId], references: [recipes.id] }),
|
recipe: one(recipes, { fields: [ratings.recipeId], references: [recipes.id] }),
|
||||||
user: one(users, { fields: [ratings.userId], references: [users.id] }),
|
user: one(users, { fields: [ratings.userId], references: [users.id] }),
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
boolean,
|
boolean,
|
||||||
integer,
|
integer,
|
||||||
pgEnum,
|
pgEnum,
|
||||||
|
primaryKey,
|
||||||
} from "drizzle-orm/pg-core";
|
} from "drizzle-orm/pg-core";
|
||||||
import { relations } from "drizzle-orm";
|
import { relations } from "drizzle-orm";
|
||||||
|
|
||||||
@@ -71,7 +72,9 @@ export const userFollows = pgTable("user_follows", {
|
|||||||
followerId: text("follower_id").notNull().references(() => users.id, { onDelete: "cascade" }),
|
followerId: text("follower_id").notNull().references(() => users.id, { onDelete: "cascade" }),
|
||||||
followingId: text("following_id").notNull().references(() => users.id, { onDelete: "cascade" }),
|
followingId: text("following_id").notNull().references(() => users.id, { onDelete: "cascade" }),
|
||||||
createdAt: timestamp("created_at").notNull().defaultNow(),
|
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||||
});
|
}, (t) => [
|
||||||
|
primaryKey({ columns: [t.followerId, t.followingId] }),
|
||||||
|
]);
|
||||||
|
|
||||||
export const apiKeys = pgTable("api_keys", {
|
export const apiKeys = pgTable("api_keys", {
|
||||||
id: text("id").primaryKey(),
|
id: text("id").primaryKey(),
|
||||||
|
|||||||
Reference in New Issue
Block a user