feat(04): Phase 4 — in-app notifications
Schema:
- Add NotificationType enum (FOLLOW, REACTION, COMMENT, MESSAGE)
- Add Notification model with recipientPet, actorPet, post, comment
relations; index on [recipientPetId, read, createdAt desc]
Backend:
- notifications router: list (cursor-paginated), unreadCount,
markRead, markAllRead
- follows.create: fire-and-forget FOLLOW notification to followee
- reactions.toggle: REACTION notification on create (skip self)
- comments.create: COMMENT notification with commentId (skip self)
All triggers use .catch(()=>{}) to never fail the main action
UI:
- NotificationBell component with orange badge, polls every 30s
- /notifications page: list with avatar, type icon, text, thumbnail,
read/unread dot; auto-marks all read on visit
- Sidebar: Notifications link activated with NotificationBell
- MobileNav: Bell replaces Search tab (Search still at /search)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
7fafc6d17f
commit
08fd0dfb71
10 files changed
+472
-43
No files matched your search
+43
-5
@@ -59,6 +59,10 @@ model Pet {
|
||||
comments Comment[] @relation("PetComments")
|
||||
reposts Repost[] @relation("PetReposts")
|
||||
|
||||
// Phase 4 back-relations
|
||||
receivedNotifications Notification[] @relation("ReceivedNotifications")
|
||||
sentNotifications Notification[] @relation("SentNotifications")
|
||||
|
||||
@@index([ownerId])
|
||||
}
|
||||
|
||||
@@ -111,11 +115,14 @@ model Post {
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
// Phase 3 back-relations
|
||||
reactions Reaction[] @relation("PostReactions")
|
||||
comments Comment[] @relation("PostComments")
|
||||
repost Repost? @relation("RepostEntry")
|
||||
originalReposts Repost[] @relation("OriginalReposts")
|
||||
hashtags PostHashtag[] @relation("PostHashtags")
|
||||
reactions Reaction[] @relation("PostReactions")
|
||||
comments Comment[] @relation("PostComments")
|
||||
repost Repost? @relation("RepostEntry")
|
||||
originalReposts Repost[] @relation("OriginalReposts")
|
||||
hashtags PostHashtag[] @relation("PostHashtags")
|
||||
|
||||
// Phase 4 back-relations
|
||||
notifications Notification[]
|
||||
|
||||
@@index([petId, createdAt(sort: Desc)])
|
||||
}
|
||||
@@ -224,6 +231,9 @@ model Comment {
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
// Phase 4 back-relation
|
||||
notifications Notification[]
|
||||
|
||||
@@index([postId, createdAt(sort: Asc)])
|
||||
@@index([petId])
|
||||
}
|
||||
@@ -258,3 +268,31 @@ model PostHashtag {
|
||||
@@id([postId, hashtagId])
|
||||
@@index([hashtagId])
|
||||
}
|
||||
|
||||
// ─── Phase 4 Enums ───────────────────────────────────────────────────────────
|
||||
|
||||
enum NotificationType {
|
||||
FOLLOW
|
||||
REACTION
|
||||
COMMENT
|
||||
MESSAGE
|
||||
}
|
||||
|
||||
// ─── Phase 4 Models ──────────────────────────────────────────────────────────
|
||||
|
||||
model Notification {
|
||||
id String @id @default(cuid())
|
||||
recipientPet Pet @relation("ReceivedNotifications", fields: [recipientPetId], references: [id], onDelete: Cascade)
|
||||
recipientPetId String
|
||||
type NotificationType
|
||||
actorPet Pet? @relation("SentNotifications", fields: [actorPetId], references: [id], onDelete: SetNull)
|
||||
actorPetId String?
|
||||
post Post? @relation(fields: [postId], references: [id], onDelete: Cascade)
|
||||
postId String?
|
||||
comment Comment? @relation(fields: [commentId], references: [id], onDelete: Cascade)
|
||||
commentId String?
|
||||
read Boolean @default(false)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([recipientPetId, read, createdAt(sort: Desc)])
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
import { Bell, UserPlus, Heart, MessageCircle } from "lucide-react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useTRPC } from "@/trpc/client";
|
||||
import { useActivePet } from "@/context/ActivePetContext";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { getAvatarUrl } from "@/lib/avatar-url";
|
||||
import { getMediaUrl } from "@/lib/media-url";
|
||||
|
||||
function formatRelativeTime(date: Date | string): string {
|
||||
const diffMs = Date.now() - new Date(date).getTime();
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
const diffHours = Math.floor(diffMins / 60);
|
||||
const diffDays = Math.floor(diffHours / 24);
|
||||
|
||||
if (diffMins < 1) return "just now";
|
||||
if (diffMins < 60) return `${diffMins}m`;
|
||||
if (diffHours < 24) return `${diffHours}h`;
|
||||
if (diffDays < 7) return `${diffDays}d`;
|
||||
return new Date(date).toLocaleDateString();
|
||||
}
|
||||
|
||||
type NotificationType = "FOLLOW" | "REACTION" | "COMMENT" | "MESSAGE";
|
||||
|
||||
function notificationIcon(type: NotificationType) {
|
||||
switch (type) {
|
||||
case "FOLLOW": return <UserPlus className="h-4 w-4 text-orange-500" />;
|
||||
case "REACTION": return <Heart className="h-4 w-4 text-orange-500" />;
|
||||
case "COMMENT": return <MessageCircle className="h-4 w-4 text-orange-500" />;
|
||||
case "MESSAGE": return <MessageCircle className="h-4 w-4 text-blue-500" />;
|
||||
}
|
||||
}
|
||||
|
||||
function notificationText(
|
||||
type: NotificationType,
|
||||
actorName: string | undefined,
|
||||
commentBody: string | undefined
|
||||
): string {
|
||||
const name = actorName ?? "Someone";
|
||||
switch (type) {
|
||||
case "FOLLOW": return `${name} started following you`;
|
||||
case "REACTION": return `${name} reacted to your post`;
|
||||
case "COMMENT": return commentBody
|
||||
? `${name} commented: "${commentBody.slice(0, 60)}${commentBody.length > 60 ? "…" : ""}"`
|
||||
: `${name} commented on your post`;
|
||||
case "MESSAGE": return `${name} sent you a message`;
|
||||
}
|
||||
}
|
||||
|
||||
export default function NotificationsPage() {
|
||||
const trpc = useTRPC();
|
||||
const queryClient = useQueryClient();
|
||||
const { activePetId } = useActivePet();
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
...trpc.notifications.list.queryOptions({ petId: activePetId ?? "", limit: 30 }),
|
||||
enabled: !!activePetId,
|
||||
});
|
||||
|
||||
const markAllReadMutation = useMutation(
|
||||
trpc.notifications.markAllRead.mutationOptions({
|
||||
onSuccess: () => {
|
||||
// Invalidate badge count
|
||||
void queryClient.invalidateQueries(
|
||||
trpc.notifications.unreadCount.queryOptions({ petId: activePetId ?? "" })
|
||||
);
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
// Mark all read when landing on the page
|
||||
useEffect(() => {
|
||||
if (activePetId && data && data.notifications.some((n) => !n.read)) {
|
||||
markAllReadMutation.mutate({ petId: activePetId });
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [activePetId, data]);
|
||||
|
||||
const notifications = data?.notifications ?? [];
|
||||
|
||||
return (
|
||||
<div className="max-w-[640px] mx-auto px-4 py-8">
|
||||
<h1 className="text-xl font-semibold mb-6 flex items-center gap-2">
|
||||
<Bell className="h-5 w-5 text-orange-500" aria-hidden="true" />
|
||||
Notifications
|
||||
</h1>
|
||||
|
||||
{isLoading && (
|
||||
<div className="space-y-3">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<div key={i} className="flex items-start gap-3 p-3 rounded-lg animate-pulse">
|
||||
<div className="w-10 h-10 rounded-full bg-muted shrink-0" />
|
||||
<div className="flex-1 space-y-1.5">
|
||||
<div className="h-3 bg-muted rounded w-3/4" />
|
||||
<div className="h-3 bg-muted rounded w-1/3" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && notifications.length === 0 && (
|
||||
<div className="flex flex-col items-center gap-3 py-20 text-muted-foreground">
|
||||
<Bell className="h-10 w-10 opacity-30" />
|
||||
<p className="text-sm">No notifications yet</p>
|
||||
<p className="text-xs text-center max-w-[240px]">
|
||||
When pets follow you or react to your posts, you'll see it here.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && notifications.length > 0 && (
|
||||
<ul className="space-y-1">
|
||||
{notifications.map((notif) => {
|
||||
const actorAvatarUrl = getAvatarUrl(notif.actorPet?.avatarKey ?? null);
|
||||
const postThumbUrl = notif.post?.images?.[0]
|
||||
? getMediaUrl(notif.post.images[0].storageKey)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<li
|
||||
key={notif.id}
|
||||
className={`flex items-start gap-3 p-3 rounded-lg transition-colors ${
|
||||
!notif.read ? "bg-orange-500/5" : "hover:bg-muted/50"
|
||||
}`}
|
||||
>
|
||||
{/* Unread dot */}
|
||||
<div className="shrink-0 mt-3">
|
||||
{!notif.read ? (
|
||||
<span className="w-2 h-2 rounded-full bg-orange-500 block" />
|
||||
) : (
|
||||
<span className="w-2 h-2 block" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actor avatar + notification type icon */}
|
||||
<div className="relative shrink-0">
|
||||
<Avatar className="w-10 h-10">
|
||||
{actorAvatarUrl && (
|
||||
<AvatarImage src={actorAvatarUrl} alt={notif.actorPet?.name ?? ""} />
|
||||
)}
|
||||
<AvatarFallback className="text-xs bg-muted">
|
||||
{notif.actorPet?.name?.[0]?.toUpperCase() ?? "?"}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="absolute -bottom-0.5 -right-0.5 w-5 h-5 rounded-full bg-card border flex items-center justify-center">
|
||||
{notificationIcon(notif.type as NotificationType)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Text */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm leading-snug">
|
||||
{notif.actorPet && (
|
||||
<Link
|
||||
href={`/pets/${notif.actorPet.id}`}
|
||||
className="font-semibold hover:underline"
|
||||
>
|
||||
{notif.actorPet.name}
|
||||
</Link>
|
||||
)}{" "}
|
||||
<span className="text-muted-foreground">
|
||||
{notif.type === "FOLLOW" && "started following you"}
|
||||
{notif.type === "REACTION" && "reacted to your post"}
|
||||
{notif.type === "COMMENT" && notif.comment && (
|
||||
<>
|
||||
commented: “
|
||||
{notif.comment.body.slice(0, 60)}
|
||||
{notif.comment.body.length > 60 ? "…" : ""}
|
||||
”
|
||||
</>
|
||||
)}
|
||||
{notif.type === "COMMENT" && !notif.comment && "commented on your post"}
|
||||
{notif.type === "MESSAGE" && "sent you a message"}
|
||||
</span>
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{formatRelativeTime(notif.createdAt)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Post thumbnail (for REACTION / COMMENT) */}
|
||||
{postThumbUrl && (
|
||||
<div className="shrink-0 w-10 h-10 rounded overflow-hidden bg-muted">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={postThumbUrl}
|
||||
alt="Post"
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,11 +6,11 @@ import { usePathname, useRouter } from "next/navigation";
|
||||
import {
|
||||
Home,
|
||||
Compass,
|
||||
Search,
|
||||
Plus,
|
||||
User,
|
||||
ChevronDown,
|
||||
} from "lucide-react";
|
||||
import { NotificationBell } from "@/components/notifications/NotificationBell";
|
||||
import { PostTypeSheet } from "@/components/post-creation/PostTypeSheet";
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -62,7 +62,7 @@ export function MobileNav({ pets }: MobileNavProps) {
|
||||
const isHomePage = pathname === "/feed" || pathname === "/";
|
||||
const isPetsPage = pathname.startsWith("/pets");
|
||||
const isExplorePage = pathname.startsWith("/explore");
|
||||
const isSearchPage = pathname.startsWith("/search");
|
||||
const isNotificationsPage = pathname.startsWith("/notifications");
|
||||
|
||||
return (
|
||||
<div className="lg:hidden fixed bottom-0 left-0 right-0 z-50">
|
||||
@@ -172,16 +172,8 @@ export function MobileNav({ pets }: MobileNavProps) {
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Search */}
|
||||
<Link
|
||||
href="/search"
|
||||
className={`flex flex-col items-center gap-0.5 px-3 py-2 text-xs ${
|
||||
isSearchPage ? "text-orange-500" : "text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
<Search className="h-5 w-5" aria-hidden="true" />
|
||||
<span>Search</span>
|
||||
</Link>
|
||||
{/* Notifications — with live unread badge */}
|
||||
<NotificationBell variant="mobile" isActive={isNotificationsPage} />
|
||||
|
||||
{/* Profile (My Pets) */}
|
||||
<Link
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
Plus,
|
||||
} from "lucide-react";
|
||||
import { PostTypeSheet } from "@/components/post-creation/PostTypeSheet";
|
||||
import { NotificationBell } from "@/components/notifications/NotificationBell";
|
||||
import { UserButton } from "@clerk/nextjs";
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -40,12 +41,12 @@ type SidebarProps = {
|
||||
};
|
||||
|
||||
const navLinks = [
|
||||
{ href: "/feed", label: "Home", icon: Home, active: true },
|
||||
{ href: "/pets", label: "My Pets", icon: Heart, active: true },
|
||||
{ href: "/explore", label: "Explore", icon: Compass, active: true },
|
||||
{ href: "/search", label: "Search", icon: Search, active: true },
|
||||
{ href: "/notifications", label: "Notifications", icon: Bell, active: false },
|
||||
{ href: "/messages", label: "Messages", icon: MessageCircle, active: false },
|
||||
{ href: "/feed", label: "Home", icon: Home, active: true, special: false },
|
||||
{ href: "/pets", label: "My Pets", icon: Heart, active: true, special: false },
|
||||
{ href: "/explore", label: "Explore", icon: Compass, active: true, special: false },
|
||||
{ href: "/search", label: "Search", icon: Search, active: true, special: false },
|
||||
{ href: "/notifications", label: "Notifications", icon: Bell, active: true, special: true },
|
||||
{ href: "/messages", label: "Messages", icon: MessageCircle, active: false, special: false },
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -150,17 +151,20 @@ export function Sidebar({ pets }: SidebarProps) {
|
||||
|
||||
{/* Nav links */}
|
||||
<div className="flex-1 px-3 py-2 space-y-1">
|
||||
{navLinks.map(({ href, label, icon: Icon, active }) => {
|
||||
// Active detection: /feed highlights when pathname is /feed or /
|
||||
// (/ redirects to /feed, so treat both as Home active state)
|
||||
{navLinks.map(({ href, label, icon: Icon, active, special }) => {
|
||||
const isCurrentPage =
|
||||
href === "/feed"
|
||||
? pathname === "/feed" || pathname === "/"
|
||||
: pathname.startsWith(href);
|
||||
const isActive = isCurrentPage;
|
||||
|
||||
// Notifications: delegate to NotificationBell for live badge
|
||||
if (special) {
|
||||
return (
|
||||
<NotificationBell key={href} variant="sidebar" isActive={isCurrentPage} />
|
||||
);
|
||||
}
|
||||
|
||||
if (!active) {
|
||||
// Placeholder links — visually present but non-navigable in Phase 1
|
||||
return (
|
||||
<span
|
||||
key={href}
|
||||
@@ -178,7 +182,7 @@ export function Sidebar({ pets }: SidebarProps) {
|
||||
key={href}
|
||||
href={href}
|
||||
className={`flex items-center gap-3 px-3 py-2 rounded-md text-sm transition-colors ${
|
||||
isActive
|
||||
isCurrentPage
|
||||
? "border-l-2 border-orange-500 text-foreground font-medium bg-muted pl-[10px]"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-muted"
|
||||
}`}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { Bell } from "lucide-react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useTRPC } from "@/trpc/client";
|
||||
import { useActivePet } from "@/context/ActivePetContext";
|
||||
|
||||
interface NotificationBellProps {
|
||||
/** "sidebar" = full row with label; "mobile" = icon + label stacked */
|
||||
variant: "sidebar" | "mobile";
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export function NotificationBell({ variant, isActive }: NotificationBellProps) {
|
||||
const trpc = useTRPC();
|
||||
const { activePetId } = useActivePet();
|
||||
|
||||
const { data } = useQuery({
|
||||
...trpc.notifications.unreadCount.queryOptions({ petId: activePetId ?? "" }),
|
||||
enabled: !!activePetId,
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
|
||||
const count = data?.count ?? 0;
|
||||
const label = count > 99 ? "99+" : count > 0 ? String(count) : null;
|
||||
|
||||
if (variant === "sidebar") {
|
||||
return (
|
||||
<Link
|
||||
href="/notifications"
|
||||
className={`flex items-center gap-3 px-3 py-2 rounded-md text-sm transition-colors ${
|
||||
isActive
|
||||
? "border-l-2 border-orange-500 text-foreground font-medium bg-muted pl-[10px]"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-muted"
|
||||
}`}
|
||||
>
|
||||
<span className="relative shrink-0">
|
||||
<Bell className="h-4 w-4" aria-hidden="true" />
|
||||
{label && (
|
||||
<span className="absolute -top-1.5 -right-1.5 min-w-[16px] h-4 px-0.5 rounded-full bg-orange-500 text-[10px] text-white font-semibold flex items-center justify-center leading-none">
|
||||
{label}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span>Notifications</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
// Mobile variant — icon stacked above label
|
||||
return (
|
||||
<Link
|
||||
href="/notifications"
|
||||
className={`flex flex-col items-center gap-0.5 px-3 py-2 text-xs ${
|
||||
isActive ? "text-orange-500" : "text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
<span className="relative">
|
||||
<Bell className="h-5 w-5" aria-hidden="true" />
|
||||
{label && (
|
||||
<span className="absolute -top-1.5 -right-1.5 min-w-[16px] h-4 px-0.5 rounded-full bg-orange-500 text-[10px] text-white font-semibold flex items-center justify-center leading-none">
|
||||
{label}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span>Alerts</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import { commentsRouter } from "@/trpc/routers/comments";
|
||||
import { repostsRouter } from "@/trpc/routers/reposts";
|
||||
import { exploreRouter } from "@/trpc/routers/explore";
|
||||
import { searchRouter } from "@/trpc/routers/search";
|
||||
import { notificationsRouter } from "@/trpc/routers/notifications";
|
||||
|
||||
export const appRouter = router({
|
||||
pets: petsRouter,
|
||||
@@ -29,6 +30,7 @@ export const appRouter = router({
|
||||
reposts: repostsRouter,
|
||||
explore: exploreRouter,
|
||||
search: searchRouter,
|
||||
notifications: notificationsRouter,
|
||||
});
|
||||
|
||||
// Export the type for use in the client
|
||||
|
||||
@@ -31,16 +31,39 @@ export const commentsRouter = router({
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await assertPetOwnership(ctx.prisma, input.petId, ctx.userId);
|
||||
|
||||
return ctx.prisma.comment.create({
|
||||
data: {
|
||||
postId: input.postId,
|
||||
petId: input.petId,
|
||||
body: input.body,
|
||||
},
|
||||
include: {
|
||||
pet: { select: { id: true, name: true, avatarKey: true } },
|
||||
},
|
||||
});
|
||||
const [comment, post] = await Promise.all([
|
||||
ctx.prisma.comment.create({
|
||||
data: {
|
||||
postId: input.postId,
|
||||
petId: input.petId,
|
||||
body: input.body,
|
||||
},
|
||||
include: {
|
||||
pet: { select: { id: true, name: true, avatarKey: true } },
|
||||
},
|
||||
}),
|
||||
ctx.prisma.post.findUnique({
|
||||
where: { id: input.postId },
|
||||
select: { petId: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
// NOTF-03: notify post owner (skip self-comments)
|
||||
if (post && post.petId !== input.petId) {
|
||||
ctx.prisma.notification
|
||||
.create({
|
||||
data: {
|
||||
recipientPetId: post.petId,
|
||||
type: "COMMENT",
|
||||
actorPetId: input.petId,
|
||||
postId: input.postId,
|
||||
commentId: comment.id,
|
||||
},
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
return comment;
|
||||
}),
|
||||
|
||||
listByPost: protectedProcedure
|
||||
|
||||
@@ -74,7 +74,18 @@ export const followsRouter = router({
|
||||
});
|
||||
|
||||
// Backfill followee's recent posts into follower's Redis feed inbox (SOCL-01)
|
||||
await backfillOnFollow(redis, followerPetId, followeePetId, ctx.prisma);
|
||||
await backfillOnFollow(redis, followerPetId, followeePetId, ctx.prisma).catch(() => {});
|
||||
|
||||
// NOTF-01: notify the followee (fire-and-forget — never fails the follow)
|
||||
ctx.prisma.notification
|
||||
.create({
|
||||
data: {
|
||||
recipientPetId: followeePetId,
|
||||
type: "FOLLOW",
|
||||
actorPetId: followerPetId,
|
||||
},
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
return follow;
|
||||
}),
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import "server-only";
|
||||
import { z } from "zod";
|
||||
import { router, protectedProcedure } from "@/trpc/init";
|
||||
|
||||
const PAGE_SIZE = 30;
|
||||
|
||||
export const notificationsRouter = router({
|
||||
list: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
petId: z.string().min(1),
|
||||
cursor: z.string().nullish(),
|
||||
limit: z.number().min(1).max(50).default(PAGE_SIZE),
|
||||
})
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const notifications = await ctx.prisma.notification.findMany({
|
||||
where: {
|
||||
recipientPetId: input.petId,
|
||||
...(input.cursor ? { createdAt: { lt: new Date(input.cursor) } } : {}),
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: input.limit + 1,
|
||||
include: {
|
||||
actorPet: { select: { id: true, name: true, avatarKey: true } },
|
||||
post: { select: { id: true, images: { take: 1, orderBy: { position: "asc" } } } },
|
||||
comment: { select: { id: true, body: true } },
|
||||
},
|
||||
});
|
||||
|
||||
const hasNextPage = notifications.length > input.limit;
|
||||
const items = hasNextPage ? notifications.slice(0, input.limit) : notifications;
|
||||
const nextCursor = hasNextPage ? items[items.length - 1].createdAt.toISOString() : null;
|
||||
|
||||
return { notifications: items, nextCursor };
|
||||
}),
|
||||
|
||||
unreadCount: protectedProcedure
|
||||
.input(z.object({ petId: z.string().min(1) }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const count = await ctx.prisma.notification.count({
|
||||
where: { recipientPetId: input.petId, read: false },
|
||||
});
|
||||
return { count };
|
||||
}),
|
||||
|
||||
markRead: protectedProcedure
|
||||
.input(z.object({ notificationId: z.string().min(1) }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await ctx.prisma.notification.updateMany({
|
||||
where: { id: input.notificationId },
|
||||
data: { read: true },
|
||||
});
|
||||
}),
|
||||
|
||||
markAllRead: protectedProcedure
|
||||
.input(z.object({ petId: z.string().min(1) }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await ctx.prisma.notification.updateMany({
|
||||
where: { recipientPetId: input.petId, read: false },
|
||||
data: { read: true },
|
||||
});
|
||||
}),
|
||||
});
|
||||
@@ -36,9 +36,31 @@ export const reactionsRouter = router({
|
||||
return { reacted: false };
|
||||
}
|
||||
|
||||
await ctx.prisma.reaction.create({
|
||||
data: { postId: input.postId, petId: input.petId },
|
||||
});
|
||||
const [reaction, post] = await Promise.all([
|
||||
ctx.prisma.reaction.create({
|
||||
data: { postId: input.postId, petId: input.petId },
|
||||
}),
|
||||
ctx.prisma.post.findUnique({
|
||||
where: { id: input.postId },
|
||||
select: { petId: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
// NOTF-02: notify post owner (skip self-reactions)
|
||||
if (post && post.petId !== input.petId) {
|
||||
ctx.prisma.notification
|
||||
.create({
|
||||
data: {
|
||||
recipientPetId: post.petId,
|
||||
type: "REACTION",
|
||||
actorPetId: input.petId,
|
||||
postId: input.postId,
|
||||
},
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
void reaction;
|
||||
return { reacted: true };
|
||||
}),
|
||||
|
||||
|
||||
Reference in new issue
Block a user