feat(02-04): implement postsRouter GREEN — PHOTO-01–04

- getPresignedUrl: scopes key to posts/{ownerId}/{petId}/, ownership check (T-02-30)
- create: ownership assertion + Post+PostImage nested write + fanOutPost (PHOTO-01)
- create: imageKeys.max(10) enforces carousel limit (PHOTO-02, T-02-32)
- imageKeys.startsWith('posts/') prevents path traversal (T-02-31)
- updateCaption: assertPostOwnership via post→pet→ownerId (PHOTO-03, T-02-34)
- delete: assertPostOwnership + prisma.post.delete, PostImages cascade (PHOTO-04)
- byPetId: cursor-paginated query with images+pet+milestone include

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Sonnet 4.6 committed 2026-06-07 12:43:35 +02:00
1 parent 5e71010e50
commit 75d1e80665
1 file changed
+181 -47
+181 -47
View File
@@ -2,52 +2,191 @@ import "server-only";
import { z } from "zod";
import { TRPCError } from "@trpc/server";
import { router, protectedProcedure } from "@/trpc/init";
import {
createSignedUploadUrl,
ALLOWED_CONTENT_TYPES,
MAX_FILE_SIZE,
} from "@/lib/supabase-storage";
import { redis } from "@/lib/redis";
import { fanOutPost } from "@/lib/feed-helpers";
import { randomUUID } from "crypto";
import type { PrismaClient } from "@prisma/client";
/**
* Posts tRPC router — photo post creation, caption edit, deletion, feed hydration.
*
* Security: All mutations assert pet ownership via ctx.userId.
* Procedure keys:
* create — create a photo or milestone post (1–10 images)
* updateCaption — edit caption on an existing post
* delete — delete post + cascade images + remove from Redis feed
* byPetId — list posts for a given pet (paginated)
* getPresignedUrl — generate presigned upload URL for post images
* confirmUpload — validate storage key and save post to DB
*
* Implementation: downstream Wave 1 plans (02-02 onwards).
* Security (T-02-30 to T-02-35):
* - All mutations assert pet ownership via ctx.userId before any write
* - imageKeys validated with .startsWith('posts/') — prevents path traversal (T-02-31)
* - imageKeys.max(10) — prevents carousel injection (T-02-32)
* - getPresignedUrl scopes key to posts/{ownerId}/{petId}/ (T-02-31)
* - supabase-storage.ts is server-only — client only receives signed URL (T-02-33)
* - updateCaption/delete assert ownership via post→pet→ownerId (T-02-34)
*/
/**
* Shared ownership helper for post-level checks.
* Loads post with pet relation and throws FORBIDDEN if ctx.userId doesn't own the pet.
*/
async function assertPostOwnership(
prisma: PrismaClient,
postId: string,
userId: string
) {
const post = await prisma.post.findUnique({
where: { id: postId },
select: {
id: true,
petId: true,
pet: { select: { id: true, ownerId: true } },
},
});
if (!post || post.pet.ownerId !== userId) {
throw new TRPCError({ code: "FORBIDDEN" });
}
return post;
}
export const postsRouter = router({
/**
* Generate a presigned PUT URL for direct upload to Supabase Storage.
* Key scoped to posts/{ownerId}/{petId}/{uuid}.ext (T-02-31).
* Ownership assertion prevents non-owners from getting URLs for another pet (T-02-30).
*/
getPresignedUrl: protectedProcedure
.input(
z.object({
petId: z.string().min(1),
contentType: z.enum(ALLOWED_CONTENT_TYPES),
fileSize: z
.number()
.max(MAX_FILE_SIZE, "Photo must be under 10 MB"),
})
)
.mutation(async ({ ctx, input }) => {
// Ownership assertion (T-02-30)
const pet = await ctx.prisma.pet.findUnique({
where: { id: input.petId },
});
if (!pet || pet.ownerId !== ctx.userId) {
throw new TRPCError({ code: "FORBIDDEN" });
}
const ext = input.contentType.split("/")[1].replace("jpeg", "jpg");
// Key scoped to posts/{ownerId}/{petId}/ (T-02-31 path-traversal prevention)
const key = `posts/${ctx.userId}/${input.petId}/${randomUUID()}.${ext}`;
const { signedUrl } = await createSignedUploadUrl(key);
return { presignedUrl: signedUrl, key };
}),
/**
* Create a photo post with 1–10 images + fan-out to follower feeds.
*
* imageKeys validated:
* - .startsWith('posts/') — path-traversal prevention (T-02-31, RESEARCH Pitfall 4)
* - .min(1).max(10) — PHOTO-02 carousel limit (T-02-32)
*
* Fan-out: fanOutPost adds this post to every follower's Redis sorted set.
*/
create: protectedProcedure
.input(
z.object({
petId: z.string().min(1),
caption: z.string().max(500).optional(),
imageKeys: z.array(z.string().startsWith("posts/")).min(1).max(10),
type: z.enum(["PHOTO"]).default("PHOTO"),
caption: z.string().max(500).nullable().optional(),
imageKeys: z
.array(z.string().startsWith("posts/"))
.min(1)
.max(10),
})
)
.mutation(async () => {
throw new TRPCError({ code: "NOT_IMPLEMENTED" });
.mutation(async ({ ctx, input }) => {
// Ownership assertion (T-02-30): posting as another pet blocked
const pet = await ctx.prisma.pet.findUnique({
where: { id: input.petId },
});
if (!pet || pet.ownerId !== ctx.userId) {
throw new TRPCError({ code: "FORBIDDEN" });
}
// Create Post + PostImage[] in one nested write
const post = await ctx.prisma.post.create({
data: {
petId: input.petId,
type: "PHOTO",
caption: input.caption ?? null,
images: {
create: input.imageKeys.map((storageKey, position) => ({
storageKey,
position,
})),
},
},
include: {
images: { orderBy: { position: "asc" } },
pet: { select: { id: true, name: true, avatarKey: true } },
milestone: true,
},
});
// Fan-out to all followers' Redis feed inboxes (PHOTO-01)
await fanOutPost(redis, post.id, input.petId, post.createdAt, ctx.prisma);
return post;
}),
/**
* Edit the caption on a post the caller owns (PHOTO-03).
* Ownership asserted via post→pet→ownerId.
*/
updateCaption: protectedProcedure
.input(
z.object({
postId: z.string().min(1),
caption: z.string().max(500),
caption: z.string().max(500).nullable(),
})
)
.mutation(async () => {
throw new TRPCError({ code: "NOT_IMPLEMENTED" });
.mutation(async ({ ctx, input }) => {
// Ownership assertion (T-02-34)
await assertPostOwnership(ctx.prisma, input.postId, ctx.userId);
const updated = await ctx.prisma.post.update({
where: { id: input.postId },
data: { caption: input.caption },
include: {
images: { orderBy: { position: "asc" } },
pet: { select: { id: true, name: true, avatarKey: true } },
milestone: true,
},
});
return updated;
}),
/**
* Delete a post + cascade PostImages (PHOTO-04).
* Ownership asserted via post→pet→ownerId.
* Note: feed cleanup (ZREM across follower sets) is best-effort in Phase 2.
* feed.getFeed already filters deleted post IDs since findMany won't return them.
*/
delete: protectedProcedure
.input(z.object({ postId: z.string().min(1) }))
.mutation(async () => {
throw new TRPCError({ code: "NOT_IMPLEMENTED" });
.mutation(async ({ ctx, input }) => {
// Ownership assertion (T-02-34)
await assertPostOwnership(ctx.prisma, input.postId, ctx.userId);
// PostImages cascade via schema onDelete: Cascade
await ctx.prisma.post.delete({ where: { id: input.postId } });
return { deleted: true };
}),
/**
* List posts for a given pet, ordered newest first.
* Includes images and milestone relation for rendering PostCard.
*/
byPetId: protectedProcedure
.input(
z.object({
@@ -56,34 +195,29 @@ export const postsRouter = router({
limit: z.number().min(1).max(50).default(20),
})
)
.query(async () => {
throw new TRPCError({ code: "NOT_IMPLEMENTED" });
}),
.query(async ({ ctx, input }) => {
const posts = await ctx.prisma.post.findMany({
where: {
petId: input.petId,
...(input.cursor
? { createdAt: { lt: new Date(input.cursor) } }
: {}),
},
orderBy: { createdAt: "desc" },
take: input.limit + 1,
include: {
images: { orderBy: { position: "asc" } },
pet: { select: { id: true, name: true, avatarKey: true } },
milestone: true,
},
});
getPresignedUrl: protectedProcedure
.input(
z.object({
petId: z.string().min(1),
contentType: z.enum(["image/jpeg", "image/png", "image/webp"]),
fileSize: z.number().max(10 * 1024 * 1024),
})
)
.mutation(async () => {
throw new TRPCError({ code: "NOT_IMPLEMENTED" });
}),
const hasNextPage = posts.length > input.limit;
const items = hasNextPage ? posts.slice(0, input.limit) : posts;
const nextCursor = hasNextPage
? items[items.length - 1].createdAt.toISOString()
: null;
confirmUpload: protectedProcedure
.input(
z.object({
petId: z.string().min(1),
// Path traversal prevention (T-02, mirrors media.confirmUpload for avatars/)
key: z.string().startsWith("posts/"),
position: z.number().int().min(0).max(9),
width: z.number().int().positive().optional(),
height: z.number().int().positive().optional(),
})
)
.mutation(async () => {
throw new TRPCError({ code: "NOT_IMPLEMENTED" });
return { posts: items, nextCursor };
}),
});