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
@@ -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