feat(02-02): implement followsRouter (GREEN)

- follows.create: assertPetOwnership + self-follow rejection + prisma.follow.create + backfillOnFollow
- follows.delete: assertPetOwnership + prisma.follow.delete by composite key
- follows.isFollowing: prisma.follow.findUnique → boolean
- follows.listFollowers: scoped by followeePetId, includes follower display fields
- follows.listFollowing: scoped by followerPetId, includes followee display fields
- All mutations use protectedProcedure; social graph is pet-to-pet never ownerId (SOCL-01/02/03)
This commit is contained in:
admin committed 2026-06-07 12:31:50 +02:00
1 parent 56f976e3c4
commit 7bdb2989ca
1 file changed
+134 -18
+134 -18
View File
@@ -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,
});
}),
});