From c435404b2d04381c07dd6e1b0c33826be30c249f Mon Sep 17 00:00:00 2001 From: neodk2004 Date: Sun, 7 Jun 2026 12:23:10 +0200 Subject: [PATCH] feat(02-01): shared libs + 7 tRPC router stubs registered in _app.ts - src/lib/redis.ts: @upstash/redis singleton (server-only) - src/lib/feed-helpers.ts: feedKey, fanOutPost, backfillOnFollow, getFeedPage, trimFeed, FEED_RETENTION_MS - src/lib/media-url.ts: getMediaUrl (mirrors getAvatarUrl signature, client-safe) - src/lib/milestone-thresholds.ts: FOLLOWER_MILESTONE_THRESHOLDS constant (D-09, A3) - Router stubs: posts, stories, milestones, follows, blocks, reports, feed (all NOT_IMPLEMENTED) - All 9 routers registered in _app.ts (pets, media + 7 new) - feed.getFeed input includes cursor: z.number().nullish() per Pitfall 6 - Rule 1 fix: @upstash/redis zrange requires {offset, count} not just count - npx tsc --noEmit exits 0 --- src/lib/feed-helpers.ts | 129 ++++++++++++++++++++++++++++++++ src/lib/media-url.ts | 11 +++ src/lib/milestone-thresholds.ts | 25 +++++++ src/lib/redis.ts | 16 ++++ src/trpc/routers/_app.ts | 14 ++++ src/trpc/routers/blocks.ts | 41 ++++++++++ src/trpc/routers/feed.ts | 31 ++++++++ src/trpc/routers/follows.ts | 79 +++++++++++++++++++ src/trpc/routers/milestones.ts | 42 +++++++++++ src/trpc/routers/posts.ts | 89 ++++++++++++++++++++++ src/trpc/routers/reports.ts | 32 ++++++++ src/trpc/routers/stories.ts | 58 ++++++++++++++ 12 files changed, 567 insertions(+) create mode 100644 src/lib/feed-helpers.ts create mode 100644 src/lib/media-url.ts create mode 100644 src/lib/milestone-thresholds.ts create mode 100644 src/lib/redis.ts create mode 100644 src/trpc/routers/blocks.ts create mode 100644 src/trpc/routers/feed.ts create mode 100644 src/trpc/routers/follows.ts create mode 100644 src/trpc/routers/milestones.ts create mode 100644 src/trpc/routers/posts.ts create mode 100644 src/trpc/routers/reports.ts create mode 100644 src/trpc/routers/stories.ts diff --git a/src/lib/feed-helpers.ts b/src/lib/feed-helpers.ts new file mode 100644 index 0000000..3f05cee --- /dev/null +++ b/src/lib/feed-helpers.ts @@ -0,0 +1,129 @@ +import "server-only"; +import type { Redis } from "@upstash/redis"; +import type { PrismaClient } from "@prisma/client"; + +/** + * Feed helpers for the Redis sorted-set fan-out feed pattern. + * + * Key convention: feed:{petId} + * - sorted set, score = Unix timestamp (ms), member = postId + * + * Security (T-02-02): server-only — UPSTASH tokens never reach client bundle. + */ + +export const PAGE_SIZE = 20; +export const FEED_RETENTION_MS = 90 * 24 * 60 * 60 * 1000; // 90 days + +/** + * Returns the Redis sorted-set key for a pet's personal feed inbox. + */ +export function feedKey(petId: string): string { + return `feed:${petId}`; +} + +/** + * Fan-out a new post to all followers' feed inboxes and the poster's own feed. + * Score = Unix timestamp (ms); member = postId. + * Called synchronously in posts.create (acceptable at v1 scale — Open Question 3). + */ +export async function fanOutPost( + redis: Redis, + postId: string, + petId: string, + createdAt: Date, + prisma: PrismaClient +): Promise { + const score = createdAt.getTime(); + + const followers = await prisma.follow.findMany({ + where: { followeePetId: petId }, + select: { followerPetId: true }, + }); + + const pipeline = redis.pipeline(); + + // Fan-out to every follower's inbox + for (const { followerPetId } of followers) { + pipeline.zadd(feedKey(followerPetId), { score, member: postId }); + } + + // Also add to own feed so own posts appear in the poster's timeline (Open Question 3) + pipeline.zadd(feedKey(petId), { score, member: postId }); + + await pipeline.exec(); +} + +/** + * Backfill the follower's feed when they follow a new pet. + * Adds the followee's recent posts (up to `limit`) to the follower's feed. + */ +export async function backfillOnFollow( + redis: Redis, + followerPetId: string, + followeePetId: string, + prisma: PrismaClient, + limit = 20 +): Promise { + const recentPosts = await prisma.post.findMany({ + where: { petId: followeePetId }, + orderBy: { createdAt: "desc" }, + take: limit, + select: { id: true, createdAt: true }, + }); + + if (recentPosts.length === 0) return; + + const pipeline = redis.pipeline(); + for (const post of recentPosts) { + pipeline.zadd(feedKey(followerPetId), { + score: post.createdAt.getTime(), + member: post.id, + }); + } + await pipeline.exec(); +} + +/** + * Retrieve a page of postIds from a pet's feed inbox. + * Uses ZRANGE with byScore + rev for O(log N + page) retrieval. + * + * Pitfall 2: rev:true requires maxScore as first positional arg. + * Pitfall 1: @upstash/redis 1.38.0 returns Array<{member, score}>, not interleaved. + */ +export async function getFeedPage( + redis: Redis, + petId: string, + cursor: number | null +): Promise<{ postIds: string[]; nextCursor: number | null }> { + const maxScore = cursor ?? Date.now(); + const minScore = 0; + + // Fetch PAGE_SIZE + 1 to detect hasNextPage + // @upstash/redis requires {offset, count} together when limiting (not count alone) + const results = (await redis.zrange(feedKey(petId), maxScore, minScore, { + byScore: true, + rev: true, + withScores: true, + offset: 0, + count: PAGE_SIZE + 1, + })) as Array<{ member: string; score: number }>; + + const hasNextPage = results.length > PAGE_SIZE; + const items = hasNextPage ? results.slice(0, PAGE_SIZE) : results; + const nextCursor = + hasNextPage ? items[items.length - 1].score - 1 : null; + + return { + postIds: items.map((r) => r.member), + nextCursor, + }; +} + +/** + * Trim a pet's feed to the 90-day retention window. + * Called by the pg_cron cleanup job — not on the request path. + */ +export async function trimFeed(redis: Redis, petId: string): Promise { + const cutoff = Date.now() - FEED_RETENTION_MS; + await redis.zremrangebyscore(feedKey(petId), 0, cutoff); +} diff --git a/src/lib/media-url.ts b/src/lib/media-url.ts new file mode 100644 index 0000000..7136b78 --- /dev/null +++ b/src/lib/media-url.ts @@ -0,0 +1,11 @@ +/** + * Constructs the public CDN URL for media stored in Supabase Storage. + * Safe to use in both server and client components (uses NEXT_PUBLIC_ env var). + * + * Mirrors getAvatarUrl signature. Used for post images and story media + * stored under the posts/ and stories/ path prefixes in the pet-avatars bucket. + */ +export function getMediaUrl(key: string | null | undefined): string | null { + if (!key) return null; + return `${process.env.NEXT_PUBLIC_SUPABASE_URL}/storage/v1/object/public/pet-avatars/${key}`; +} diff --git a/src/lib/milestone-thresholds.ts b/src/lib/milestone-thresholds.ts new file mode 100644 index 0000000..0daf429 --- /dev/null +++ b/src/lib/milestone-thresholds.ts @@ -0,0 +1,25 @@ +import { MilestoneType } from "@prisma/client"; + +/** + * Hard-coded follower milestone thresholds and their corresponding MilestoneType enum values. + * + * Per D-09 and A3: these values are a constant array, not a DB table. + * Used by the follows router to detect when a follower count threshold is crossed + * and emit an auto-triggered milestone (D-10). + */ +export const FOLLOWER_MILESTONE_THRESHOLDS: Array<{ + count: number; + type: MilestoneType; +}> = [ + { count: 500, type: MilestoneType.FOLLOWERS_500 }, + { count: 1_000, type: MilestoneType.FOLLOWERS_1K }, + { count: 2_500, type: MilestoneType.FOLLOWERS_2_5K }, + { count: 5_000, type: MilestoneType.FOLLOWERS_5K }, + { count: 10_000, type: MilestoneType.FOLLOWERS_10K }, + { count: 25_000, type: MilestoneType.FOLLOWERS_25K }, + { count: 50_000, type: MilestoneType.FOLLOWERS_50K }, + { count: 100_000, type: MilestoneType.FOLLOWERS_100K }, + { count: 250_000, type: MilestoneType.FOLLOWERS_250K }, + { count: 500_000, type: MilestoneType.FOLLOWERS_500K }, + { count: 1_000_000, type: MilestoneType.FOLLOWERS_1M }, +]; diff --git a/src/lib/redis.ts b/src/lib/redis.ts new file mode 100644 index 0000000..8ab7250 --- /dev/null +++ b/src/lib/redis.ts @@ -0,0 +1,16 @@ +import "server-only"; +import { Redis } from "@upstash/redis"; + +/** + * @upstash/redis singleton for Phase 2 feed fan-out sorted sets. + * + * Security (T-02-02): server-only import ensures UPSTASH_REDIS_REST_TOKEN + * never reaches the client bundle. + * + * Environment: UPSTASH_REDIS_REST_URL and UPSTASH_REDIS_REST_TOKEN must be + * set in .env.local (Upstash Console → Redis database → REST API). + */ +export const redis = new Redis({ + url: process.env.UPSTASH_REDIS_REST_URL!, + token: process.env.UPSTASH_REDIS_REST_TOKEN!, +}); diff --git a/src/trpc/routers/_app.ts b/src/trpc/routers/_app.ts index a2e8ea3..fe33767 100644 --- a/src/trpc/routers/_app.ts +++ b/src/trpc/routers/_app.ts @@ -1,10 +1,24 @@ import { router } from "@/trpc/init"; import { petsRouter } from "@/trpc/routers/pets"; import { mediaRouter } from "@/trpc/routers/media"; +import { postsRouter } from "@/trpc/routers/posts"; +import { storiesRouter } from "@/trpc/routers/stories"; +import { milestonesRouter } from "@/trpc/routers/milestones"; +import { followsRouter } from "@/trpc/routers/follows"; +import { blocksRouter } from "@/trpc/routers/blocks"; +import { reportsRouter } from "@/trpc/routers/reports"; +import { feedRouter } from "@/trpc/routers/feed"; export const appRouter = router({ pets: petsRouter, media: mediaRouter, + posts: postsRouter, + stories: storiesRouter, + milestones: milestonesRouter, + follows: followsRouter, + blocks: blocksRouter, + reports: reportsRouter, + feed: feedRouter, }); // Export the type for use in the client diff --git a/src/trpc/routers/blocks.ts b/src/trpc/routers/blocks.ts new file mode 100644 index 0000000..57f6129 --- /dev/null +++ b/src/trpc/routers/blocks.ts @@ -0,0 +1,41 @@ +import "server-only"; +import { z } from "zod"; +import { TRPCError } from "@trpc/server"; +import { router, protectedProcedure } from "@/trpc/init"; + +/** + * Blocks tRPC router — safety baseline (SAFE-01). + * + * Blocks are stored in PostgreSQL (authoritative) — never in Redis. + * Block filter is applied at Prisma hydration time in feed.getFeed. + * + * Procedure keys: + * create — block a pet (insert Block row) + * list — list pets blocked by the active pet + * + * Implementation: downstream Wave 1 plans. + */ +export const blocksRouter = router({ + create: protectedProcedure + .input( + z.object({ + blockerPetId: z.string().min(1), + blockedPetId: z.string().min(1), + }) + ) + .mutation(async () => { + throw new TRPCError({ code: "NOT_IMPLEMENTED" }); + }), + + list: protectedProcedure + .input( + z.object({ + petId: z.string().min(1), + cursor: z.string().nullish(), + limit: z.number().min(1).max(100).default(20), + }) + ) + .query(async () => { + throw new TRPCError({ code: "NOT_IMPLEMENTED" }); + }), +}); diff --git a/src/trpc/routers/feed.ts b/src/trpc/routers/feed.ts new file mode 100644 index 0000000..e3c01aa --- /dev/null +++ b/src/trpc/routers/feed.ts @@ -0,0 +1,31 @@ +import "server-only"; +import { z } from "zod"; +import { TRPCError } from "@trpc/server"; +import { router, protectedProcedure } from "@/trpc/init"; + +/** + * Feed tRPC router — Redis sorted-set fan-out home feed (FEED-01). + * + * getFeed is an infinite query: tRPC v11 requires the input schema to include + * a field named exactly `cursor` for useInfiniteQuery to work (Pitfall 6). + * + * Feed hydration order: + * 1. ZRANGE feed:{petId} (by score desc, cursor-based) + * 2. Block filter applied at Prisma level (pet: { id: { notIn: blockedPetIds } }) + * 3. Post array sorted to match Redis score order (Pitfall 7) + * + * Implementation: downstream Wave 1 plans. + */ +export const feedRouter = router({ + getFeed: protectedProcedure + .input( + z.object({ + petId: z.string().min(1), + cursor: z.number().nullish(), // Unix ms timestamp — must be named 'cursor' (Pitfall 6) + limit: z.number().min(1).max(50).default(20), + }) + ) + .query(async () => { + throw new TRPCError({ code: "NOT_IMPLEMENTED" }); + }), +}); diff --git a/src/trpc/routers/follows.ts b/src/trpc/routers/follows.ts new file mode 100644 index 0000000..f0ee7ad --- /dev/null +++ b/src/trpc/routers/follows.ts @@ -0,0 +1,79 @@ +import "server-only"; +import { z } from "zod"; +import { TRPCError } from "@trpc/server"; +import { router, protectedProcedure } from "@/trpc/init"; + +/** + * Follows tRPC router — pet-to-pet social graph (SOCL-01, SOCL-02, SOCL-03). + * + * Follow is pet-to-pet exclusively — never owner-to-owner. + * 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. + */ +export const followsRouter = router({ + create: protectedProcedure + .input( + z.object({ + followerPetId: z.string().min(1), + followeePetId: z.string().min(1), + }) + ) + .mutation(async () => { + throw new TRPCError({ code: "NOT_IMPLEMENTED" }); + }), + + delete: protectedProcedure + .input( + z.object({ + followerPetId: z.string().min(1), + followeePetId: z.string().min(1), + }) + ) + .mutation(async () => { + throw new TRPCError({ code: "NOT_IMPLEMENTED" }); + }), + + isFollowing: protectedProcedure + .input( + z.object({ + followerPetId: z.string().min(1), + followeePetId: z.string().min(1), + }) + ) + .query(async () => { + throw new TRPCError({ code: "NOT_IMPLEMENTED" }); + }), + + listFollowers: protectedProcedure + .input( + z.object({ + petId: z.string().min(1), + cursor: z.string().nullish(), + limit: z.number().min(1).max(100).default(20), + }) + ) + .query(async () => { + throw new TRPCError({ code: "NOT_IMPLEMENTED" }); + }), + + listFollowing: protectedProcedure + .input( + z.object({ + petId: z.string().min(1), + cursor: z.string().nullish(), + limit: z.number().min(1).max(100).default(20), + }) + ) + .query(async () => { + throw new TRPCError({ code: "NOT_IMPLEMENTED" }); + }), +}); diff --git a/src/trpc/routers/milestones.ts b/src/trpc/routers/milestones.ts new file mode 100644 index 0000000..44c87db --- /dev/null +++ b/src/trpc/routers/milestones.ts @@ -0,0 +1,42 @@ +import "server-only"; +import { z } from "zod"; +import { TRPCError } from "@trpc/server"; +import { MilestoneType } from "@prisma/client"; +import { router, protectedProcedure } from "@/trpc/init"; + +/** + * Milestones tRPC router — structured milestone posts (D-09). + * + * Procedure keys: + * create — create a milestone post with a validated MilestoneType enum value + * listByPet — list milestone posts for a pet (MILE-02) + * + * Implementation: downstream Wave 1 plans. + */ +export const milestonesRouter = router({ + create: protectedProcedure + .input( + z.object({ + petId: z.string().min(1), + milestoneType: z.nativeEnum(MilestoneType), + notes: z.string().max(500).optional(), + caption: z.string().max(500).optional(), + imageKey: z.string().startsWith("posts/").optional(), + }) + ) + .mutation(async () => { + throw new TRPCError({ code: "NOT_IMPLEMENTED" }); + }), + + listByPet: protectedProcedure + .input( + z.object({ + petId: z.string().min(1), + cursor: z.string().nullish(), + limit: z.number().min(1).max(50).default(20), + }) + ) + .query(async () => { + throw new TRPCError({ code: "NOT_IMPLEMENTED" }); + }), +}); diff --git a/src/trpc/routers/posts.ts b/src/trpc/routers/posts.ts new file mode 100644 index 0000000..86d8de8 --- /dev/null +++ b/src/trpc/routers/posts.ts @@ -0,0 +1,89 @@ +import "server-only"; +import { z } from "zod"; +import { TRPCError } from "@trpc/server"; +import { router, protectedProcedure } from "@/trpc/init"; + +/** + * 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). + */ +export const postsRouter = router({ + 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"), + }) + ) + .mutation(async () => { + throw new TRPCError({ code: "NOT_IMPLEMENTED" }); + }), + + updateCaption: protectedProcedure + .input( + z.object({ + postId: z.string().min(1), + caption: z.string().max(500), + }) + ) + .mutation(async () => { + throw new TRPCError({ code: "NOT_IMPLEMENTED" }); + }), + + delete: protectedProcedure + .input(z.object({ postId: z.string().min(1) })) + .mutation(async () => { + throw new TRPCError({ code: "NOT_IMPLEMENTED" }); + }), + + byPetId: protectedProcedure + .input( + z.object({ + petId: z.string().min(1), + cursor: z.string().nullish(), + limit: z.number().min(1).max(50).default(20), + }) + ) + .query(async () => { + throw new TRPCError({ code: "NOT_IMPLEMENTED" }); + }), + + 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" }); + }), + + 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" }); + }), +}); diff --git a/src/trpc/routers/reports.ts b/src/trpc/routers/reports.ts new file mode 100644 index 0000000..4f4163a --- /dev/null +++ b/src/trpc/routers/reports.ts @@ -0,0 +1,32 @@ +import "server-only"; +import { z } from "zod"; +import { TRPCError } from "@trpc/server"; +import { ReportReason } from "@prisma/client"; +import { router, protectedProcedure } from "@/trpc/init"; + +/** + * Reports tRPC router — content reporting for review (SAFE-02). + * + * Reports reference either a post or a pet profile (targetPostId XOR targetPetId). + * ReportReason is a validated enum — prevents arbitrary reason strings. + * + * Procedure keys: + * create — submit a report + * + * Implementation: downstream Wave 1 plans. + */ +export const reportsRouter = router({ + create: protectedProcedure + .input( + z.object({ + reporterPetId: z.string().min(1), + targetPostId: z.string().min(1).optional(), + targetPetId: z.string().min(1).optional(), + reason: z.nativeEnum(ReportReason), + notes: z.string().max(500).optional(), + }) + ) + .mutation(async () => { + throw new TRPCError({ code: "NOT_IMPLEMENTED" }); + }), +}); diff --git a/src/trpc/routers/stories.ts b/src/trpc/routers/stories.ts new file mode 100644 index 0000000..f99e7a7 --- /dev/null +++ b/src/trpc/routers/stories.ts @@ -0,0 +1,58 @@ +import "server-only"; +import { z } from "zod"; +import { TRPCError } from "@trpc/server"; +import { router, protectedProcedure } from "@/trpc/init"; + +/** + * Stories tRPC router — 24-hour photo stories with lazy-delete expiry (D-07). + * + * Procedure keys: + * create — create a story with expiresAt = now + 24h + * listActive — list non-expired stories for a pet (expiresAt > now) + * recordView — upsert a story view (dedup by storyId + viewerPetId per D-08) + * getViewers — list pets that viewed a story (STORY-03) + * hasActiveStory — returns boolean; used for story ring display (STORY-02) + * + * Implementation: downstream Wave 1 plans. + */ +export const storiesRouter = router({ + create: protectedProcedure + .input( + z.object({ + petId: z.string().min(1), + storageKey: z.string().startsWith("stories/"), + }) + ) + .mutation(async () => { + throw new TRPCError({ code: "NOT_IMPLEMENTED" }); + }), + + listActive: protectedProcedure + .input(z.object({ petId: z.string().min(1) })) + .query(async () => { + throw new TRPCError({ code: "NOT_IMPLEMENTED" }); + }), + + recordView: protectedProcedure + .input( + z.object({ + storyId: z.string().min(1), + viewerPetId: z.string().min(1), + }) + ) + .mutation(async () => { + throw new TRPCError({ code: "NOT_IMPLEMENTED" }); + }), + + getViewers: protectedProcedure + .input(z.object({ storyId: z.string().min(1) })) + .query(async () => { + throw new TRPCError({ code: "NOT_IMPLEMENTED" }); + }), + + hasActiveStory: protectedProcedure + .input(z.object({ petId: z.string().min(1) })) + .query(async () => { + throw new TRPCError({ code: "NOT_IMPLEMENTED" }); + }), +});