From 75d1e806657d8a3758193b3c19092aa44a1104e7 Mon Sep 17 00:00:00 2001 From: neodk2004 Date: Sun, 7 Jun 2026 12:43:35 +0200 Subject: [PATCH] =?UTF-8?q?feat(02-04):=20implement=20postsRouter=20GREEN?= =?UTF-8?q?=20=E2=80=94=20PHOTO-01=E2=80=9304?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- src/trpc/routers/posts.ts | 228 ++++++++++++++++++++++++++++++-------- 1 file changed, 181 insertions(+), 47 deletions(-) diff --git a/src/trpc/routers/posts.ts b/src/trpc/routers/posts.ts index 86d8de8..aad3637 100644 --- a/src/trpc/routers/posts.ts +++ b/src/trpc/routers/posts.ts @@ -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 }; }), });