diff --git a/src/__tests__/helpers/prisma-mock.ts b/src/__tests__/helpers/prisma-mock.ts index 4297e5e..bc9b296 100644 --- a/src/__tests__/helpers/prisma-mock.ts +++ b/src/__tests__/helpers/prisma-mock.ts @@ -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(), diff --git a/src/__tests__/posts.test.ts b/src/__tests__/posts.test.ts index 0fa8ab4..72027ef 100644 --- a/src/__tests__/posts.test.ts +++ b/src/__tests__/posts.test.ts @@ -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); + }); +}); diff --git a/src/app/(app)/notifications/page.tsx b/src/app/(app)/notifications/page.tsx index 5a8e5d3..d91ed6f 100644 --- a/src/app/(app)/notifications/page.tsx +++ b/src/app/(app)/notifications/page.tsx @@ -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": diff --git a/src/components/profile/ProfileTabs.tsx b/src/components/profile/ProfileTabs.tsx index ab5f2cd..ccb8b63 100644 --- a/src/components/profile/ProfileTabs.tsx +++ b/src/components/profile/ProfileTabs.tsx @@ -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(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=) — 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 ( <> @@ -134,8 +164,8 @@ export function ProfileTabs({ petId }: ProfileTabsProps) { setSelectedPost(null)} - onDeleted={() => setSelectedPost(null)} + onClose={handleCloseDialog} + onDeleted={handleCloseDialog} /> ); diff --git a/src/trpc/routers/posts.ts b/src/trpc/routers/posts.ts index 37f8024..15c1645 100644 --- a/src/trpc/routers/posts.ts +++ b/src/trpc/routers/posts.ts @@ -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; + }), });