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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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 { db, comments, commentReactions, eq, and, count } from "@epicure/db";
|
||||
import { requireSession } from "@/lib/api-auth";
|
||||
import { applyRateLimit } from "@/lib/rate-limit";
|
||||
import { createNotification } from "@/lib/notifications";
|
||||
|
||||
const ReactionSchema = z.object({
|
||||
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) {
|
||||
const { session, response } = await requireSession();
|
||||
if (response) return response;
|
||||
|
||||
const limited = await applyRateLimit(`rl:reaction:${session!.user.id}`, 60, 60);
|
||||
if (limited) return limited;
|
||||
|
||||
const { commentId } = await params;
|
||||
|
||||
// Verify comment exists
|
||||
@@ -85,6 +91,7 @@ export async function POST(req: NextRequest, { params }: Params) {
|
||||
type,
|
||||
});
|
||||
added = true;
|
||||
void createNotification({ userId: comment.userId, type: "reaction", actorId: userId, recipeId: comment.recipeId, commentId });
|
||||
}
|
||||
|
||||
// Return updated counts
|
||||
|
||||
@@ -2,8 +2,10 @@ import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { db, recipes, comments, users, eq, and } from "@epicure/db";
|
||||
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";
|
||||
|
||||
const Schema = z.object({
|
||||
content: z.string().min(1).max(5000),
|
||||
@@ -44,6 +46,9 @@ export async function POST(req: NextRequest, { params }: Params) {
|
||||
if (response) return response;
|
||||
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) });
|
||||
if (!recipe || (recipe.visibility === "private" && recipe.authorId !== session!.user.id)) {
|
||||
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);
|
||||
if (!parsed.success) return NextResponse.json({ error: "Validation error" }, { status: 400 });
|
||||
|
||||
let parent: { id: string; userId: string } | undefined;
|
||||
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)),
|
||||
});
|
||||
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}`,
|
||||
});
|
||||
}
|
||||
|
||||
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 });
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { db, recipes, ratings, eq, and } from "@epicure/db";
|
||||
import { requireSession } from "@/lib/api-auth";
|
||||
import { createNotification } from "@/lib/notifications";
|
||||
|
||||
const Schema = z.object({
|
||||
score: z.number().int().min(1).max(5),
|
||||
@@ -45,5 +46,6 @@ export async function POST(req: NextRequest, { params }: Params) {
|
||||
score: parsed.data.score,
|
||||
reviewText: parsed.data.reviewText,
|
||||
});
|
||||
void createNotification({ userId: recipe.authorId, type: "rating", actorId: session!.user.id, recipeId: id });
|
||||
return NextResponse.json({ created: true }, { status: 201 });
|
||||
}
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { db, users, userFollows, eq, and } from "@epicure/db";
|
||||
import { requireSession } from "@/lib/api-auth";
|
||||
import { applyRateLimit } from "@/lib/rate-limit";
|
||||
import { createNotification } from "@/lib/notifications";
|
||||
|
||||
type Params = { params: Promise<{ username: string }> };
|
||||
|
||||
export async function POST(_req: NextRequest, { params }: Params) {
|
||||
const { session, response } = await requireSession();
|
||||
if (response) return response;
|
||||
|
||||
const limited = await applyRateLimit(`rl:follow:${session!.user.id}`, 30, 60);
|
||||
if (limited) return limited;
|
||||
|
||||
const { username } = await params;
|
||||
|
||||
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 })
|
||||
.onConflictDoNothing();
|
||||
|
||||
void createNotification({ userId: target.id, type: "follow", actorId: session!.user.id });
|
||||
|
||||
return NextResponse.json({ following: true });
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
SheetTrigger,
|
||||
} from "@/components/ui/sheet";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { NotificationBell } from "@/components/social/notification-bell";
|
||||
import { authClient } from "@/lib/auth/client";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
@@ -100,6 +101,7 @@ export function Nav() {
|
||||
))}
|
||||
</nav>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<NotificationBell />
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger className="rounded-full outline-none focus-visible:ring-2 focus-visible:ring-ring">
|
||||
<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",
|
||||
"apiKeys": "API keys",
|
||||
"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": {
|
||||
"share": "Share",
|
||||
|
||||
@@ -14,7 +14,18 @@
|
||||
"signOut": "Se déconnecter",
|
||||
"apiKeys": "Clés API",
|
||||
"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": {
|
||||
"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,
|
||||
"tag": "0015_aromatic_lester",
|
||||
"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 { recipes } from "./recipes";
|
||||
|
||||
export const feedItemTypeEnum = pgEnum("feed_item_type", [
|
||||
"new_recipe",
|
||||
"new_follow",
|
||||
"recipe_rated",
|
||||
export const notificationTypeEnum = pgEnum("notification_type", [
|
||||
"follow",
|
||||
"comment",
|
||||
"reply",
|
||||
"reaction",
|
||||
"rating",
|
||||
"mention",
|
||||
]);
|
||||
|
||||
export const ratings = pgTable("ratings", {
|
||||
@@ -78,17 +81,25 @@ export const cookingHistory = pgTable("cooking_history", {
|
||||
index("cooking_history_user_idx").on(t.userId),
|
||||
]);
|
||||
|
||||
export const feedItems = pgTable("feed_items", {
|
||||
export const notifications = pgTable("notifications", {
|
||||
id: text("id").primaryKey(),
|
||||
userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
|
||||
type: feedItemTypeEnum("type").notNull(),
|
||||
userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }), // recipient
|
||||
type: notificationTypeEnum("type").notNull(),
|
||||
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(),
|
||||
}, (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 }) => ({
|
||||
recipe: one(recipes, { fields: [ratings.recipeId], references: [recipes.id] }),
|
||||
user: one(users, { fields: [ratings.userId], references: [users.id] }),
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
boolean,
|
||||
integer,
|
||||
pgEnum,
|
||||
primaryKey,
|
||||
} from "drizzle-orm/pg-core";
|
||||
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" }),
|
||||
followingId: text("following_id").notNull().references(() => users.id, { onDelete: "cascade" }),
|
||||
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||
});
|
||||
}, (t) => [
|
||||
primaryKey({ columns: [t.followerId, t.followingId] }),
|
||||
]);
|
||||
|
||||
export const apiKeys = pgTable("api_keys", {
|
||||
id: text("id").primaryKey(),
|
||||
|
||||
Reference in New Issue
Block a user