- feed.ts: Postgres fallback path (used when Redis is down) never
filtered blocked pets, unlike the primary Redis path — blocked pets'
posts could reappear during a Redis hiccup.
- feed-helpers.ts/feed.ts: getFeedPage always paginated by a hardcoded
PAGE_SIZE=20, silently ignoring the client's requested (zod-validated
1-50) limit whenever Redis was up.
- milestones.ts: create accepted any MilestoneType including the
follower-threshold values (FOLLOWERS_500..FOLLOWERS_1M), which are
meant to be exclusively auto-awarded — restriction existed only in
the MilestoneForm picker, not server-side. Now validated against the
existing USER_INITIATED_MILESTONE_TYPES constant.
- admin.ts: deletePost/deleteReportedPost didn't decrement the original
post's repostCount when the deleted post was itself a repost, unlike
reposts.ts's user-facing delete — counter drifted upward permanently.
- PawButton.tsx: count was optimistically guessed off a `reacted` flag
that can be stale (callers pass initialReacted={false} unconditionally),
permanently corrupting the displayed count on click. Count is now
computed from the server's actual toggle result instead of guessed.
- PostDetailDialog.tsx: Report/Block dropdown items had no onClick and
neither ReportSheet nor BlockDialog were rendered — wired up to match
PostCard.tsx's existing pattern.
- VideoCard.tsx: invalidated the entire query cache on every video-ready
transition; scoped to feed/posts/explore router queries instead.
121 lines
3.1 KiB
TypeScript
121 lines
3.1 KiB
TypeScript
import "server-only";
|
|
import type { Redis } from "ioredis";
|
|
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 — Redis credentials never reach client bundle.
|
|
*/
|
|
|
|
export const PAGE_SIZE = 20;
|
|
export const FEED_RETENTION_MS = 90 * 24 * 60 * 60 * 1000; // 90 days
|
|
|
|
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.
|
|
*/
|
|
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();
|
|
|
|
for (const { followerPetId } of followers) {
|
|
pipeline.zadd(feedKey(followerPetId), score, postId);
|
|
}
|
|
pipeline.zadd(feedKey(petId), score, postId);
|
|
|
|
await pipeline.exec();
|
|
}
|
|
|
|
/**
|
|
* Backfill the follower's feed when they follow a new pet.
|
|
*/
|
|
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), post.createdAt.getTime(), post.id);
|
|
}
|
|
await pipeline.exec();
|
|
}
|
|
|
|
/**
|
|
* Retrieve a page of postIds from a pet's feed inbox.
|
|
* Uses ZREVRANGEBYSCORE for reverse-chronological retrieval.
|
|
*/
|
|
export async function getFeedPage(
|
|
redis: Redis,
|
|
petId: string,
|
|
cursor: number | null,
|
|
limit: number = PAGE_SIZE
|
|
): Promise<{ postIds: string[]; nextCursor: number | null }> {
|
|
const maxScore = cursor ?? Date.now();
|
|
const minScore = 0;
|
|
|
|
// WITHSCORES returns interleaved [member, score, member, score, ...]
|
|
const raw = (await redis.zrevrangebyscore(
|
|
feedKey(petId),
|
|
maxScore,
|
|
minScore,
|
|
"WITHSCORES",
|
|
"LIMIT",
|
|
0,
|
|
limit + 1
|
|
)) as string[];
|
|
|
|
const results: Array<{ member: string; score: number }> = [];
|
|
for (let i = 0; i < raw.length; i += 2) {
|
|
results.push({ member: raw[i], score: parseFloat(raw[i + 1]) });
|
|
}
|
|
|
|
const hasNextPage = results.length > limit;
|
|
const items = hasNextPage ? results.slice(0, limit) : 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.
|
|
*/
|
|
export async function trimFeed(redis: Redis, petId: string): Promise<void> {
|
|
const cutoff = Date.now() - FEED_RETENTION_MS;
|
|
await redis.zremrangebyscore(feedKey(petId), 0, cutoff);
|
|
}
|