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
This commit is contained in:
admin committed 2026-06-07 12:23:10 +02:00
1 parent b2db6310fe
commit c435404b2d
12 files changed
+567

No files matched your search

+129
View File
@@ -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<void> {
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<void> {
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<void> {
const cutoff = Date.now() - FEED_RETENTION_MS;
await redis.zremrangebyscore(feedKey(petId), 0, cutoff);
}
+11
View File
@@ -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}`;
}
+25
View File
@@ -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 },
];
+16
View File
@@ -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!,
});
+14
View File
@@ -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
+41
View File
@@ -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" });
}),
});
+31
View File
@@ -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" });
}),
});
+79
View File
@@ -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" });
}),
});
+42
View File
@@ -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" });
}),
});
+89
View File
@@ -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" });
}),
});
+32
View File
@@ -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" });
}),
});
+58
View File
@@ -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" });
}),
});