feat(gmw): public features #2-#6 — live moderation feed, toxic topic trends, channel timeline, CSV export, activity heatmap

- Live Moderation Feed: gateway publishes discord:moderation:action (Redis) → backend WS emits moderation_action → public web shows realtime stream.
- Toxic Topic Trends: backend moderation.trends aggregates categories/severity/action_type (read-only) → SVG bar + donut.
- Channel Timeline: messages view gets Feed/Timeline toggle with date-grouped separators.
- CSV Export: client-side downloadCsv for moderation actions (no backend write scope).
- Activity Heatmap: backend messages.activity (per-hour volume by channel) → pure-SVG grid.

User reputation deliberately excluded — no such feature exists in the codebase.
All read-only / public-facing / fully automatic per project rules.
This commit is contained in:
asepharyana
2026-08-18 17:43:02 +07:00
parent 36363fa3db
commit 9b3134d767
26 changed files with 854 additions and 44 deletions
@@ -1,6 +1,7 @@
import { orpc } from "@/lib/orpc/client";
import type {
AttachmentRecord,
MessageActivityBucket,
MessageRecord,
SemanticSearchResult,
} from "@/lib/types";
@@ -73,4 +74,10 @@ export const messagesApi = {
results: SemanticSearchResult[];
nextCursor: null;
}>,
// Public, read-only activity heatmap data (per-hour volume by channel).
getActivity: (days = 30) =>
orpc.messages.activity({ days }) as unknown as Promise<
MessageActivityBucket[]
>,
};
+8 -1
View File
@@ -1,5 +1,9 @@
import { orpc } from "@/lib/orpc/client";
import type { ModerationStats, PaginatedModerationActions } from "@/lib/types";
import type {
ModerationStats,
ModerationTrends,
PaginatedModerationActions,
} from "@/lib/types";
export const moderationApi = {
getStats: () =>
@@ -17,4 +21,7 @@ export const moderationApi = {
actionType,
cursor,
}) as unknown as Promise<PaginatedModerationActions>,
getTrends: (days = 30) =>
orpc.moderation.trends({ days }) as unknown as Promise<ModerationTrends>,
};
+32
View File
@@ -0,0 +1,32 @@
/** Client-side CSV export. Pure browser — no backend, no write scope. */
export function toCsv(rows: Record<string, unknown>[]): string {
if (rows.length === 0) return "";
const headers = Array.from(
rows.reduce<Set<string>>((s, r) => {
Object.keys(r).forEach((k) => s.add(k));
return s;
}, new Set()),
);
const esc = (v: unknown): string => {
if (v == null) return "";
const s = typeof v === "object" ? JSON.stringify(v) : String(v);
return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
};
const head = headers.map(esc).join(",");
const body = rows
.map((r) => headers.map((h) => esc(r[h])).join(","))
.join("\n");
return `${head}\n${body}`;
}
export function downloadCsv(filename: string, rows: Record<string, unknown>[]) {
const csv = toCsv(rows);
if (!csv) return;
const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}
@@ -173,6 +173,12 @@ export interface SemanticSearchResult {
created_at: number;
}
export interface MessageActivityBucket {
channelId: string;
hour: number;
count: number;
}
export interface SemanticSearchResponse {
results: SemanticSearchResult[];
nextCursor: null;
@@ -44,3 +44,9 @@ export interface PaginatedModerationActions {
data: ModerationAction[];
nextCursor: string | null;
}
export interface ModerationTrends {
categories: { name: string; count: number }[];
severities: { level: string; count: number }[];
actions: { type: string; count: number }[];
}
+3
View File
@@ -2,6 +2,7 @@ import type {
ActiveSpeaker,
MediaState,
MessageRecord,
ModerationAction,
VoiceRecording,
} from "@/lib/types";
@@ -68,6 +69,8 @@ export interface WsEventMap {
presence_updated: unknown;
guild_member_added: unknown;
guild_member_removed: unknown;
/** Live moderation action broadcast (gateway → Redis → backend → WS). */
moderation_action: ModerationAction;
media_state: MediaState;
user_state: unknown;
ui_state: unknown;