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:
1 parent
56f976e3c4
commit
7bdb2989ca
1 file changed
+134
-18
+134
-18
@@ -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,
|
||||
});
|
||||
}),
|
||||
});
|
||||
Reference in new issue
Block a user