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:
@@ -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 };
|
||||
}),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user