diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 22c33b3..ef7ee7c 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -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)])
+}
diff --git a/src/app/(app)/notifications/page.tsx b/src/app/(app)/notifications/page.tsx
new file mode 100644
index 0000000..00c4f1b
--- /dev/null
+++ b/src/app/(app)/notifications/page.tsx
@@ -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 ;
+ case "REACTION": return ;
+ case "COMMENT": return ;
+ case "MESSAGE": return ;
+ }
+}
+
+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 (
+
+
+
+ Notifications
+
+
+ {isLoading && (
+
+ {[...Array(5)].map((_, i) => (
+
+ ))}
+
+ )}
+
+ {!isLoading && notifications.length === 0 && (
+
+
+
No notifications yet
+
+ When pets follow you or react to your posts, you'll see it here.
+
+
+ )}
+
+ {!isLoading && notifications.length > 0 && (
+
+ )}
+
+ );
+}
diff --git a/src/components/layout/MobileNav.tsx b/src/components/layout/MobileNav.tsx
index 1b923bc..84f31da 100644
--- a/src/components/layout/MobileNav.tsx
+++ b/src/components/layout/MobileNav.tsx
@@ -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 (
@@ -172,16 +172,8 @@ export function MobileNav({ pets }: MobileNavProps) {
- {/* Search */}
-
-
- Search
-
+ {/* Notifications — with live unread badge */}
+
{/* Profile (My Pets) */}
- {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 (
+
+ );
+ }
if (!active) {
- // Placeholder links — visually present but non-navigable in Phase 1
return (
99 ? "99+" : count > 0 ? String(count) : null;
+
+ if (variant === "sidebar") {
+ return (
+
+
+
+ {label && (
+
+ {label}
+
+ )}
+
+ Notifications
+
+ );
+ }
+
+ // Mobile variant — icon stacked above label
+ return (
+
+
+
+ {label && (
+
+ {label}
+
+ )}
+
+ Alerts
+
+ );
+}
diff --git a/src/trpc/routers/_app.ts b/src/trpc/routers/_app.ts
index 0621ef1..b9aaa11 100644
--- a/src/trpc/routers/_app.ts
+++ b/src/trpc/routers/_app.ts
@@ -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
diff --git a/src/trpc/routers/comments.ts b/src/trpc/routers/comments.ts
index e5a4366..8abef1b 100644
--- a/src/trpc/routers/comments.ts
+++ b/src/trpc/routers/comments.ts
@@ -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
diff --git a/src/trpc/routers/follows.ts b/src/trpc/routers/follows.ts
index ea6e1da..2b40ce1 100644
--- a/src/trpc/routers/follows.ts
+++ b/src/trpc/routers/follows.ts
@@ -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;
}),
diff --git a/src/trpc/routers/notifications.ts b/src/trpc/routers/notifications.ts
new file mode 100644
index 0000000..8282e6b
--- /dev/null
+++ b/src/trpc/routers/notifications.ts
@@ -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 },
+ });
+ }),
+});
diff --git a/src/trpc/routers/reactions.ts b/src/trpc/routers/reactions.ts
index 47a05f6..0cba791 100644
--- a/src/trpc/routers/reactions.ts
+++ b/src/trpc/routers/reactions.ts
@@ -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 };
}),