Files
petfeed/src/lib/feed-helpers.ts
T
admin bf49024ace fix: 6 HIGH-severity findings from 2026-07-21 audit rerun
- 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.
2026-07-22 19:24:21 +02:00

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);
}