feat(02-03): implement feed.getFeed router with Redis + Prisma hydration

- assertPetOwnership enforces petId ownership (T-02-20)
- getFeedPage reads Redis sorted set with cursor pagination
- Block filter applied at Prisma hydration layer (T-02-21)
- Post order restored to Redis score order after Prisma findMany (Pitfall 7)
- FEED-01 tests green
This commit is contained in:
2026-06-07 12:31:36 +02:00
parent 2e1cf45d7a
commit 56f976e3c4
+71 -6
View File
@@ -2,6 +2,27 @@ import "server-only";
import { z } from "zod";
import { TRPCError } from "@trpc/server";
import { router, protectedProcedure } from "@/trpc/init";
import { redis } from "@/lib/redis";
import { getFeedPage } from "@/lib/feed-helpers";
/**
* Ownership assertion: verifies petId belongs to ctx.userId.
* Security (T-02-20): prevents feed spoofing via fabricated petId.
*/
async function assertPetOwnership(
prisma: Parameters<typeof feedRouter.createCaller>[0]["prisma"],
petId: string,
userId: string
) {
const pet = await prisma.pet.findUnique({ where: { id: petId } });
if (!pet || pet.ownerId !== userId) {
throw new TRPCError({
code: "FORBIDDEN",
message: "You do not have permission to access this pet.",
});
}
return pet;
}
/**
* Feed tRPC router — Redis sorted-set fan-out home feed (FEED-01).
@@ -10,11 +31,12 @@ import { router, protectedProcedure } from "@/trpc/init";
* a field named exactly `cursor` for useInfiniteQuery to work (Pitfall 6).
*
* Feed hydration order:
* 1. ZRANGE feed:{petId} (by score desc, cursor-based)
* 2. Block filter applied at Prisma level (pet: { id: { notIn: blockedPetIds } })
* 3. Post array sorted to match Redis score order (Pitfall 7)
* 1. assertPetOwnership: petId must belong to ctx.userId (T-02-20)
* 2. ZRANGE feed:{petId} (by score desc, cursor-based) via getFeedPage
* 3. Block filter applied at Prisma level (pet: { id: { notIn: blockedPetIds } }) (T-02-21)
* 4. Post array sorted to match Redis score order (Pitfall 7)
*
* Implementation: downstream Wave 1 plans.
* Own posts appear because fanOutPost ZADDs to feed:{petId} alongside follower fan-out.
*/
export const feedRouter = router({
getFeed: protectedProcedure
@@ -25,7 +47,50 @@ export const feedRouter = router({
limit: z.number().min(1).max(50).default(20),
})
)
.query(async () => {
throw new TRPCError({ code: "NOT_IMPLEMENTED" });
.query(async ({ ctx, input }) => {
// T-02-20: assert petId belongs to the requesting owner
await assertPetOwnership(ctx.prisma, input.petId, ctx.userId);
// Step 1: Get post IDs from Redis sorted set (cursor-based, newest first)
const { postIds, nextCursor } = await getFeedPage(
redis,
input.petId,
input.cursor ?? null
);
// No post IDs → return early with empty result
if (postIds.length === 0) {
return { posts: [], nextCursor: null };
}
// Step 2: Fetch blocked pet IDs (T-02-21 — authoritative in Postgres, never Redis)
const blockedEntries = await ctx.prisma.block.findMany({
where: { blockerPetId: input.petId },
select: { blockedPetId: true },
});
const blockedPetIds = blockedEntries.map((b) => b.blockedPetId);
// Step 3: Hydrate posts from Postgres with block filter applied
// Note: Prisma findMany does NOT preserve the `in` array order (Pitfall 7)
const rawPosts = await ctx.prisma.post.findMany({
where: {
id: { in: postIds },
pet: { id: { notIn: blockedPetIds } },
},
include: {
pet: { select: { id: true, name: true, avatarKey: true } },
images: { orderBy: { position: "asc" } },
milestone: true,
},
});
// Step 4: Re-order results to match Redis score order (Pitfall 7)
// Prisma returns results in DB natural order — we must preserve Redis ordering
const idToPost = new Map(rawPosts.map((p) => [p.id, p]));
const posts = postIds
.map((id) => idToPost.get(id))
.filter((p): p is NonNullable<typeof p> => p !== undefined);
return { posts, nextCursor };
}),
});