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:
Arnaud
2026-07-03 21:56:34 +02:00
parent e0e1ac49d9
commit 1abab17ca8
21 changed files with 11216 additions and 53 deletions
+51 -40
View File
@@ -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,32 +156,36 @@ 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} />
<VersionHistoryButton {isOwner && (
recipeId={id} <>
currentSnapshot={{ <VersionHistoryButton
title: recipe.title, recipeId={id}
description: recipe.description, currentSnapshot={{
ingredients: recipe.ingredients.map((ing) => ({ title: recipe.title,
rawName: ing.rawName, description: recipe.description,
quantity: ing.quantity, ingredients: recipe.ingredients.map((ing) => ({
unit: ing.unit, rawName: ing.rawName,
note: ing.note, quantity: ing.quantity,
})), unit: ing.unit,
steps: recipe.steps.map((s) => ({ note: ing.note,
instruction: s.instruction, })),
timerSeconds: s.timerSeconds, 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" }))}> <Tooltip>
<Pencil className="h-4 w-4" /> <TooltipTrigger render={
</Link> <Link href={`/recipes/${id}/edit`} className={cn(buttonVariants({ variant: "ghost", size: "icon" }))}>
} /> <Pencil className="h-4 w-4" />
<TooltipContent>{m.recipe.edit}</TooltipContent> </Link>
</Tooltip> } />
<DeleteRecipeButton recipeId={id} /> <TooltipContent>{m.recipe.edit}</TooltipContent>
</Tooltip>
<DeleteRecipeButton recipeId={id} />
</>
)}
</div> </div>
</TooltipProvider> </TooltipProvider>
@@ -246,17 +255,19 @@ 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>
<div className="flex items-center gap-2"> {isOwner && (
<GenerateContentButton <div className="flex items-center gap-2">
recipeId={id} <GenerateContentButton
title={recipe.title} recipeId={id}
description={recipe.description} title={recipe.title}
/> description={recipe.description}
<Link href={`/recipes/${id}/edit`} className={cn(buttonVariants({ variant: "outline", size: "sm" }))}> />
<Pencil className="h-4 w-4" /> <Link href={`/recipes/${id}/edit`} className={cn(buttonVariants({ variant: "outline", size: "sm" }))}>
Edit manually <Pencil className="h-4 w-4" />
</Link> Edit manually
</div> </Link>
</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 });
} }
+2
View File
@@ -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>
);
}
+22
View File
@@ -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,
});
}
+12 -1
View File
@@ -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",
+12 -1
View File
@@ -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
} }
] ]
} }
+20 -9
View File
@@ -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] }),
+4 -1
View File
@@ -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(),