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:
1 parent
2e1cf45d7a
commit
56f976e3c4
1 file changed
+71
-6
@@ -2,6 +2,27 @@ import "server-only";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
import { router, protectedProcedure } from "@/trpc/init";
|
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).
|
* 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).
|
* a field named exactly `cursor` for useInfiniteQuery to work (Pitfall 6).
|
||||||
*
|
*
|
||||||
* Feed hydration order:
|
* Feed hydration order:
|
||||||
* 1. ZRANGE feed:{petId} (by score desc, cursor-based)
|
* 1. assertPetOwnership: petId must belong to ctx.userId (T-02-20)
|
||||||
* 2. Block filter applied at Prisma level (pet: { id: { notIn: blockedPetIds } })
|
* 2. ZRANGE feed:{petId} (by score desc, cursor-based) via getFeedPage
|
||||||
* 3. Post array sorted to match Redis score order (Pitfall 7)
|
* 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({
|
export const feedRouter = router({
|
||||||
getFeed: protectedProcedure
|
getFeed: protectedProcedure
|
||||||
@@ -25,7 +47,50 @@ export const feedRouter = router({
|
|||||||
limit: z.number().min(1).max(50).default(20),
|
limit: z.number().min(1).max(50).default(20),
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
.query(async () => {
|
.query(async ({ ctx, input }) => {
|
||||||
throw new TRPCError({ code: "NOT_IMPLEMENTED" });
|
// 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