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:
1 parent
fbaceac497
commit
b3d0551bd8
5 files changed
+114
-4
No files matched your search
@@ -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(),
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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":
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}),
|
||||
});
|
||||
Reference in new issue
Block a user