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:
1 parent
5e71010e50
commit
75d1e80665
1 file changed
+181
-47
+181
-47
@@ -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 };
|
||||
}),
|
||||
});
|
||||
Reference in new issue
Block a user