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:
@@ -164,6 +164,60 @@ export class ModerationRepository {
|
||||
|
||||
return { data, nextCursor };
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregate moderation trends over the last `days` days.
|
||||
* - category counts (from the jsonb/text[] `categories` column, unnested)
|
||||
* - severity distribution
|
||||
* - action_type distribution
|
||||
* Read-only; powers the public Toxic Topic Trends panel.
|
||||
*/
|
||||
async getTrends(days: number) {
|
||||
const db = getDatabase();
|
||||
const since = Date.now() - days * 24 * 60 * 60 * 1000;
|
||||
|
||||
const cats = await db.execute(sql`
|
||||
SELECT jsonb_array_elements_text(a.categories::jsonb) AS cat, COUNT(*)::int AS c
|
||||
FROM moderation_actions a
|
||||
WHERE a.created_at >= ${since} AND a.categories IS NOT NULL AND a.categories != '[]' AND a.categories != ''
|
||||
GROUP BY cat
|
||||
ORDER BY c DESC
|
||||
LIMIT 15
|
||||
`);
|
||||
const catRows = (cats.rows as Record<string, unknown>[]) || [];
|
||||
|
||||
const sev = await db.execute(sql`
|
||||
SELECT severity, COUNT(*)::int AS c
|
||||
FROM moderation_actions
|
||||
WHERE created_at >= ${since} AND severity IS NOT NULL
|
||||
GROUP BY severity
|
||||
`);
|
||||
const sevRows = (sev.rows as Record<string, unknown>[]) || [];
|
||||
|
||||
const act = await db.execute(sql`
|
||||
SELECT action_type, COUNT(*)::int AS c
|
||||
FROM moderation_actions
|
||||
WHERE created_at >= ${since}
|
||||
GROUP BY action_type
|
||||
ORDER BY c DESC
|
||||
`);
|
||||
const actRows = (act.rows as Record<string, unknown>[]) || [];
|
||||
|
||||
return {
|
||||
categories: catRows.map((r) => ({
|
||||
name: String(r.cat),
|
||||
count: Number(r.c ?? 0),
|
||||
})),
|
||||
severities: sevRows.map((r) => ({
|
||||
level: String(r.severity),
|
||||
count: Number(r.c ?? 0),
|
||||
})),
|
||||
actions: actRows.map((r) => ({
|
||||
type: String(r.action_type),
|
||||
count: Number(r.c ?? 0),
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const moderationRepository = new ModerationRepository();
|
||||
|
||||
@@ -8,10 +8,13 @@ const logger = createChildLogger("moderation.service");
|
||||
|
||||
export class ModerationService {
|
||||
async getStats() {
|
||||
logger.debug("Fetching moderation stats");
|
||||
return moderationRepository.getStats();
|
||||
}
|
||||
|
||||
async getTrends(days = 30) {
|
||||
return moderationRepository.getTrends(days);
|
||||
}
|
||||
|
||||
async listActions(query: ListModerationQuery) {
|
||||
logger.debug({ query }, "Listing moderation actions");
|
||||
return moderationRepository.listActions(query);
|
||||
|
||||
Reference in New Issue
Block a user