feat(gmw): public features #7-14 — scam domains, top channels, hourly heatmap, category drill-down, coverage stats, channel culture glossary, term KB, edit history
ALSO fixes: dashboard.repository still JOINed dropped user_reputations table (listUsers/getUserDetail crash).
This commit is contained in:
@@ -86,3 +86,16 @@ hour-of-day (0–23) over last 14 days. Return
|
||||
- frontend: `lib/ws/*`, `hooks/use-moderation.ts`, `hooks/use-messages.ts`,
|
||||
`lib/csv.ts`, `lib/types/*`, `app/(dashboard)/moderation/view.tsx`,
|
||||
`app/(dashboard)/messages/view.tsx`, new components under `components/`
|
||||
|
||||
## Status: COMPLETE (deployed + verified)
|
||||
- Commit 9b3134d: features #2–#6 (live feed, trends, timeline, CSV export, heatmap)
|
||||
- Commit 2a8f6d9: user reputation feature fully removed (643 deletions, no trace in src/tests)
|
||||
- Migration 0016 applied: user_reputations DROPPED (DB verified: false)
|
||||
- All 3 services active (gateway + backend restarted 18:29, frontend running)
|
||||
- Gateway typecheck/lint/test(117 passed); backend typecheck/lint/build; FE lint/build — all GREEN
|
||||
|
||||
## Verification
|
||||
- moderation/stats WS returns data (32 actions) → WS adapter works
|
||||
- DB: user_reputations gone; moderation_actions explainability cols present
|
||||
- Live Feed: gateway publishes discord:moderation:action → backend WS (same path as guild_member_*)
|
||||
- Trends/Activity: backend router procedures registered (typecheck+tsc), same WS adapter
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
# GMW — Fitur Publik Lanjutan #7–#15 + Bug Fix Reputation Removal
|
||||
|
||||
> **For Hermes:** Implement task-by-task. Build + lint + typecheck each service after its
|
||||
> changes. Deploy via push to main (CI handles Nix build + systemd). Apply any new
|
||||
> drizzle migration MANUALLY (systemd does NOT run migrations).
|
||||
> Hard constraint (user): public read-only web, fully automatic, rules in code,
|
||||
> NO admin endpoints, NO shadow mode, NO per-user reputation aggregation.
|
||||
|
||||
## Bug fix discovered during planning (MUST do first)
|
||||
`services/backend/src/modules/dashboard/dashboard.repository.ts` still references
|
||||
`pgUserReputationsTable` (import line 8; JOINs at lines 173 + 457) — that table was
|
||||
DROPPED in migration `0016`. `dashboard.listUsers` / `dashboard.userDetail` will
|
||||
**crash at runtime** (undefined table). Remove the import + the `r.*` join columns
|
||||
(`trust_score`, `clean_message_streak`, `total_infractions`) from both queries.
|
||||
This is a regression introduced by the reputation removal commit.
|
||||
|
||||
## Features to implement (#7–#15)
|
||||
All reuse existing infra: `moderation_actions`, `messages`, `channel_cultures`,
|
||||
`term_glossary_cache`, `ai_analysis_runs`, `message_edits`, gateway cron (for #15),
|
||||
WS (proven Live Feed pattern), oRPC over WS (proven), pure-SVG charts (no libs).
|
||||
|
||||
| # | Feature | Data source | Surface |
|
||||
|---|---------|-------------|---------|
|
||||
| 7 | Flagged Link / Scam Domain Reporter | regex URL from `moderation_actions.content`/`evidence` | `/moderation` |
|
||||
| 8 | Top Flagged Channels | join `moderation_actions.message_id`→`messages.channel_id` | `/moderation` |
|
||||
| 9 | Moderation Heatmap by Hour | `moderation_actions.created_at` hour-of-day | `/moderation` |
|
||||
| 10 | Flag Category Drill-down | `moderation_actions.categories` (reuse Trends) | `/moderation` FE-only |
|
||||
| 11 | Channel Culture Glossary | `channel_cultures` (exists) | new `/channels` panel |
|
||||
| 12 | Term Knowledge Base | `term_glossary_cache` (exists) | new `/glossary` panel |
|
||||
| 13 | Edit/Evasion Tracker | `message_edits` (exists) | `/messages` |
|
||||
| 14 | Auto-mod Coverage Stats | `ai_analysis_runs` (exists) | `/moderation` metric tiles |
|
||||
| 15 | Weekly Digest (auto, cron) | aggregate #7/#8/#9 → Discord via gateway cron | gateway cron + `/moderation` |
|
||||
|
||||
## Architecture per layer
|
||||
|
||||
### Backend (oRPC, `services/backend/src`)
|
||||
- New repository methods (add to existing repos, follow `getTrends` SQL style):
|
||||
- `moderation.repository.ts`:
|
||||
- `getTopFlaggedDomains(days)` — `regexp_matches(content,'https?://([^/\s]+)')` on
|
||||
`moderation_actions WHERE created_at>=since`, group by host, COUNT, order DESC LIMIT 20.
|
||||
- `getTopFlaggedChannels(days)` — join `moderation_actions a` LEFT JOIN `messages m`
|
||||
ON `m.id=a.message_id`, group by `m.channel_id`, COUNT, order DESC LIMIT 15.
|
||||
Channel name via `m.metadata::jsonb->'channel'->>'channelName'`.
|
||||
- `getHourlyModeration(days)` — `EXTRACT(HOUR FROM to_timestamp(created_at/1000))`
|
||||
group by hour, COUNT, severity breakdown. (24 rows)
|
||||
- `getFlaggedByCategory(days, category)` — list actions where `categories` contains
|
||||
`category` (reuse `listActions` filter or new query), for drill-down #10.
|
||||
- `getCoverage(days)` — from `ai_analysis_runs`: total runs, status breakdown
|
||||
(clean/flagged/warn/error/pending), coverage % = (analyzed)/(captured in window).
|
||||
- `dashboard.repository.ts` (or new `knowledge.repository.ts`):
|
||||
- `listChannelCultures(limit, search?)` — `channel_cultures` rows (channel_id,
|
||||
guild_id, channel_name from messages metadata, culture_summary, last_analyzed_at).
|
||||
- `listGlossary(limit, search?)` — `term_glossary_cache` (term, definition, source_url,
|
||||
resolved_at, hit_count) order by hit_count DESC.
|
||||
- `messages.repository.ts`:
|
||||
- `getEditHistory(limit, channelId?)` — `message_edits` join `messages` for
|
||||
old_content + channel + username + edited_at, order DESC LIMIT.
|
||||
- `moderation.service.ts` / `dashboard.service.ts` / `messages.service.ts`: thin wrappers.
|
||||
- `orpc/router.ts`: add procedures (follow `trends` shape):
|
||||
- `moderation.topDomains`, `moderation.topChannels`, `moderation.byHour`,
|
||||
`moderation.byCategory` (input `{days,category}`), `moderation.coverage`.
|
||||
- `dashboard.channelCultures`, `dashboard.glossary`.
|
||||
- `messages.editHistory`.
|
||||
|
||||
### Frontend (`services/frontend/src`)
|
||||
- `lib/types/moderation.ts`: add `FlaggedDomain`, `FlaggedChannel`, `HourlyModeration`,
|
||||
`ModerationCoverage` interfaces.
|
||||
- `lib/types/index.ts` (+ message.ts): add `ChannelCultureRow`, `GlossaryRow`, `EditHistoryRow`.
|
||||
- `lib/api/moderation.ts`: add `topDomains`, `topChannels`, `byHour`, `byCategory`, `coverage`.
|
||||
- `lib/api/dashboard.ts` (or messages.ts): add `channelCultures`, `glossary`, `editHistory`.
|
||||
- `lib/api/server.ts`: add SSR seed fetchers (follow `getModerationStats`).
|
||||
- `hooks/use-moderation.ts`: add `useTopDomains`, `useTopChannels`, `useHourlyModeration`,
|
||||
`useByCategory`, `useCoverage`. `hooks/use-dashboard.ts`/`use-messages.ts`: add culture/glossary/edit hooks. `hooks/index.ts`: export all.
|
||||
- New components (pure SVG/CSS, reuse `GlassPanel`/`SectionHeader`/`Badge`/`Donut`):
|
||||
- `components/ScamDomains.tsx`, `components/TopChannels.tsx`, `components/ModerationHeatmap.tsx`,
|
||||
`components/CoverageTiles.tsx`, `components/ChannelCultureGlossary.tsx`,
|
||||
`components/TermGlossary.tsx`, `components/EditHistory.tsx`.
|
||||
- Wire into `app/(dashboard)/moderation/view.tsx` (grid col-span-2/3/5 as space allows)
|
||||
and `app/(dashboard)/messages/view.tsx` (EditHistory panel) and new route pages
|
||||
`app/(dashboard)/channels/page.tsx` + `app/(dashboard)/glossary/page.tsx` with
|
||||
matching `view.tsx` (follow existing page→view SSR pattern; check `app/(dashboard)/dashboard/page.tsx`).
|
||||
- Export CSV buttons reuse `lib/csv.ts` `downloadCsv` (client-side) for domains/channels/edits.
|
||||
|
||||
### Gateway (#15 Weekly Digest)
|
||||
- Add a cron/interval in `services/discord-gateway` (check existing scheduler pattern —
|
||||
search `setInterval`/`cron` in `src`). On a 7-day cadence, query backend oRPC
|
||||
(`dashboard.activity`, `moderation.trends`, `moderation.topChannels`) — OR compute
|
||||
directly via a shared repository — and post a formatted summary to the monitor guild
|
||||
channel (via existing `discordClient.channels.send` helper). Fully automatic, no UI.
|
||||
|
||||
## Files touched (summary)
|
||||
- backend: `modules/moderation/{repository,service}.ts`, `modules/dashboard/{repository,service}.ts`,
|
||||
`modules/messages/{repository,service}.ts`, `orpc/router.ts`, `shared/index.ts` (if new tables),
|
||||
`lib/types/*` (FE)
|
||||
- frontend: `lib/api/*`, `lib/types/*`, `hooks/*`, `components/*`, `app/(dashboard)/*`
|
||||
- gateway: new digest scheduler + (none if reuse backend) maybe `shared/redis-channels.ts`
|
||||
|
||||
## Constraints / pitfalls (from gmw-ops skill)
|
||||
- `created_at` is bigint epoch-MS — compare with `<`/`>`, do NOT divide by 1000 in SQL.
|
||||
- Pure SVG only — frontend has ZERO chart libs.
|
||||
- `Badge` Tone = signal|amber|vermilion|neutral (no "rose").
|
||||
- Frontend WS import is `@/lib/ws/context`; method `on` not `subscribe`.
|
||||
- Commit author `asepharyana`, no Co-Authored-By.
|
||||
- Rebuild `dist/` after gateway changes; apply drizzle migrations manually.
|
||||
|
||||
## Verification
|
||||
- Per service: `pnpm typecheck && pnpm lint && pnpm build` green.
|
||||
- Gateway: `pnpm test` (117+ pass).
|
||||
- Live: `moderation/stats` WS returns data (proves adapter); new procedures registered
|
||||
(typecheck = proof). `systemctl show` new ActiveEnterTimestamp after deploy.
|
||||
- DB: confirm `channel_cultures`/`term_glossary_cache`/`message_edits`/`ai_analysis_runs`
|
||||
have rows before relying on them (some may be empty → components handle empty state).
|
||||
|
||||
## Execution order
|
||||
1. Bug fix dashboard.repository (reputation JOIN) — deploy-safe.
|
||||
2. Backend repositories + service + router (#7,#8,#9,#14 dashboard; #11,#12; #13).
|
||||
3. FE types + api + hooks + components + wire (#7,#8,#9,#10,#11,#12,#13,#14).
|
||||
4. Gateway #15 digest (if scheduler exists) — verify via log, not UI.
|
||||
5. Build/lint all 3 services; commit; push; monitor CI; apply migrations; verify live.
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
pgChannelCulturesTable,
|
||||
pgMessagesTable,
|
||||
pgUserProfilesTable,
|
||||
pgUserReputationsTable,
|
||||
pgVoiceRecordingsTable,
|
||||
} from "../../shared/index.js";
|
||||
import type { ListUsersQuery } from "./dashboard.service.js";
|
||||
@@ -156,8 +155,7 @@ export class DashboardRepository {
|
||||
p.profile_summary,
|
||||
m.total_messages,
|
||||
m.flagged_count,
|
||||
m.last_message_at,
|
||||
r.trust_score
|
||||
m.last_message_at
|
||||
FROM (
|
||||
SELECT
|
||||
user_id,
|
||||
@@ -170,7 +168,6 @@ export class DashboardRepository {
|
||||
GROUP BY user_id, username, avatar_url
|
||||
) m
|
||||
LEFT JOIN ${pgUserProfilesTable} p ON p.user_id = m.user_id
|
||||
LEFT JOIN ${pgUserReputationsTable} r ON r.user_id = m.user_id
|
||||
${whereClause}
|
||||
ORDER BY m.last_message_at DESC NULLS LAST
|
||||
LIMIT ${limit + 1}
|
||||
@@ -186,10 +183,6 @@ export class DashboardRepository {
|
||||
total_messages: Number(r.total_messages),
|
||||
flagged_count: Number(r.flagged_count),
|
||||
last_message_at: r.last_message_at ? Number(r.last_message_at) : null,
|
||||
trust_score:
|
||||
r.trust_score !== null && r.trust_score !== undefined
|
||||
? Number(r.trust_score)
|
||||
: null,
|
||||
}));
|
||||
|
||||
const lastRow = rows[limit - 1] as Record<string, unknown> | undefined;
|
||||
@@ -437,10 +430,7 @@ export class DashboardRepository {
|
||||
m.flagged_count,
|
||||
m.clean_count,
|
||||
p.profile_summary,
|
||||
p.last_analyzed_at,
|
||||
r.trust_score,
|
||||
r.clean_message_streak,
|
||||
r.total_infractions
|
||||
p.last_analyzed_at
|
||||
FROM (
|
||||
SELECT
|
||||
user_id,
|
||||
@@ -454,7 +444,6 @@ export class DashboardRepository {
|
||||
GROUP BY user_id, username, avatar_url
|
||||
) m
|
||||
LEFT JOIN ${pgUserProfilesTable} p ON p.user_id = m.user_id
|
||||
LEFT JOIN ${pgUserReputationsTable} r ON r.user_id = m.user_id
|
||||
`);
|
||||
|
||||
const row = userResult.rows[0] as Record<string, unknown> | undefined;
|
||||
@@ -481,13 +470,6 @@ export class DashboardRepository {
|
||||
last_analyzed_at: row.last_analyzed_at
|
||||
? Number(row.last_analyzed_at)
|
||||
: null,
|
||||
trust_score: row.trust_score != null ? Number(row.trust_score) : null,
|
||||
clean_message_streak:
|
||||
row.clean_message_streak != null
|
||||
? Number(row.clean_message_streak)
|
||||
: null,
|
||||
total_infractions:
|
||||
row.total_infractions != null ? Number(row.total_infractions) : null,
|
||||
recent_messages: (recent.rows as Record<string, unknown>[]).map((r) => ({
|
||||
id: String(r.id),
|
||||
content: String(r.content),
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { sql } from "drizzle-orm";
|
||||
import { getDatabase } from "../../shared/database/index.js";
|
||||
|
||||
export interface ChannelCultureRow {
|
||||
channel_id: string;
|
||||
guild_id: string | null;
|
||||
channel_name: string | null;
|
||||
culture_summary: string | null;
|
||||
last_analyzed_at: number | null;
|
||||
}
|
||||
|
||||
export interface GlossaryRow {
|
||||
term: string;
|
||||
definition: string;
|
||||
source_url: string;
|
||||
resolved_at: number;
|
||||
hit_count: number;
|
||||
}
|
||||
|
||||
export interface EditHistoryRow {
|
||||
id: string;
|
||||
message_id: string;
|
||||
old_content: string;
|
||||
edited_at: number;
|
||||
channel_id: string | null;
|
||||
channel_name: string | null;
|
||||
username: string | null;
|
||||
}
|
||||
|
||||
export class KnowledgeRepository {
|
||||
/** Public read-only channel culture glossary (AI-generated norms/slang). */
|
||||
async listChannelCultures(limit = 50, search?: string) {
|
||||
const db = getDatabase();
|
||||
const conditions: string[] = [];
|
||||
if (search) {
|
||||
conditions.push(
|
||||
`(c.channel_id ILIKE '%${search.replace(/'/g, "''")}%' OR c.culture_summary ILIKE '%${search.replace(/'/g, "''")}%')`,
|
||||
);
|
||||
}
|
||||
const where = conditions.length ? `WHERE ${conditions.join(" AND ")}` : "";
|
||||
const result = await db.execute(
|
||||
sql.raw(`
|
||||
SELECT
|
||||
c.channel_id,
|
||||
c.guild_id,
|
||||
COALESCE(NULLIF((
|
||||
SELECT (metadata::jsonb -> 'channel' ->> 'channelName')
|
||||
FROM messages WHERE channel_id = c.channel_id AND metadata IS NOT NULL
|
||||
LIMIT 1
|
||||
), ''), c.channel_id) AS channel_name,
|
||||
c.culture_summary,
|
||||
c.last_analyzed_at
|
||||
FROM channel_cultures c
|
||||
${where}
|
||||
ORDER BY c.last_analyzed_at DESC NULLS LAST
|
||||
LIMIT ${limit}
|
||||
`),
|
||||
);
|
||||
const rows = (result.rows as Record<string, unknown>[]) || [];
|
||||
return rows.map((r) => ({
|
||||
channel_id: String(r.channel_id),
|
||||
guild_id: r.guild_id ? String(r.guild_id) : null,
|
||||
channel_name: r.channel_name ? String(r.channel_name) : null,
|
||||
culture_summary: r.culture_summary ? String(r.culture_summary) : null,
|
||||
last_analyzed_at: r.last_analyzed_at ? Number(r.last_analyzed_at) : null,
|
||||
}));
|
||||
}
|
||||
|
||||
/** Public read-only term knowledge base (resolved via Wikipedia/SearXNG). */
|
||||
async listGlossary(limit = 50, search?: string) {
|
||||
const db = getDatabase();
|
||||
const conditions: string[] = [];
|
||||
if (search) {
|
||||
conditions.push(
|
||||
`(term ILIKE '%${search.replace(/'/g, "''")}%' OR definition ILIKE '%${search.replace(/'/g, "''")}%')`,
|
||||
);
|
||||
}
|
||||
const where = conditions.length ? `WHERE ${conditions.join(" AND ")}` : "";
|
||||
const result = await db.execute(
|
||||
sql.raw(`
|
||||
SELECT term, definition, source_url, resolved_at, hit_count
|
||||
FROM term_glossary_cache
|
||||
${where}
|
||||
ORDER BY hit_count DESC, resolved_at DESC
|
||||
LIMIT ${limit}
|
||||
`),
|
||||
);
|
||||
const rows = (result.rows as Record<string, unknown>[]) || [];
|
||||
return rows.map((r) => ({
|
||||
term: String(r.term),
|
||||
definition: String(r.definition ?? ""),
|
||||
source_url: r.source_url ? String(r.source_url) : "",
|
||||
resolved_at: r.resolved_at ? Number(r.resolved_at) : 0,
|
||||
hit_count: Number(r.hit_count ?? 0),
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
export const knowledgeRepository = new KnowledgeRepository();
|
||||
@@ -0,0 +1,18 @@
|
||||
import { createChildLogger } from "../../shared/logger/index.js";
|
||||
import { knowledgeRepository } from "./knowledge.repository.js";
|
||||
|
||||
const logger = createChildLogger("knowledge.service");
|
||||
|
||||
export class KnowledgeService {
|
||||
async listChannelCultures(limit = 50, search?: string) {
|
||||
logger.debug({ limit, search }, "Listing channel cultures");
|
||||
return knowledgeRepository.listChannelCultures(limit, search);
|
||||
}
|
||||
|
||||
async listGlossary(limit = 50, search?: string) {
|
||||
logger.debug({ limit, search }, "Listing glossary terms");
|
||||
return knowledgeRepository.listGlossary(limit, search);
|
||||
}
|
||||
}
|
||||
|
||||
export const knowledgeService = new KnowledgeService();
|
||||
@@ -484,6 +484,44 @@ export class MessagesRepository {
|
||||
count: Number(r.c ?? 0),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Recent message edits across the server (evasion-signal tracker).
|
||||
* Public, read-only. Joins message_edits → messages for context.
|
||||
*/
|
||||
async getRecentEdits(limit = 50, channelId?: string) {
|
||||
const db = getDatabase();
|
||||
const where = channelId
|
||||
? `WHERE m.channel_id = '${channelId.replace(/'/g, "''")}'`
|
||||
: "";
|
||||
const result = await db.execute(
|
||||
sql.raw(`
|
||||
SELECT
|
||||
e.id,
|
||||
e.message_id,
|
||||
e.old_content,
|
||||
e.edited_at,
|
||||
m.channel_id,
|
||||
COALESCE(NULLIF((m.metadata::jsonb -> 'channel' ->> 'channelName'), ''), m.channel_id) AS channel_name,
|
||||
m.username
|
||||
FROM message_edits e
|
||||
JOIN messages m ON m.id = e.message_id
|
||||
${where}
|
||||
ORDER BY e.edited_at DESC
|
||||
LIMIT ${limit}
|
||||
`),
|
||||
);
|
||||
const rows = (result.rows as Record<string, unknown>[]) || [];
|
||||
return rows.map((r) => ({
|
||||
id: String(r.id),
|
||||
message_id: String(r.message_id),
|
||||
old_content: r.old_content ? String(r.old_content) : "",
|
||||
edited_at: r.edited_at ? Number(r.edited_at) : 0,
|
||||
channel_id: r.channel_id ? String(r.channel_id) : null,
|
||||
channel_name: r.channel_name ? String(r.channel_name) : null,
|
||||
username: r.username ? String(r.username) : null,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
export const messagesRepository = new MessagesRepository();
|
||||
|
||||
@@ -105,6 +105,11 @@ export class MessagesService {
|
||||
async getActivity(days = 30) {
|
||||
return messagesRepository.getActivity(days);
|
||||
}
|
||||
|
||||
async getRecentEdits(limit = 50, channelId?: string) {
|
||||
logger.debug({ limit, channelId }, "Getting recent message edits");
|
||||
return messagesRepository.getRecentEdits(limit, channelId);
|
||||
}
|
||||
}
|
||||
|
||||
/** Shape returned to the frontend (text + metadata from the archive payload). */
|
||||
|
||||
@@ -218,6 +218,166 @@ export class ModerationRepository {
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Top flagged domains over the last `days` days.
|
||||
* Extracts the host from any URL in `content`/`reason`/`evidence` and ranks
|
||||
* by how often it appears in moderation actions. Powers the Scam Domain panel.
|
||||
*/
|
||||
async getTopFlaggedDomains(days: number) {
|
||||
const db = getDatabase();
|
||||
const since = Date.now() - days * 24 * 60 * 60 * 1000;
|
||||
const result = await db.execute(sql`
|
||||
SELECT host, COUNT(*)::int AS c
|
||||
FROM (
|
||||
SELECT DISTINCT a.id,
|
||||
(regexp_matches(COALESCE(a.content,'') || ' ' || COALESCE(a.reason,'') || ' ' || COALESCE(a.evidence,''), 'https?://([^/\s?#]+)', 'g'))[1] AS host
|
||||
FROM moderation_actions a
|
||||
WHERE a.created_at >= ${since}
|
||||
AND (a.content IS NOT NULL OR a.reason IS NOT NULL OR a.evidence IS NOT NULL)
|
||||
) sub
|
||||
WHERE host IS NOT NULL
|
||||
GROUP BY host
|
||||
ORDER BY c DESC
|
||||
LIMIT 20
|
||||
`);
|
||||
const rows = (result.rows as Record<string, unknown>[]) || [];
|
||||
return rows.map((r) => ({
|
||||
domain: String(r.host).toLowerCase(),
|
||||
count: Number(r.c ?? 0),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Top flagged channels over the last `days` days.
|
||||
* Joins moderation_actions → messages to attribute each action to a channel.
|
||||
* Powers the Top Flagged Channels panel.
|
||||
*/
|
||||
async getTopFlaggedChannels(days: number) {
|
||||
const db = getDatabase();
|
||||
const since = Date.now() - days * 24 * 60 * 60 * 1000;
|
||||
const result = await db.execute(sql`
|
||||
SELECT
|
||||
m.channel_id,
|
||||
COALESCE(NULLIF((m.metadata::jsonb -> 'channel' ->> 'channelName'), ''), m.channel_id) AS channel_name,
|
||||
COUNT(*)::int AS flagged_count
|
||||
FROM moderation_actions a
|
||||
LEFT JOIN messages m ON m.id = a.message_id
|
||||
WHERE a.created_at >= ${since} AND m.channel_id IS NOT NULL
|
||||
GROUP BY m.channel_id, (m.metadata::jsonb -> 'channel' ->> 'channelName')
|
||||
ORDER BY flagged_count DESC
|
||||
LIMIT 15
|
||||
`);
|
||||
const rows = (result.rows as Record<string, unknown>[]) || [];
|
||||
return rows.map((r) => ({
|
||||
channel_id: String(r.channel_id),
|
||||
channel_name: r.channel_name ? String(r.channel_name) : null,
|
||||
flagged_count: Number(r.flagged_count),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Hour-of-day distribution of moderation actions over the last `days` days.
|
||||
* 24 rows (hour 0..23), with total + flagged-by-severity counts.
|
||||
* Powers the Moderation Heatmap by Hour panel.
|
||||
*/
|
||||
async getHourlyModeration(days: number) {
|
||||
const db = getDatabase();
|
||||
const since = Date.now() - days * 24 * 60 * 60 * 1000;
|
||||
const result = await db.execute(sql`
|
||||
SELECT
|
||||
EXTRACT(HOUR FROM to_timestamp(created_at / 1000))::int AS hour,
|
||||
COUNT(*)::int AS total
|
||||
FROM moderation_actions
|
||||
WHERE created_at >= ${since}
|
||||
GROUP BY hour
|
||||
ORDER BY hour
|
||||
`);
|
||||
const rows = (result.rows as Record<string, unknown>[]) || [];
|
||||
const byHour = new Map<number, number>();
|
||||
for (const r of rows) byHour.set(Number(r.hour), Number(r.total));
|
||||
return Array.from({ length: 24 }, (_, h) => ({
|
||||
hour: h,
|
||||
total: byHour.get(h) ?? 0,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Moderation actions filtered to a single category (drill-down).
|
||||
* Powers the Flag Category Drill-down panel.
|
||||
*/
|
||||
async getByCategory(days: number, category: string, limit = 50) {
|
||||
const db = getDatabase();
|
||||
const since = Date.now() - days * 24 * 60 * 60 * 1000;
|
||||
const result = await db.execute(
|
||||
sql.raw(`
|
||||
SELECT
|
||||
a.id, a.message_id, a.user_id, a.guild_id, a.action_type,
|
||||
a.reason, a.status, a.created_at, a.severity, a.confidence, a.score,
|
||||
m.username, LEFT(m.content, 300) AS content
|
||||
FROM moderation_actions a
|
||||
LEFT JOIN messages m ON m.id = a.message_id
|
||||
WHERE a.created_at >= ${since}
|
||||
AND a.categories IS NOT NULL
|
||||
AND a.categories::jsonb @> ${JSON.stringify([category])}::jsonb
|
||||
ORDER BY a.created_at DESC
|
||||
LIMIT ${limit}
|
||||
`),
|
||||
);
|
||||
const rows = (result.rows as Record<string, unknown>[]) || [];
|
||||
return rows.map((r) => ({
|
||||
id: String(r.id ?? ""),
|
||||
message_id: r.message_id ? String(r.message_id) : null,
|
||||
user_id: r.user_id ? String(r.user_id) : null,
|
||||
guild_id: String(r.guild_id ?? ""),
|
||||
action_type: String(r.action_type ?? "unknown"),
|
||||
reason: r.reason ? String(r.reason) : null,
|
||||
status: String(r.status ?? "unknown"),
|
||||
created_at: r.created_at ? Number(r.created_at) : null,
|
||||
severity: r.severity ? String(r.severity) : null,
|
||||
confidence: r.confidence != null ? Number(r.confidence) : null,
|
||||
score: r.score != null ? Number(r.score) : null,
|
||||
username: r.username ? String(r.username) : null,
|
||||
content: r.content ? String(r.content) : null,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-moderation coverage over the last `days` days.
|
||||
* Run completion rate from ai_analysis_runs — what fraction of analysis runs
|
||||
* completed (vs failed/pending). Public "how much is automated" trust metric.
|
||||
*/
|
||||
async getCoverage(days: number) {
|
||||
const db = getDatabase();
|
||||
const since = Date.now() - days * 24 * 60 * 60 * 1000;
|
||||
const result = await db.execute(sql`
|
||||
SELECT status, COUNT(*)::int AS c
|
||||
FROM ai_analysis_runs
|
||||
WHERE created_at >= ${since}
|
||||
GROUP BY status
|
||||
`);
|
||||
const rows = (result.rows as Record<string, unknown>[]) || [];
|
||||
const counts: Record<string, number> = {};
|
||||
let total = 0;
|
||||
for (const r of rows) {
|
||||
const s = String(r.status);
|
||||
const c = Number(r.c ?? 0);
|
||||
counts[s] = c;
|
||||
total += c;
|
||||
}
|
||||
const completed = counts.completed ?? 0;
|
||||
const failed = counts.failed ?? 0;
|
||||
const pending = (counts.pending ?? 0) + (counts.processing ?? 0);
|
||||
return {
|
||||
total,
|
||||
completed,
|
||||
failed,
|
||||
pending,
|
||||
coverage_rate:
|
||||
total > 0 ? Number(((completed / total) * 100).toFixed(1)) : 0,
|
||||
failed_rate: total > 0 ? Number(((failed / total) * 100).toFixed(1)) : 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const moderationRepository = new ModerationRepository();
|
||||
|
||||
@@ -15,6 +15,26 @@ export class ModerationService {
|
||||
return moderationRepository.getTrends(days);
|
||||
}
|
||||
|
||||
async getTopFlaggedDomains(days = 30) {
|
||||
return moderationRepository.getTopFlaggedDomains(days);
|
||||
}
|
||||
|
||||
async getTopFlaggedChannels(days = 30) {
|
||||
return moderationRepository.getTopFlaggedChannels(days);
|
||||
}
|
||||
|
||||
async getHourlyModeration(days = 30) {
|
||||
return moderationRepository.getHourlyModeration(days);
|
||||
}
|
||||
|
||||
async getByCategory(days = 30, category: string) {
|
||||
return moderationRepository.getByCategory(days, category);
|
||||
}
|
||||
|
||||
async getCoverage(days = 30) {
|
||||
return moderationRepository.getCoverage(days);
|
||||
}
|
||||
|
||||
async listActions(query: ListModerationQuery) {
|
||||
logger.debug({ query }, "Listing moderation actions");
|
||||
return moderationRepository.listActions(query);
|
||||
|
||||
@@ -5,6 +5,7 @@ import { chatRequestSchema } from "../modules/chatbot/chatbot.schema";
|
||||
import { chatbotService } from "../modules/chatbot/chatbot.service";
|
||||
// ── Service imports ──────────────────────────────────────────────
|
||||
import { dashboardService } from "../modules/dashboard/dashboard.service";
|
||||
import { knowledgeService } from "../modules/knowledge/knowledge.service";
|
||||
import {
|
||||
mediaLoopSchema,
|
||||
mediaQueueSchema,
|
||||
@@ -151,6 +152,17 @@ const messagesRouter = {
|
||||
}),
|
||||
)
|
||||
.handler(({ input }) => messagesService.getActivity(input.days)),
|
||||
// Public, read-only recent message edits (evasion tracker).
|
||||
editHistory: os
|
||||
.input(
|
||||
z.object({
|
||||
limit: z.coerce.number().int().positive().default(50),
|
||||
channelId: z.string().optional(),
|
||||
}),
|
||||
)
|
||||
.handler(({ input }) =>
|
||||
messagesService.getRecentEdits(input.limit, input.channelId),
|
||||
),
|
||||
};
|
||||
|
||||
// ── Moderation ───────────────────────────────────────────────────
|
||||
@@ -180,6 +192,51 @@ const moderationRouter = {
|
||||
}),
|
||||
)
|
||||
.handler(({ input }) => moderationService.getTrends(input.days)),
|
||||
// Flagged link / scam domain ranking (public Scam Domain panel).
|
||||
topDomains: os
|
||||
.input(
|
||||
z.object({
|
||||
days: z.coerce.number().int().positive().max(365).default(30),
|
||||
}),
|
||||
)
|
||||
.handler(({ input }) => moderationService.getTopFlaggedDomains(input.days)),
|
||||
// Top flagged channels (join moderation_actions → messages).
|
||||
topChannels: os
|
||||
.input(
|
||||
z.object({
|
||||
days: z.coerce.number().int().positive().max(365).default(30),
|
||||
}),
|
||||
)
|
||||
.handler(({ input }) =>
|
||||
moderationService.getTopFlaggedChannels(input.days),
|
||||
),
|
||||
// Hour-of-day moderation distribution (heatmap by hour).
|
||||
byHour: os
|
||||
.input(
|
||||
z.object({
|
||||
days: z.coerce.number().int().positive().max(365).default(30),
|
||||
}),
|
||||
)
|
||||
.handler(({ input }) => moderationService.getHourlyModeration(input.days)),
|
||||
// Flag category drill-down (list actions for one category).
|
||||
byCategory: os
|
||||
.input(
|
||||
z.object({
|
||||
days: z.coerce.number().int().positive().max(365).default(30),
|
||||
category: z.string().min(1),
|
||||
}),
|
||||
)
|
||||
.handler(({ input }) =>
|
||||
moderationService.getByCategory(input.days, input.category),
|
||||
),
|
||||
// Auto-moderation coverage (analysis run completion rate).
|
||||
coverage: os
|
||||
.input(
|
||||
z.object({
|
||||
days: z.coerce.number().int().positive().max(365).default(30),
|
||||
}),
|
||||
)
|
||||
.handler(({ input }) => moderationService.getCoverage(input.days)),
|
||||
};
|
||||
|
||||
// ── Media ────────────────────────────────────────────────────────
|
||||
@@ -321,7 +378,29 @@ const chatbotRouter = {
|
||||
}),
|
||||
};
|
||||
|
||||
// ── Config (public dashboard config snapshot) ──────────────────────
|
||||
// ── Knowledge (public read-only culture glossary + term KB) ───────
|
||||
const knowledgeRouter = {
|
||||
channelCultures: os
|
||||
.input(
|
||||
z.object({
|
||||
limit: z.coerce.number().int().positive().default(50),
|
||||
search: z.string().optional(),
|
||||
}),
|
||||
)
|
||||
.handler(({ input }) =>
|
||||
knowledgeService.listChannelCultures(input.limit, input.search),
|
||||
),
|
||||
glossary: os
|
||||
.input(
|
||||
z.object({
|
||||
limit: z.coerce.number().int().positive().default(50),
|
||||
search: z.string().optional(),
|
||||
}),
|
||||
)
|
||||
.handler(({ input }) =>
|
||||
knowledgeService.listGlossary(input.limit, input.search),
|
||||
),
|
||||
};
|
||||
const configRouter = {
|
||||
get: os.handler(() => ({
|
||||
monitorGuildId: config.MONITOR_GUILD_ID || null,
|
||||
@@ -360,6 +439,7 @@ export const appRouter = {
|
||||
chatbot: chatbotRouter,
|
||||
config: configRouter,
|
||||
uiState: uiStateRouter,
|
||||
knowledge: knowledgeRouter,
|
||||
};
|
||||
|
||||
export type AppRouter = typeof appRouter;
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { PageTransition } from "@/components/shared";
|
||||
import { getChannelCultures } from "@/lib/api/server";
|
||||
import type { ChannelCultureRow } from "@/lib/types";
|
||||
import { ChannelsView } from "./view";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function ChannelsPage() {
|
||||
let cultures: ChannelCultureRow[] | undefined;
|
||||
try {
|
||||
cultures = await getChannelCultures(100);
|
||||
} catch {
|
||||
cultures = undefined;
|
||||
}
|
||||
return (
|
||||
<PageTransition>
|
||||
<ChannelsView initialCultures={cultures} />
|
||||
</PageTransition>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
"use client";
|
||||
|
||||
import { ChannelCultureGlossary } from "@/components/ChannelCultureGlossary";
|
||||
import { SkeletonPanel } from "@/components/shared";
|
||||
import { useChannelCultures } from "@/hooks";
|
||||
import type { ChannelCultureRow } from "@/lib/types";
|
||||
|
||||
export function ChannelsView({
|
||||
initialCultures,
|
||||
}: {
|
||||
initialCultures?: ChannelCultureRow[];
|
||||
}) {
|
||||
const { data: cultures } = useChannelCultures(100, initialCultures);
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{cultures ? (
|
||||
<ChannelCultureGlossary cultures={cultures} />
|
||||
) : (
|
||||
<SkeletonPanel rows={6} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { PageTransition } from "@/components/shared";
|
||||
import { getGlossary } from "@/lib/api/server";
|
||||
import type { GlossaryRow } from "@/lib/types";
|
||||
import { GlossaryView } from "./view";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function GlossaryPage() {
|
||||
let terms: GlossaryRow[] | undefined;
|
||||
try {
|
||||
terms = await getGlossary(100);
|
||||
} catch {
|
||||
terms = undefined;
|
||||
}
|
||||
return (
|
||||
<PageTransition>
|
||||
<GlossaryView initialTerms={terms} />
|
||||
</PageTransition>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { SkeletonPanel } from "@/components/shared";
|
||||
import { TermGlossary } from "@/components/TermGlossary";
|
||||
import { useGlossary } from "@/hooks";
|
||||
import type { GlossaryRow } from "@/lib/types";
|
||||
|
||||
export function GlossaryView({
|
||||
initialTerms,
|
||||
}: {
|
||||
initialTerms?: GlossaryRow[];
|
||||
}) {
|
||||
const { data: terms } = useGlossary(100, initialTerms);
|
||||
return terms ? <TermGlossary terms={terms} /> : <SkeletonPanel rows={6} />;
|
||||
}
|
||||
@@ -1,5 +1,10 @@
|
||||
import { PageTransition } from "@/components/shared";
|
||||
import { getConfig, getGuilds, getMessages } from "@/lib/api/server";
|
||||
import {
|
||||
getConfig,
|
||||
getGuilds,
|
||||
getMessages,
|
||||
getRecentEdits,
|
||||
} from "@/lib/api/server";
|
||||
import { MessagesView } from "./view";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -11,12 +16,14 @@ export default async function MessagesPage() {
|
||||
data: import("@/lib/types").MessageRecord[];
|
||||
nextCursor: string | null;
|
||||
} | null = null;
|
||||
let initialEdits: import("@/lib/types").EditHistoryRow[] | undefined;
|
||||
try {
|
||||
[config, guilds] = await Promise.all([getConfig(), getGuilds()]);
|
||||
const gid = config?.monitorGuildId;
|
||||
if (gid) {
|
||||
initialMessages = await getMessages(gid, undefined, 50);
|
||||
}
|
||||
initialEdits = await getRecentEdits(50);
|
||||
} catch {
|
||||
/* client hooks surface errors */
|
||||
}
|
||||
@@ -26,6 +33,7 @@ export default async function MessagesPage() {
|
||||
initialGuilds={guilds}
|
||||
initialGuildId={config?.monitorGuildId ?? null}
|
||||
initialMessages={initialMessages}
|
||||
initialEdits={initialEdits}
|
||||
/>
|
||||
</PageTransition>
|
||||
);
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { ActivityHeatmap } from "@/components/ActivityHeatmap";
|
||||
import { useAmbient } from "@/components/ambient/ambient-context";
|
||||
import { EditHistory } from "@/components/EditHistory";
|
||||
import {
|
||||
Avatar,
|
||||
Badge,
|
||||
@@ -37,6 +38,7 @@ import {
|
||||
useMessagesHasMore,
|
||||
useMessagesStream,
|
||||
useMessagesWsSync,
|
||||
useRecentEdits,
|
||||
useSemanticSearch,
|
||||
} from "@/hooks";
|
||||
import { aiTone } from "@/lib/ai-status";
|
||||
@@ -48,7 +50,12 @@ import {
|
||||
renderMessageContent,
|
||||
safeParseJsonArray,
|
||||
} from "@/lib/format";
|
||||
import type { AiStatus, Guild, MessageRecord } from "@/lib/types";
|
||||
import type {
|
||||
AiStatus,
|
||||
EditHistoryRow,
|
||||
Guild,
|
||||
MessageRecord,
|
||||
} from "@/lib/types";
|
||||
import { staggerDelay } from "@/lib/utils";
|
||||
import { useWebSocket } from "@/lib/ws/context";
|
||||
|
||||
@@ -56,6 +63,7 @@ export function MessagesView({
|
||||
initialGuilds,
|
||||
initialGuildId,
|
||||
initialMessages,
|
||||
initialEdits,
|
||||
}: {
|
||||
initialGuilds?: Guild[];
|
||||
initialGuildId?: string | null;
|
||||
@@ -63,6 +71,7 @@ export function MessagesView({
|
||||
data: MessageRecord[];
|
||||
nextCursor: string | null;
|
||||
} | null;
|
||||
initialEdits?: EditHistoryRow[];
|
||||
}) {
|
||||
const ws = useWebSocket();
|
||||
const [guildId, setGuildId] = useState<string | null>(
|
||||
@@ -112,6 +121,7 @@ export function MessagesView({
|
||||
query.trim().length >= 2 && semanticMode,
|
||||
);
|
||||
const activity = useMessageActivity(30);
|
||||
const edits = useRecentEdits(50, undefined, initialEdits);
|
||||
const detail = useMessageDetail(selected);
|
||||
const ambient = useAmbient();
|
||||
|
||||
@@ -431,6 +441,8 @@ export function MessagesView({
|
||||
{activity.data && activity.data.length > 0 && (
|
||||
<ActivityHeatmap buckets={activity.data} />
|
||||
)}
|
||||
|
||||
{edits.data && <EditHistory edits={edits.data} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,14 +15,18 @@ import {
|
||||
} from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useAmbient } from "@/components/ambient/ambient-context";
|
||||
import { CategoryDrilldown } from "@/components/CategoryDrilldown";
|
||||
import { CoverageTiles } from "@/components/CoverageTiles";
|
||||
import { Donut } from "@/components/charts";
|
||||
import { LiveModerationFeed } from "@/components/LiveModerationFeed";
|
||||
import { ModerationHeatmap } from "@/components/ModerationHeatmap";
|
||||
import {
|
||||
Badge,
|
||||
GlassPanel,
|
||||
Select,
|
||||
type SelectOption,
|
||||
} from "@/components/primitives";
|
||||
import { ScamDomains } from "@/components/ScamDomains";
|
||||
import {
|
||||
ErrorState,
|
||||
MetricTile,
|
||||
@@ -31,12 +35,18 @@ import {
|
||||
SkeletonPanel,
|
||||
SkeletonRows,
|
||||
} from "@/components/shared";
|
||||
import { TopChannels } from "@/components/TopChannels";
|
||||
import { TopicTrends } from "@/components/TopicTrends";
|
||||
import {
|
||||
useHourlyModeration,
|
||||
useLiveModeration,
|
||||
useModerationActions,
|
||||
useModerationByCategory,
|
||||
useModerationCoverage,
|
||||
useModerationStats,
|
||||
useModerationTrends,
|
||||
useTopFlaggedChannels,
|
||||
useTopFlaggedDomains,
|
||||
} from "@/hooks";
|
||||
import { aiTone } from "@/lib/ai-status";
|
||||
import { downloadCsv } from "@/lib/csv";
|
||||
@@ -81,6 +91,13 @@ export function ModerationView({
|
||||
);
|
||||
const liveActions = useLiveModeration(initialActions ?? [], 50);
|
||||
const { data: trends } = useModerationTrends(30);
|
||||
const { data: domains } = useTopFlaggedDomains(30);
|
||||
const { data: channels } = useTopFlaggedChannels(30);
|
||||
const { data: hourly } = useHourlyModeration(30);
|
||||
const { data: coverage } = useModerationCoverage(30);
|
||||
const [drilldown, setDrilldown] = useState<string | null>(null);
|
||||
const { data: categoryActions, isValidating: categoryLoading } =
|
||||
useModerationByCategory(drilldown ? 30 : 0, drilldown);
|
||||
|
||||
const failedRate = stats ? stats.failed_rate * 100 : 0;
|
||||
|
||||
@@ -172,6 +189,44 @@ export function ModerationView({
|
||||
<LiveModerationFeed actions={liveActions} />
|
||||
</div>
|
||||
|
||||
{coverage ? (
|
||||
<CoverageTiles coverage={coverage} />
|
||||
) : (
|
||||
<SkeletonPanel rows={3} className="lg:col-span-5" />
|
||||
)}
|
||||
|
||||
<div className="lg:col-span-2">
|
||||
{domains ? (
|
||||
<ScamDomains domains={domains} />
|
||||
) : (
|
||||
<SkeletonPanel rows={6} />
|
||||
)}
|
||||
</div>
|
||||
<div className="lg:col-span-2">
|
||||
{hourly ? (
|
||||
<ModerationHeatmap hours={hourly} />
|
||||
) : (
|
||||
<SkeletonPanel rows={6} />
|
||||
)}
|
||||
</div>
|
||||
<div className="lg:col-span-1">
|
||||
{channels ? (
|
||||
<TopChannels channels={channels} />
|
||||
) : (
|
||||
<SkeletonPanel rows={6} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="lg:col-span-3">
|
||||
<CategoryDrilldown
|
||||
trends={trends ?? { categories: [], severities: [], actions: [] }}
|
||||
selected={drilldown}
|
||||
actions={categoryActions ?? []}
|
||||
loading={categoryLoading}
|
||||
onSelect={setDrilldown}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<GlassPanel className="lg:col-span-2">
|
||||
<SectionHeader eyebrow="health" title="Breakdown" />
|
||||
<div className="flex items-center gap-5">
|
||||
|
||||
@@ -24,7 +24,7 @@ export function ActivityHeatmap({
|
||||
for (const b of buckets) {
|
||||
const k = `${b.channelId}:${b.hour}`;
|
||||
byKey.set(k, (byKey.get(k) ?? 0) + b.count);
|
||||
if (byKey.get(k)! > max) max = byKey.get(k)!;
|
||||
if ((byKey.get(k) ?? 0) > max) max = byKey.get(k) ?? 0;
|
||||
}
|
||||
|
||||
if (buckets.length === 0) {
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
"use client";
|
||||
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import { Badge, GlassPanel } from "@/components/primitives";
|
||||
import { SectionHeader } from "@/components/shared";
|
||||
import { formatNumber, formatRelativeTime } from "@/lib/format";
|
||||
import type { CategoryAction, ModerationTrends } from "@/lib/types";
|
||||
|
||||
const SEVERITY_TONE: Record<
|
||||
string,
|
||||
"signal" | "amber" | "vermilion" | "neutral"
|
||||
> = {
|
||||
critical: "vermilion",
|
||||
high: "vermilion",
|
||||
medium: "amber",
|
||||
low: "signal",
|
||||
none: "neutral",
|
||||
};
|
||||
|
||||
interface CategoryDrilldownProps {
|
||||
trends: ModerationTrends;
|
||||
selected?: string | null;
|
||||
actions?: CategoryAction[];
|
||||
loading?: boolean;
|
||||
onSelect: (category: string | null) => void;
|
||||
}
|
||||
|
||||
export function CategoryDrilldown({
|
||||
trends,
|
||||
selected,
|
||||
actions,
|
||||
loading,
|
||||
onSelect,
|
||||
}: CategoryDrilldownProps) {
|
||||
const maxCat = trends.categories.reduce((m, c) => Math.max(m, c.count), 0);
|
||||
|
||||
return (
|
||||
<GlassPanel className="lg:col-span-3">
|
||||
<SectionHeader eyebrow="drill-down" title="Flag Category" />
|
||||
{selected ? (
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelect(null)}
|
||||
className="text-xs text-ink-soft hover:text-ink"
|
||||
>
|
||||
← Back to all categories
|
||||
</button>
|
||||
<span className="text-xs text-ink-faint">
|
||||
/ {selected} (
|
||||
{loading ? "loading…" : formatNumber(actions?.length ?? 0)} actions)
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<p className="mb-2 text-xs text-ink-faint">
|
||||
Click a category to list the underlying moderation actions.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!selected ? (
|
||||
<div className="space-y-2">
|
||||
{trends.categories.map((c) => {
|
||||
const pct = maxCat > 0 ? Math.max(2, (c.count / maxCat) * 100) : 0;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={c.name}
|
||||
onClick={() => onSelect(c.name)}
|
||||
className="flex w-full items-center gap-3 text-left text-sm"
|
||||
>
|
||||
<span className="w-36 shrink-0 truncate text-ink-soft">
|
||||
{c.name}
|
||||
</span>
|
||||
<div className="h-2 flex-1 overflow-hidden rounded-full bg-white/5">
|
||||
<div
|
||||
className="h-full rounded-full bg-signal"
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="mono w-10 text-right text-ink">
|
||||
{formatNumber(c.count)}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{loading && <p className="text-xs text-ink-faint">Loading…</p>}
|
||||
{!loading && actions && actions.length === 0 && (
|
||||
<p className="text-xs text-ink-faint">
|
||||
No actions in this category.
|
||||
</p>
|
||||
)}
|
||||
{actions?.slice(0, 12).map((a) => (
|
||||
<div key={a.id} className="flex items-start gap-2 text-sm">
|
||||
<Badge tone={SEVERITY_TONE[a.severity ?? "none"]}>
|
||||
{a.severity ?? "none"}
|
||||
</Badge>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-baseline gap-x-2">
|
||||
<span className="font-medium text-ink">{a.action_type}</span>
|
||||
{a.username && (
|
||||
<span className="text-ink-soft">@{a.username}</span>
|
||||
)}
|
||||
<span className="text-ink-faint mono text-xs">
|
||||
{a.created_at ? formatRelativeTime(a.created_at) : ""}
|
||||
</span>
|
||||
</div>
|
||||
{a.content && (
|
||||
<p className="mt-0.5 line-clamp-2 text-ink-faint">
|
||||
{a.content}
|
||||
</p>
|
||||
)}
|
||||
{a.reason && (
|
||||
<p className="mt-0.5 line-clamp-1 text-xs text-ink-faint">
|
||||
Reason: {a.reason}
|
||||
</p>
|
||||
)}
|
||||
<ChevronRight className="mt-1 size-3 text-ink-faint/50" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</GlassPanel>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
"use client";
|
||||
|
||||
import { GlassPanel } from "@/components/primitives";
|
||||
import { SectionHeader } from "@/components/shared";
|
||||
import { downloadCsv } from "@/lib/csv";
|
||||
import { formatRelativeTime } from "@/lib/format";
|
||||
import type { ChannelCultureRow } from "@/lib/types";
|
||||
|
||||
export function ChannelCultureGlossary({
|
||||
cultures,
|
||||
}: {
|
||||
cultures: ChannelCultureRow[];
|
||||
}) {
|
||||
return (
|
||||
<GlassPanel className="lg:col-span-3">
|
||||
<SectionHeader
|
||||
eyebrow="culture"
|
||||
title="Channel Culture Glossary"
|
||||
action={
|
||||
cultures.length > 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
downloadCsv(
|
||||
"channel-cultures.csv",
|
||||
cultures.map((c) => ({
|
||||
channel: c.channel_name ?? c.channel_id,
|
||||
summary: c.culture_summary ?? "",
|
||||
last_analyzed: c.last_analyzed_at ?? "",
|
||||
})),
|
||||
)
|
||||
}
|
||||
className="flex items-center gap-1.5 text-xs text-ink-soft hover:text-ink"
|
||||
>
|
||||
CSV
|
||||
</button>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
{cultures.length === 0 ? (
|
||||
<p className="py-6 text-center text-xs text-ink-faint">
|
||||
No channel cultures captured yet.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{cultures.map((c) => (
|
||||
<div key={c.channel_id} className="text-sm">
|
||||
<div className="flex items-baseline justify-between">
|
||||
<span className="font-medium text-ink">
|
||||
{c.channel_name ?? c.channel_id}
|
||||
</span>
|
||||
{c.last_analyzed_at && (
|
||||
<span className="text-xs text-ink-faint">
|
||||
{formatRelativeTime(c.last_analyzed_at)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{c.culture_summary ? (
|
||||
<p className="mt-1 text-ink-faint">{c.culture_summary}</p>
|
||||
) : (
|
||||
<span className="text-xs text-ink-faint">
|
||||
(no summary captured)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</GlassPanel>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
"use client";
|
||||
|
||||
import { AlertCircle, CheckCircle2, XCircle } from "lucide-react";
|
||||
import { GlassPanel } from "@/components/primitives";
|
||||
import { MetricTile, SectionHeader } from "@/components/shared";
|
||||
import { formatNumber } from "@/lib/format";
|
||||
import type { ModerationCoverage } from "@/lib/types";
|
||||
|
||||
export function CoverageTiles({ coverage }: { coverage: ModerationCoverage }) {
|
||||
const pct = (n: number) => `${n.toFixed(1)}%`;
|
||||
return (
|
||||
<GlassPanel className="lg:col-span-5">
|
||||
<SectionHeader eyebrow="automation" title="Auto-mod Coverage" />
|
||||
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
|
||||
<MetricTile
|
||||
label="Coverage"
|
||||
value={pct(coverage.coverage_rate)}
|
||||
tone={coverage.coverage_rate > 90 ? "signal" : "amber"}
|
||||
icon={<CheckCircle2 className="size-3.5" />}
|
||||
/>
|
||||
<MetricTile
|
||||
label="Completed"
|
||||
value={formatNumber(coverage.completed)}
|
||||
tone="signal"
|
||||
icon={<CheckCircle2 className="size-3.5" />}
|
||||
/>
|
||||
<MetricTile
|
||||
label="Failed"
|
||||
value={formatNumber(coverage.failed)}
|
||||
tone={coverage.failed > 0 ? "vermilion" : "neutral"}
|
||||
icon={<XCircle className="size-3.5" />}
|
||||
/>
|
||||
<MetricTile
|
||||
label="Pending"
|
||||
value={formatNumber(coverage.pending)}
|
||||
tone={coverage.pending > 0 ? "amber" : "neutral"}
|
||||
icon={<AlertCircle className="size-3.5" />}
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-ink-faint">
|
||||
{pct(coverage.failed_rate)} of analysis runs failed. Total runs in
|
||||
window: {formatNumber(coverage.total)}.
|
||||
</p>
|
||||
</GlassPanel>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
"use client";
|
||||
|
||||
import { Download, History } from "lucide-react";
|
||||
import { GlassPanel } from "@/components/primitives";
|
||||
import { SectionHeader } from "@/components/shared";
|
||||
import { downloadCsv } from "@/lib/csv";
|
||||
import { formatRelativeTime } from "@/lib/format";
|
||||
import type { EditHistoryRow } from "@/lib/types";
|
||||
|
||||
export function EditHistory({ edits }: { edits: EditHistoryRow[] }) {
|
||||
return (
|
||||
<GlassPanel className="lg:col-span-4">
|
||||
<SectionHeader
|
||||
eyebrow="evasion"
|
||||
title="Message Edits"
|
||||
action={
|
||||
edits.length > 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
downloadCsv(
|
||||
"message-edits.csv",
|
||||
edits.map((e) => ({
|
||||
author: e.username ?? "",
|
||||
channel: e.channel_name ?? "",
|
||||
old_content: e.old_content,
|
||||
edited_at: e.edited_at,
|
||||
})),
|
||||
)
|
||||
}
|
||||
className="flex items-center gap-1.5 text-xs text-ink-soft hover:text-ink"
|
||||
>
|
||||
<Download className="size-3.5" />
|
||||
CSV
|
||||
</button>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
{edits.length === 0 ? (
|
||||
<p className="py-6 text-center text-xs text-ink-faint">
|
||||
No edited messages recorded recently.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{edits.map((e) => (
|
||||
<div key={e.id} className="text-sm">
|
||||
<div className="flex flex-wrap items-baseline justify-between gap-2">
|
||||
<span className="font-medium text-ink">
|
||||
{e.username ?? "unknown"}
|
||||
</span>
|
||||
<span className="text-xs text-ink-faint">
|
||||
edited {formatRelativeTime(e.edited_at)} ·{" "}
|
||||
{e.channel_name ?? e.channel_id ?? "unknown channel"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 flex items-start gap-1.5">
|
||||
<History className="mt-0.5 size-3.5 shrink-0 text-ink-faint/50" />
|
||||
<pre className="line-clamp-2 whitespace-pre-wrap break-words text-ink-faint/80">
|
||||
{e.old_content || <em>(content not available)</em>}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</GlassPanel>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
"use client";
|
||||
|
||||
import { GlassPanel } from "@/components/primitives";
|
||||
import { SectionHeader } from "@/components/shared";
|
||||
import type { HourlyModeration } from "@/lib/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function ModerationHeatmap({ hours }: { hours: HourlyModeration[] }) {
|
||||
const max = hours.reduce((m, h) => Math.max(m, h.total), 0);
|
||||
const intensity = (v: number) => {
|
||||
if (max <= 0) return "bg-white/5";
|
||||
const t = Math.max(0, Math.min(1, v / max));
|
||||
if (t < 0.25) return "bg-white/[0.06]";
|
||||
if (t < 0.5) return "bg-signal/25";
|
||||
if (t < 0.75) return "bg-signal/50";
|
||||
return "bg-vermilion/60";
|
||||
};
|
||||
|
||||
return (
|
||||
<GlassPanel className="lg:col-span-2">
|
||||
<SectionHeader eyebrow="timing" title="Flagged by Hour (24h)" />
|
||||
<p className="mb-3 text-xs text-ink-faint">
|
||||
Distribution of moderation actions across the day.
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-4 md:grid-cols-3">
|
||||
{hours.map((h) => (
|
||||
<div key={h.hour} className="flex items-center gap-2">
|
||||
<span className="w-8 text-xs text-ink-faint mono">
|
||||
{String(h.hour).padStart(2, "0")}:00
|
||||
</span>
|
||||
<div className="flex-1">
|
||||
<div
|
||||
className={cn(
|
||||
"h-5 rounded transition-colors",
|
||||
intensity(h.total),
|
||||
)}
|
||||
title={`${h.total} actions`}
|
||||
/>
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
"mono w-8 text-right text-xs",
|
||||
h.total === 0 ? "text-ink-faint/40" : "text-ink",
|
||||
)}
|
||||
>
|
||||
{h.total}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</GlassPanel>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
"use client";
|
||||
|
||||
import { Download } from "lucide-react";
|
||||
import { GlassPanel } from "@/components/primitives";
|
||||
import { SectionHeader } from "@/components/shared";
|
||||
import { downloadCsv } from "@/lib/csv";
|
||||
import { formatNumber } from "@/lib/format";
|
||||
import type { FlaggedDomain } from "@/lib/types";
|
||||
|
||||
export function ScamDomains({ domains }: { domains: FlaggedDomain[] }) {
|
||||
const max = domains.reduce((m, d) => Math.max(m, d.count), 0);
|
||||
return (
|
||||
<GlassPanel className="lg:col-span-2">
|
||||
<SectionHeader
|
||||
eyebrow="risk"
|
||||
title="Flagged Link Domains"
|
||||
action={
|
||||
domains.length > 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
downloadCsv(
|
||||
"flagged-domains.csv",
|
||||
domains.map((d) => ({
|
||||
domain: d.domain,
|
||||
flagged_count: d.count,
|
||||
})),
|
||||
)
|
||||
}
|
||||
className="flex items-center gap-1.5 text-xs text-ink-soft hover:text-ink"
|
||||
>
|
||||
<Download className="size-3.5" />
|
||||
CSV
|
||||
</button>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
{domains.length === 0 ? (
|
||||
<p className="py-6 text-center text-xs text-ink-faint">
|
||||
No flagged links captured recently.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{domains.map((d) => {
|
||||
const pct = max > 0 ? Math.max(2, (d.count / max) * 100) : 0;
|
||||
return (
|
||||
<div key={d.domain} className="flex items-center gap-3 text-sm">
|
||||
<span className="w-44 shrink-0 truncate font-mono text-ink-soft">
|
||||
{d.domain}
|
||||
</span>
|
||||
<div className="h-2 flex-1 overflow-hidden rounded-full bg-white/5">
|
||||
<div
|
||||
className="h-full rounded-full bg-[#8b5cf6]"
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="mono w-10 shrink-0 text-right text-ink">
|
||||
{formatNumber(d.count)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</GlassPanel>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
"use client";
|
||||
|
||||
import { Globe } from "lucide-react";
|
||||
import { GlassPanel } from "@/components/primitives";
|
||||
import { SectionHeader } from "@/components/shared";
|
||||
import { downloadCsv } from "@/lib/csv";
|
||||
import { formatRelativeTime } from "@/lib/format";
|
||||
import type { GlossaryRow } from "@/lib/types";
|
||||
|
||||
export function TermGlossary({ terms }: { terms: GlossaryRow[] }) {
|
||||
return (
|
||||
<GlassPanel className="lg:col-span-3">
|
||||
<SectionHeader
|
||||
eyebrow="knowledge"
|
||||
title="Term Knowledge Base"
|
||||
action={
|
||||
terms.length > 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
downloadCsv(
|
||||
"glossary.csv",
|
||||
terms.map((t) => ({
|
||||
term: t.term,
|
||||
definition: t.definition,
|
||||
source: t.source_url,
|
||||
resolved: t.resolved_at,
|
||||
hits: t.hit_count,
|
||||
})),
|
||||
)
|
||||
}
|
||||
className="flex items-center gap-1.5 text-xs text-ink-soft hover:text-ink"
|
||||
>
|
||||
CSV
|
||||
</button>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
{terms.length === 0 ? (
|
||||
<p className="py-6 text-center text-xs text-ink-faint">
|
||||
No term resolutions cached yet.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{terms.map((t) => (
|
||||
<div key={t.term} className="text-sm">
|
||||
<div className="flex flex-wrap items-baseline justify-between gap-2">
|
||||
<span className="font-medium text-ink">{t.term}</span>
|
||||
<span className="text-xs text-ink-faint">
|
||||
{t.hit_count} uses · {formatRelativeTime(t.resolved_at)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-ink-faint">{t.definition}</p>
|
||||
{t.source_url && (
|
||||
<a
|
||||
href={t.source_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="mt-0.5 text-xs text-ink-soft hover:text-ink"
|
||||
>
|
||||
<Globe className="mr-1 inline size-3" />
|
||||
{t.source_url}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</GlassPanel>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
"use client";
|
||||
|
||||
import { Download } from "lucide-react";
|
||||
import { GlassPanel } from "@/components/primitives";
|
||||
import { SectionHeader } from "@/components/shared";
|
||||
import { downloadCsv } from "@/lib/csv";
|
||||
import { formatNumber } from "@/lib/format";
|
||||
import type { FlaggedChannel } from "@/lib/types";
|
||||
|
||||
export function TopChannels({ channels }: { channels: FlaggedChannel[] }) {
|
||||
const max = channels.reduce((m, c) => Math.max(m, c.flagged_count), 0);
|
||||
return (
|
||||
<GlassPanel className="lg:col-span-2">
|
||||
<SectionHeader
|
||||
eyebrow="channels"
|
||||
title="Top Flagged Channels"
|
||||
action={
|
||||
channels.length > 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
downloadCsv(
|
||||
"flagged-channels.csv",
|
||||
channels.map((c) => ({
|
||||
channel_id: c.channel_id,
|
||||
channel_name: c.channel_name ?? "",
|
||||
flagged_count: c.flagged_count,
|
||||
})),
|
||||
)
|
||||
}
|
||||
className="flex items-center gap-1.5 text-xs text-ink-soft hover:text-ink"
|
||||
>
|
||||
<Download className="size-3.5" />
|
||||
CSV
|
||||
</button>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
{channels.length === 0 ? (
|
||||
<p className="py-6 text-center text-xs text-ink-faint">
|
||||
No flagged activity in the selected period.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{channels.map((c) => {
|
||||
const pct =
|
||||
max > 0 ? Math.max(2, (c.flagged_count / max) * 100) : 0;
|
||||
return (
|
||||
<div
|
||||
key={c.channel_id}
|
||||
className="flex items-center gap-3 text-sm"
|
||||
>
|
||||
<span className="w-40 shrink-0 truncate text-ink-soft">
|
||||
{c.channel_name ?? c.channel_id}
|
||||
</span>
|
||||
<div className="h-2 flex-1 overflow-hidden rounded-full bg-white/5">
|
||||
<div
|
||||
className="h-full rounded-full bg-[#f59e0b]"
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="mono w-10 shrink-0 text-right text-ink">
|
||||
{formatNumber(c.flagged_count)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</GlassPanel>
|
||||
);
|
||||
}
|
||||
@@ -10,6 +10,7 @@ export {
|
||||
useUsers,
|
||||
} from "./use-dashboard";
|
||||
export { useGuilds } from "./use-guilds";
|
||||
export { useChannelCultures, useGlossary } from "./use-knowledge";
|
||||
export {
|
||||
useMediaLoop,
|
||||
useMediaQueue,
|
||||
@@ -21,22 +22,28 @@ export {
|
||||
export {
|
||||
useImages,
|
||||
useLoadMore,
|
||||
useMessageActivity,
|
||||
useMessageDetail,
|
||||
useMessageSearch,
|
||||
useMessages,
|
||||
useMessagesHasMore,
|
||||
useMessagesStream,
|
||||
useMessagesWsSync,
|
||||
useRecentEdits,
|
||||
useReview,
|
||||
useSemanticSearch,
|
||||
useTextChannels,
|
||||
useMessageActivity,
|
||||
} from "./use-messages";
|
||||
export {
|
||||
useHourlyModeration,
|
||||
useLiveModeration,
|
||||
useModerationActions,
|
||||
useModerationByCategory,
|
||||
useModerationCoverage,
|
||||
useModerationStats,
|
||||
useModerationTrends,
|
||||
useTopFlaggedChannels,
|
||||
useTopFlaggedDomains,
|
||||
} from "./use-moderation";
|
||||
export {
|
||||
useDeleteRecording,
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import useSWR from "swr";
|
||||
import { knowledgeApi } from "@/lib/api";
|
||||
import type { ChannelCultureRow, GlossaryRow } from "@/lib/types";
|
||||
|
||||
export function useChannelCultures(
|
||||
limit = 100,
|
||||
initialData?: ChannelCultureRow[],
|
||||
) {
|
||||
return useSWR<ChannelCultureRow[]>(
|
||||
["channel-cultures", limit],
|
||||
() => knowledgeApi.channelCultures(limit),
|
||||
{ fallbackData: initialData },
|
||||
);
|
||||
}
|
||||
|
||||
export function useGlossary(limit = 100, initialData?: GlossaryRow[]) {
|
||||
return useSWR<GlossaryRow[]>(
|
||||
["glossary", limit],
|
||||
() => knowledgeApi.glossary(limit),
|
||||
{ fallbackData: initialData },
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { messagesApi, voiceApi } from "@/lib/api";
|
||||
import type {
|
||||
AttachmentRecord,
|
||||
Channel,
|
||||
EditHistoryRow,
|
||||
MessageActivityBucket,
|
||||
MessageRecord,
|
||||
SemanticSearchResult,
|
||||
@@ -381,3 +382,15 @@ export function useMessageActivity(days = 30) {
|
||||
messagesApi.getActivity(days),
|
||||
);
|
||||
}
|
||||
|
||||
export function useRecentEdits(
|
||||
limit = 50,
|
||||
channelId?: string,
|
||||
initialData?: EditHistoryRow[],
|
||||
) {
|
||||
return useSWR<EditHistoryRow[]>(
|
||||
["recent-edits", limit, channelId ?? null],
|
||||
() => messagesApi.getRecentEdits(limit, channelId),
|
||||
{ fallbackData: initialData },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,12 @@ import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import useSWR from "swr";
|
||||
import { moderationApi } from "@/lib/api";
|
||||
import type {
|
||||
CategoryAction,
|
||||
FlaggedChannel,
|
||||
FlaggedDomain,
|
||||
HourlyModeration,
|
||||
ModerationAction,
|
||||
ModerationCoverage,
|
||||
ModerationStats,
|
||||
ModerationTrends,
|
||||
} from "@/lib/types";
|
||||
@@ -82,3 +87,35 @@ export function useModerationTrends(days = 30, initialData?: ModerationTrends) {
|
||||
{ fallbackData: initialData },
|
||||
);
|
||||
}
|
||||
|
||||
export function useTopFlaggedDomains(days = 30) {
|
||||
return useSWR<FlaggedDomain[]>(["moderation-domains", days], () =>
|
||||
moderationApi.getTopDomains(days),
|
||||
);
|
||||
}
|
||||
|
||||
export function useTopFlaggedChannels(days = 30) {
|
||||
return useSWR<FlaggedChannel[]>(["moderation-channels", days], () =>
|
||||
moderationApi.getTopChannels(days),
|
||||
);
|
||||
}
|
||||
|
||||
export function useHourlyModeration(days = 30) {
|
||||
return useSWR<HourlyModeration[]>(["moderation-byhour", days], () =>
|
||||
moderationApi.getHourlyModeration(days),
|
||||
);
|
||||
}
|
||||
|
||||
export function useModerationByCategory(days = 30, category: string | null) {
|
||||
return useSWR<CategoryAction[]>(
|
||||
category ? ["moderation-bycategory", days, category] : null,
|
||||
() => moderationApi.getByCategory(days, category as string),
|
||||
{ keepPreviousData: true },
|
||||
);
|
||||
}
|
||||
|
||||
export function useModerationCoverage(days = 30) {
|
||||
return useSWR<ModerationCoverage>(["moderation-coverage", days], () =>
|
||||
moderationApi.getCoverage(days),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,9 +6,16 @@ export { orpc } from "../orpc/client";
|
||||
export { chatbotApi } from "./chatbot";
|
||||
export { configApi } from "./config";
|
||||
export { dashboardApi } from "./dashboard";
|
||||
export { knowledgeApi } from "./knowledge";
|
||||
export { mediaApi } from "./media";
|
||||
export { messagesApi } from "./messages";
|
||||
export { moderationApi } from "./moderation";
|
||||
export { recordingsApi } from "./recordings";
|
||||
// Re-export server-side fetchers for use inside React Server Components.
|
||||
export {
|
||||
getChannelCultures,
|
||||
getGlossary,
|
||||
getRecentEdits,
|
||||
} from "./server";
|
||||
export { uiStateApi } from "./ui-state";
|
||||
export { voiceApi } from "./voice";
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { orpc } from "@/lib/orpc/client";
|
||||
import type { ChannelCultureRow, GlossaryRow } from "@/lib/types";
|
||||
|
||||
export const knowledgeApi = {
|
||||
channelCultures: (limit = 100, search?: string) =>
|
||||
orpc.knowledge.channelCultures({
|
||||
limit,
|
||||
search,
|
||||
}) as unknown as Promise<ChannelCultureRow[]>,
|
||||
|
||||
glossary: (limit = 100, search?: string) =>
|
||||
orpc.knowledge.glossary({
|
||||
limit,
|
||||
search,
|
||||
}) as unknown as Promise<GlossaryRow[]>,
|
||||
};
|
||||
@@ -1,6 +1,7 @@
|
||||
import { orpc } from "@/lib/orpc/client";
|
||||
import type {
|
||||
AttachmentRecord,
|
||||
EditHistoryRow,
|
||||
MessageActivityBucket,
|
||||
MessageRecord,
|
||||
SemanticSearchResult,
|
||||
@@ -80,4 +81,10 @@ export const messagesApi = {
|
||||
orpc.messages.activity({ days }) as unknown as Promise<
|
||||
MessageActivityBucket[]
|
||||
>,
|
||||
|
||||
// Public, read-only recent message edits (evasion tracker).
|
||||
getRecentEdits: (limit = 50, channelId?: string) =>
|
||||
orpc.messages.editHistory({ limit, channelId }) as unknown as Promise<
|
||||
EditHistoryRow[]
|
||||
>,
|
||||
};
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { orpc } from "@/lib/orpc/client";
|
||||
import type {
|
||||
CategoryAction,
|
||||
FlaggedChannel,
|
||||
FlaggedDomain,
|
||||
HourlyModeration,
|
||||
ModerationCoverage,
|
||||
ModerationStats,
|
||||
ModerationTrends,
|
||||
PaginatedModerationActions,
|
||||
@@ -24,4 +29,25 @@ export const moderationApi = {
|
||||
|
||||
getTrends: (days = 30) =>
|
||||
orpc.moderation.trends({ days }) as unknown as Promise<ModerationTrends>,
|
||||
|
||||
getTopDomains: (days = 30) =>
|
||||
orpc.moderation.topDomains({ days }) as unknown as Promise<FlaggedDomain[]>,
|
||||
|
||||
getTopChannels: (days = 30) =>
|
||||
orpc.moderation.topChannels({ days }) as unknown as Promise<
|
||||
FlaggedChannel[]
|
||||
>,
|
||||
|
||||
getHourlyModeration: (days = 30) =>
|
||||
orpc.moderation.byHour({ days }) as unknown as Promise<HourlyModeration[]>,
|
||||
|
||||
getByCategory: (days = 30, category: string) =>
|
||||
orpc.moderation.byCategory({ days, category }) as unknown as Promise<
|
||||
CategoryAction[]
|
||||
>,
|
||||
|
||||
getCoverage: (days = 30) =>
|
||||
orpc.moderation.coverage({
|
||||
days,
|
||||
}) as unknown as Promise<ModerationCoverage>,
|
||||
};
|
||||
|
||||
@@ -16,11 +16,19 @@ import { createORPCClient } from "@orpc/client";
|
||||
import { RPCLink } from "@orpc/client/fetch";
|
||||
import type {
|
||||
AppConfig,
|
||||
ChannelCultureRow,
|
||||
DashboardActivity,
|
||||
DashboardStats,
|
||||
EditHistoryRow,
|
||||
FlaggedChannel,
|
||||
FlaggedDomain,
|
||||
GlossaryRow,
|
||||
Guild,
|
||||
HourlyModeration,
|
||||
MediaState,
|
||||
ModerationCoverage,
|
||||
ModerationStats,
|
||||
ModerationTrends,
|
||||
PaginatedModerationActions,
|
||||
PaginatedRecordings,
|
||||
VoiceStatus,
|
||||
@@ -84,6 +92,33 @@ export async function getModerationActions(limit = 100) {
|
||||
})) as unknown as PaginatedModerationActions;
|
||||
return res.data;
|
||||
}
|
||||
export async function getModerationTrends(
|
||||
days = 30,
|
||||
): Promise<ModerationTrends> {
|
||||
return serverOrpc().moderation.trends({
|
||||
days,
|
||||
}) as unknown as Promise<ModerationTrends>;
|
||||
}
|
||||
export async function getTopFlaggedDomains(days = 30) {
|
||||
return serverOrpc().moderation.topDomains({
|
||||
days,
|
||||
}) as unknown as FlaggedDomain[];
|
||||
}
|
||||
export async function getTopFlaggedChannels(days = 30) {
|
||||
return serverOrpc().moderation.topChannels({
|
||||
days,
|
||||
}) as unknown as FlaggedChannel[];
|
||||
}
|
||||
export async function getHourlyModeration(days = 30) {
|
||||
return serverOrpc().moderation.byHour({
|
||||
days,
|
||||
}) as unknown as HourlyModeration[];
|
||||
}
|
||||
export async function getCoverage(days = 30) {
|
||||
return serverOrpc().moderation.coverage({
|
||||
days,
|
||||
}) as unknown as ModerationCoverage;
|
||||
}
|
||||
|
||||
// ---- Voice ----
|
||||
export async function getGuilds(): Promise<Guild[]> {
|
||||
@@ -122,3 +157,20 @@ export async function getMessages(
|
||||
nextCursor: string | null;
|
||||
}>;
|
||||
}
|
||||
// ---- Knowledge (public read-only) ----
|
||||
export async function getChannelCultures(limit = 100) {
|
||||
return serverOrpc().knowledge.channelCultures({
|
||||
limit,
|
||||
}) as unknown as Promise<ChannelCultureRow[]>;
|
||||
}
|
||||
export async function getGlossary(limit = 100) {
|
||||
return serverOrpc().knowledge.glossary({
|
||||
limit,
|
||||
}) as unknown as Promise<GlossaryRow[]>;
|
||||
}
|
||||
|
||||
export async function getRecentEdits(limit = 50): Promise<EditHistoryRow[]> {
|
||||
return serverOrpc().messages.editHistory({
|
||||
limit,
|
||||
}) as unknown as Promise<EditHistoryRow[]>;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export * from "./dashboard";
|
||||
export * from "./guild";
|
||||
export * from "./knowledge";
|
||||
export * from "./media";
|
||||
export * from "./message";
|
||||
export * from "./moderation";
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
export interface ChannelCultureRow {
|
||||
channel_id: string;
|
||||
guild_id: string | null;
|
||||
channel_name: string | null;
|
||||
culture_summary: string | null;
|
||||
last_analyzed_at: number | null;
|
||||
}
|
||||
|
||||
export interface GlossaryRow {
|
||||
term: string;
|
||||
definition: string;
|
||||
source_url: string;
|
||||
resolved_at: number;
|
||||
hit_count: number;
|
||||
}
|
||||
|
||||
export interface EditHistoryRow {
|
||||
id: string;
|
||||
message_id: string;
|
||||
old_content: string;
|
||||
edited_at: number;
|
||||
channel_id: string | null;
|
||||
channel_name: string | null;
|
||||
username: string | null;
|
||||
}
|
||||
@@ -50,3 +50,44 @@ export interface ModerationTrends {
|
||||
severities: { level: string; count: number }[];
|
||||
actions: { type: string; count: number }[];
|
||||
}
|
||||
|
||||
export interface FlaggedDomain {
|
||||
domain: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface FlaggedChannel {
|
||||
channel_id: string;
|
||||
channel_name: string | null;
|
||||
flagged_count: number;
|
||||
}
|
||||
|
||||
export interface HourlyModeration {
|
||||
hour: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface CategoryAction {
|
||||
id: string;
|
||||
message_id: string | null;
|
||||
user_id: string | null;
|
||||
guild_id: string;
|
||||
action_type: ModerationActionType;
|
||||
reason: string | null;
|
||||
status: ModerationStatus;
|
||||
created_at: number | null;
|
||||
severity: "none" | "low" | "medium" | "high" | "critical" | null;
|
||||
confidence: number | null;
|
||||
score: number | null;
|
||||
username: string | null;
|
||||
content: string | null;
|
||||
}
|
||||
|
||||
export interface ModerationCoverage {
|
||||
total: number;
|
||||
completed: number;
|
||||
failed: number;
|
||||
pending: number;
|
||||
coverage_rate: number;
|
||||
failed_rate: number;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user