diff --git a/src/trpc/routers/follows.ts b/src/trpc/routers/follows.ts index f0ee7ad..82bbc6a 100644 --- a/src/trpc/routers/follows.ts +++ b/src/trpc/routers/follows.ts @@ -2,6 +2,30 @@ import "server-only"; import { z } from "zod"; import { TRPCError } from "@trpc/server"; import { router, protectedProcedure } from "@/trpc/init"; +import type { PrismaClient } from "@prisma/client"; +import { redis } from "@/lib/redis"; +import { backfillOnFollow } from "@/lib/feed-helpers"; + +/** + * Ownership assertion helper — verifies that a pet belongs to the requesting + * owner before any mutation or scoped query proceeds. + * + * Security (T-02-10): never trust client-supplied petId — acting pet must belong to ctx.userId. + */ +async function assertPetOwnership( + prisma: PrismaClient, + 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; +} /** * Follows tRPC router — pet-to-pet social graph (SOCL-01, SOCL-02, SOCL-03). @@ -10,16 +34,19 @@ import { router, protectedProcedure } from "@/trpc/init"; * On follow: Prisma INSERT Follow + Redis backfill of followee's recent posts. * On unfollow: Prisma DELETE Follow (no Redis cleanup — feed TTL handles it). * - * Procedure keys: - * create — follow a pet - * delete — unfollow a pet - * isFollowing — check follow status (used for optimistic updates) - * listFollowers — list pets following a given pet (SOCL-03) - * listFollowing — list pets a given pet is following (SOCL-03) - * - * Implementation: downstream Wave 1 plans. + * Security (T-02-10): assertPetOwnership before every write. + * Security (T-02-11): self-follow rejected with BAD_REQUEST. */ export const followsRouter = router({ + /** + * follow.create — follow another pet (SOCL-01) + * + * Input: { followerPetId, followeePetId } + * - asserts ctx.userId owns followerPetId + * - rejects self-follow (followerPetId === followeePetId) + * - inserts Follow row via composite primary key + * - calls backfillOnFollow to seed recent followee posts into follower's Redis feed + */ create: protectedProcedure .input( z.object({ @@ -27,10 +54,39 @@ export const followsRouter = router({ followeePetId: z.string().min(1), }) ) - .mutation(async () => { - throw new TRPCError({ code: "NOT_IMPLEMENTED" }); + .mutation(async ({ ctx, input }) => { + const { followerPetId, followeePetId } = input; + + // T-02-11: reject self-follow + if (followerPetId === followeePetId) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "A pet cannot follow itself.", + }); + } + + // T-02-10: assert ctx.userId owns the acting pet + await assertPetOwnership(ctx.prisma, followerPetId, ctx.userId); + + // Insert Follow row (composite PK prevents duplicates at DB layer) + const follow = await ctx.prisma.follow.create({ + data: { followerPetId, followeePetId }, + }); + + // Backfill followee's recent posts into follower's Redis feed inbox (SOCL-01) + await backfillOnFollow(redis, followerPetId, followeePetId, ctx.prisma); + + return follow; }), + /** + * follow.delete — unfollow a pet (SOCL-02) + * + * Input: { followerPetId, followeePetId } + * - asserts ctx.userId owns followerPetId + * - removes Follow row by composite key + * - no Redis cleanup: feed TTL (90 days) handles stale entries + */ delete: protectedProcedure .input( z.object({ @@ -38,10 +94,24 @@ export const followsRouter = router({ followeePetId: z.string().min(1), }) ) - .mutation(async () => { - throw new TRPCError({ code: "NOT_IMPLEMENTED" }); + .mutation(async ({ ctx, input }) => { + const { followerPetId, followeePetId } = input; + + // T-02-10: assert ctx.userId owns the acting pet + await assertPetOwnership(ctx.prisma, followerPetId, ctx.userId); + + return ctx.prisma.follow.delete({ + where: { + followerPetId_followeePetId: { followerPetId, followeePetId }, + }, + }); }), + /** + * follow.isFollowing — check if followerPetId follows followeePetId + * + * Returns boolean — used by FollowButton optimistic update pattern. + */ isFollowing: protectedProcedure .input( z.object({ @@ -49,10 +119,23 @@ export const followsRouter = router({ followeePetId: z.string().min(1), }) ) - .query(async () => { - throw new TRPCError({ code: "NOT_IMPLEMENTED" }); + .query(async ({ ctx, input }) => { + const follow = await ctx.prisma.follow.findUnique({ + where: { + followerPetId_followeePetId: { + followerPetId: input.followerPetId, + followeePetId: input.followeePetId, + }, + }, + }); + return follow !== null; }), + /** + * follow.listFollowers — list pets following a given pet (SOCL-03) + * + * Input: { petId } — returns Follow rows with follower pet display fields. + */ listFollowers: protectedProcedure .input( z.object({ @@ -61,10 +144,29 @@ export const followsRouter = router({ limit: z.number().min(1).max(100).default(20), }) ) - .query(async () => { - throw new TRPCError({ code: "NOT_IMPLEMENTED" }); + .query(async ({ ctx, input }) => { + return ctx.prisma.follow.findMany({ + where: { followeePetId: input.petId }, + include: { + follower: { + select: { + id: true, + name: true, + avatarKey: true, + species: { select: { name: true } }, + }, + }, + }, + orderBy: { createdAt: "desc" }, + take: input.limit, + }); }), + /** + * follow.listFollowing — list pets a given pet is following (SOCL-03) + * + * Input: { petId } — returns Follow rows with followee pet display fields. + */ listFollowing: protectedProcedure .input( z.object({ @@ -73,7 +175,21 @@ export const followsRouter = router({ limit: z.number().min(1).max(100).default(20), }) ) - .query(async () => { - throw new TRPCError({ code: "NOT_IMPLEMENTED" }); + .query(async ({ ctx, input }) => { + return ctx.prisma.follow.findMany({ + where: { followerPetId: input.petId }, + include: { + followee: { + select: { + id: true, + name: true, + avatarKey: true, + species: { select: { name: true } }, + }, + }, + }, + orderBy: { createdAt: "desc" }, + take: input.limit, + }); }), });