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:
@@ -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[]
|
||||
>,
|
||||
};
|
||||
|
||||
@@ -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>,
|
||||
};
|
||||
|
||||
@@ -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 }[];
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user