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:
adminandClaude Sonnet 4.6 committed 2026-06-13 12:59:23 +02:00
1 parent 7fafc6d17f
commit 08fd0dfb71
10 files changed
+472 -43

No files matched your search

+2
View File
@@ -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
+33 -10
View File
@@ -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
+12 -1
View File
@@ -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;
}),
+64
View File
@@ -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 },
});
}),
});
+25 -3
View File
@@ -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 };
}),