fix(notifications): deep-link like/comment/mention notifications to the actual post

Notification rows for REACTION/COMMENT/MENTION/PAW_BACK linked to the post
owner's profile page instead of the specific post — there was no way to
land on the post itself. Add posts.getById for direct post lookups and
have the pet profile page pick up a ?post= query param to auto-open the
post detail dialog, falling back to a fetch when the post isn't among the
most recently loaded ones. Follow notifications already linked correctly
to the actor's profile.
This commit is contained in:
admin committed 2026-08-14 14:36:25 +02:00
1 parent fbaceac497
commit b3d0551bd8
5 files changed
+114 -4

No files matched your search

+2
View File
@@ -68,6 +68,7 @@ interface MockPostClient {
create: MockFn;
findMany: MockFn;
findUnique: MockFn;
findFirst: MockFn;
update: MockFn;
delete: MockFn;
count: MockFn;
@@ -355,6 +356,7 @@ export function createMockPrisma(): MockPrismaClient {
create: vi.fn(),
findMany: vi.fn(),
findUnique: vi.fn(),
findFirst: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
count: vi.fn(),
+38
View File
@@ -225,3 +225,41 @@ describe("posts.delete", () => {
});
});
});
describe("posts.getById", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("returns the post when found and visible", async () => {
const { caller, mockPrisma } = createCaller();
const post = {
id: "post-1",
petId: "pet-1",
caption: "hi",
pet: { id: "pet-1", name: "Rex", avatarKey: null },
images: [],
milestone: null,
videoPost: null,
repost: null,
};
mockPrisma.post.findFirst.mockResolvedValue(post);
const result = await caller.getById({ postId: "post-1" });
expect(result).toEqual(post);
expect(mockPrisma.post.findFirst).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({ id: "post-1", hiddenAt: null }),
})
);
});
it("throws NOT_FOUND when the post doesn't exist or isn't visible", async () => {
const { caller, mockPrisma } = createCaller();
mockPrisma.post.findFirst.mockResolvedValue(null);
await expect(caller.getById({ postId: "missing" })).rejects.toThrow(TRPCError);
});
});
+3 -1
View File
@@ -70,7 +70,9 @@ export default function NotificationsPage() {
case "COMMENT":
case "MENTION":
case "PAW_BACK":
return notif.post?.petId ? `/pets/${notif.post.petId}` : null;
return notif.post?.petId
? `/pets/${notif.post.petId}?post=${notif.post.id}`
: null;
case "MESSAGE":
return null;
case "BIRTHDAY":
+33 -3
View File
@@ -1,6 +1,7 @@
"use client";
import { useState, useCallback } from "react";
import { useState, useCallback, useEffect } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { Layers2, Play } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { useTranslations } from "next-intl";
@@ -26,6 +27,8 @@ interface ProfileTabsProps {
export function ProfileTabs({ petId }: ProfileTabsProps) {
const t = useTranslations("ProfileTabs");
const trpc = useTRPC();
const router = useRouter();
const searchParams = useSearchParams();
const [_activeTab, setActiveTab] = useState("posts");
const [selectedPost, setSelectedPost] = useState<PostCardPost | null>(null);
@@ -40,10 +43,37 @@ export function ProfileTabs({ petId }: ProfileTabsProps) {
const posts = postsData?.posts ?? [];
const milestones = (milestonesData ?? []) as PostCardPost[];
// Deep link from a notification (?post=<id>) — open the dialog directly,
// falling back to a dedicated fetch when the post isn't among the ones
// already loaded (e.g. older than the most recent 30).
const deepLinkPostId = searchParams.get("post");
const deepLinkPost = deepLinkPostId
? (posts.find((p) => p.id === deepLinkPostId) ?? null)
: null;
const { data: fetchedDeepLinkPost } = useQuery({
...trpc.posts.getById.queryOptions({ postId: deepLinkPostId ?? "" }),
enabled: !!deepLinkPostId && !deepLinkPost,
});
useEffect(() => {
if (!deepLinkPostId) return;
const post = deepLinkPost ?? (fetchedDeepLinkPost as PostCardPost | undefined) ?? null;
if (post) setSelectedPost(post);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [deepLinkPostId, deepLinkPost, fetchedDeepLinkPost]);
const handleTabChange = useCallback((tab: string) => {
setActiveTab(tab);
}, []);
const handleCloseDialog = useCallback(() => {
setSelectedPost(null);
if (searchParams.get("post")) {
router.replace(`/pets/${petId}`, { scroll: false });
}
}, [petId, router, searchParams]);
return (
<>
<Tabs defaultValue="posts" onValueChange={handleTabChange}>
@@ -134,8 +164,8 @@ export function ProfileTabs({ petId }: ProfileTabsProps) {
<PostDetailDialog
post={selectedPost}
open={!!selectedPost}
onClose={() => setSelectedPost(null)}
onDeleted={() => setSelectedPost(null)}
onClose={handleCloseDialog}
onDeleted={handleCloseDialog}
/>
</>
);
+38
View File
@@ -274,4 +274,42 @@ export const postsRouter = router({
return { posts: items, nextCursor };
}),
/**
* Fetch a single post by id, for deep-linking (e.g. notifications) into the
* detail dialog without requiring the post to be among the pet's most
* recently loaded posts. Same visibility rule as byPetId.
*/
getById: protectedProcedure
.input(z.object({ postId: z.string().min(1) }))
.query(async ({ ctx, input }) => {
const post = await ctx.prisma.post.findFirst({
where: {
id: input.postId,
hiddenAt: null,
pet: { OR: [{ ownerId: ctx.userId }, { owner: { shadowban: null } }] },
},
include: {
images: { orderBy: { position: "asc" } },
pet: { select: { id: true, name: true, avatarKey: true } },
milestone: true,
videoPost: { select: { status: true, muxPlaybackId: true } },
repost: {
include: {
originalPost: {
include: {
pet: { select: { id: true, name: true, avatarKey: true } },
images: { orderBy: { position: "asc" } },
milestone: true,
},
},
},
},
},
});
if (!post) throw new TRPCError({ code: "NOT_FOUND" });
return post;
}),
});