Files
petfeed/src/lib/feed-helpers.ts
T
admin a29c57e37a feat(admin): hide posts without deleting (Gitea #10 Phase 3, part 1/4)
Adds Post.hiddenAt (nullable, db push applied) plus MODERATOR+ admin
mutations hidePost/unhidePost, each logging HIDE_POST/UNHIDE_POST to
ModerationLog with the standard moderatorRole+ipAddress stamp.

hidePost requires a non-empty reason (visible to nobody but audit
trail); unhidePost is a plain reversal. The admin posts grid shows a
"Hidden" overlay badge and swaps the delete-adjacent action button
between hide/unhide based on hiddenAt.

Every reader-facing post query now filters hiddenAt: null so a hidden
post disappears everywhere without touching its row: feed (discovery
candidates, Postgres fallback, and the Redis-postId lookup — the same
place blocked-pet filtering already happens, so an already-fanned-out
hidden post silently drops out at read time), feed backfill on new
follows, explore trending, profile grid, hashtag search, and the
milestones tab.

Admin-facing queries (listPosts, analytics bucketing) are deliberately
NOT filtered — moderators need to see hidden posts to unhide them, and
volume analytics shouldn't be skewed by moderation actions.

Gitea #10
2026-08-12 14:23:00 +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, hiddenAt: null },
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);
}