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