From 00e8d68ce572981a6841956d9ec51fad923824f1 Mon Sep 17 00:00:00 2001 From: asepharyana Date: Tue, 18 Aug 2026 20:45:12 +0700 Subject: [PATCH] =?UTF-8?q?feat(gmw):=20public=20features=20#7-14=20?= =?UTF-8?q?=E2=80=94=20scam=20domains,=20top=20channels,=20hourly=20heatma?= =?UTF-8?q?p,=20category=20drill-down,=20coverage=20stats,=20channel=20cul?= =?UTF-8?q?ture=20glossary,=20term=20KB,=20edit=20history?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ALSO fixes: dashboard.repository still JOINed dropped user_reputations table (listUsers/getUserDetail crash). --- .../2026-08-18_gmw-public-features-2-6.md | 13 ++ .../2026-08-18_gmw-public-features-7-15.md | 119 +++++++++++++ .../modules/dashboard/dashboard.repository.ts | 22 +-- .../modules/knowledge/knowledge.repository.ts | 99 +++++++++++ .../modules/knowledge/knowledge.service.ts | 18 ++ .../modules/messages/messages.repository.ts | 38 +++++ .../src/modules/messages/messages.service.ts | 5 + .../moderation/moderation.repository.ts | 160 ++++++++++++++++++ .../modules/moderation/moderation.service.ts | 20 +++ services/backend/src/orpc/router.ts | 82 ++++++++- .../src/app/(dashboard)/channels/page.tsx | 20 +++ .../src/app/(dashboard)/channels/view.tsx | 23 +++ .../src/app/(dashboard)/glossary/page.tsx | 20 +++ .../src/app/(dashboard)/glossary/view.tsx | 15 ++ .../src/app/(dashboard)/messages/page.tsx | 10 +- .../src/app/(dashboard)/messages/view.tsx | 14 +- .../src/app/(dashboard)/moderation/view.tsx | 55 ++++++ .../src/components/ActivityHeatmap.tsx | 2 +- .../src/components/CategoryDrilldown.tsx | 128 ++++++++++++++ .../src/components/ChannelCultureGlossary.tsx | 71 ++++++++ .../frontend/src/components/CoverageTiles.tsx | 46 +++++ .../frontend/src/components/EditHistory.tsx | 68 ++++++++ .../src/components/ModerationHeatmap.tsx | 53 ++++++ .../frontend/src/components/ScamDomains.tsx | 67 ++++++++ .../frontend/src/components/TermGlossary.tsx | 71 ++++++++ .../frontend/src/components/TopChannels.tsx | 72 ++++++++ services/frontend/src/hooks/index.ts | 9 +- services/frontend/src/hooks/use-knowledge.ts | 22 +++ services/frontend/src/hooks/use-messages.ts | 13 ++ services/frontend/src/hooks/use-moderation.ts | 37 ++++ services/frontend/src/lib/api/index.ts | 7 + services/frontend/src/lib/api/knowledge.ts | 16 ++ services/frontend/src/lib/api/messages.ts | 7 + services/frontend/src/lib/api/moderation.ts | 26 +++ services/frontend/src/lib/api/server.ts | 52 ++++++ services/frontend/src/lib/types/index.ts | 1 + services/frontend/src/lib/types/knowledge.ts | 25 +++ services/frontend/src/lib/types/moderation.ts | 41 +++++ 38 files changed, 1542 insertions(+), 25 deletions(-) create mode 100644 .hermes/plans/2026-08-18_gmw-public-features-7-15.md create mode 100644 services/backend/src/modules/knowledge/knowledge.repository.ts create mode 100644 services/backend/src/modules/knowledge/knowledge.service.ts create mode 100644 services/frontend/src/app/(dashboard)/channels/page.tsx create mode 100644 services/frontend/src/app/(dashboard)/channels/view.tsx create mode 100644 services/frontend/src/app/(dashboard)/glossary/page.tsx create mode 100644 services/frontend/src/app/(dashboard)/glossary/view.tsx create mode 100644 services/frontend/src/components/CategoryDrilldown.tsx create mode 100644 services/frontend/src/components/ChannelCultureGlossary.tsx create mode 100644 services/frontend/src/components/CoverageTiles.tsx create mode 100644 services/frontend/src/components/EditHistory.tsx create mode 100644 services/frontend/src/components/ModerationHeatmap.tsx create mode 100644 services/frontend/src/components/ScamDomains.tsx create mode 100644 services/frontend/src/components/TermGlossary.tsx create mode 100644 services/frontend/src/components/TopChannels.tsx create mode 100644 services/frontend/src/hooks/use-knowledge.ts create mode 100644 services/frontend/src/lib/api/knowledge.ts create mode 100644 services/frontend/src/lib/types/knowledge.ts diff --git a/.hermes/plans/2026-08-18_gmw-public-features-2-6.md b/.hermes/plans/2026-08-18_gmw-public-features-2-6.md index 815697d..3abc306 100644 --- a/.hermes/plans/2026-08-18_gmw-public-features-2-6.md +++ b/.hermes/plans/2026-08-18_gmw-public-features-2-6.md @@ -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 diff --git a/.hermes/plans/2026-08-18_gmw-public-features-7-15.md b/.hermes/plans/2026-08-18_gmw-public-features-7-15.md new file mode 100644 index 0000000..c8d6ea5 --- /dev/null +++ b/.hermes/plans/2026-08-18_gmw-public-features-7-15.md @@ -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. diff --git a/services/backend/src/modules/dashboard/dashboard.repository.ts b/services/backend/src/modules/dashboard/dashboard.repository.ts index c1ee021..f9ad4e3 100644 --- a/services/backend/src/modules/dashboard/dashboard.repository.ts +++ b/services/backend/src/modules/dashboard/dashboard.repository.ts @@ -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 | 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 | 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[]).map((r) => ({ id: String(r.id), content: String(r.content), diff --git a/services/backend/src/modules/knowledge/knowledge.repository.ts b/services/backend/src/modules/knowledge/knowledge.repository.ts new file mode 100644 index 0000000..ed9009e --- /dev/null +++ b/services/backend/src/modules/knowledge/knowledge.repository.ts @@ -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[]) || []; + 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[]) || []; + 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(); diff --git a/services/backend/src/modules/knowledge/knowledge.service.ts b/services/backend/src/modules/knowledge/knowledge.service.ts new file mode 100644 index 0000000..0dba852 --- /dev/null +++ b/services/backend/src/modules/knowledge/knowledge.service.ts @@ -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(); diff --git a/services/backend/src/modules/messages/messages.repository.ts b/services/backend/src/modules/messages/messages.repository.ts index 2261859..71ff8e6 100644 --- a/services/backend/src/modules/messages/messages.repository.ts +++ b/services/backend/src/modules/messages/messages.repository.ts @@ -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[]) || []; + 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(); diff --git a/services/backend/src/modules/messages/messages.service.ts b/services/backend/src/modules/messages/messages.service.ts index 06814ab..35fd2ff 100644 --- a/services/backend/src/modules/messages/messages.service.ts +++ b/services/backend/src/modules/messages/messages.service.ts @@ -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). */ diff --git a/services/backend/src/modules/moderation/moderation.repository.ts b/services/backend/src/modules/moderation/moderation.repository.ts index 5c875b5..e813511 100644 --- a/services/backend/src/modules/moderation/moderation.repository.ts +++ b/services/backend/src/modules/moderation/moderation.repository.ts @@ -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[]) || []; + 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[]) || []; + 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[]) || []; + const byHour = new Map(); + 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[]) || []; + 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[]) || []; + const counts: Record = {}; + 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(); diff --git a/services/backend/src/modules/moderation/moderation.service.ts b/services/backend/src/modules/moderation/moderation.service.ts index 0c9b172..de0d6b3 100644 --- a/services/backend/src/modules/moderation/moderation.service.ts +++ b/services/backend/src/modules/moderation/moderation.service.ts @@ -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); diff --git a/services/backend/src/orpc/router.ts b/services/backend/src/orpc/router.ts index 75cd2f6..65fa7dc 100644 --- a/services/backend/src/orpc/router.ts +++ b/services/backend/src/orpc/router.ts @@ -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; diff --git a/services/frontend/src/app/(dashboard)/channels/page.tsx b/services/frontend/src/app/(dashboard)/channels/page.tsx new file mode 100644 index 0000000..edff830 --- /dev/null +++ b/services/frontend/src/app/(dashboard)/channels/page.tsx @@ -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 ( + + + + ); +} diff --git a/services/frontend/src/app/(dashboard)/channels/view.tsx b/services/frontend/src/app/(dashboard)/channels/view.tsx new file mode 100644 index 0000000..9e33728 --- /dev/null +++ b/services/frontend/src/app/(dashboard)/channels/view.tsx @@ -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 ( +
+ {cultures ? ( + + ) : ( + + )} +
+ ); +} diff --git a/services/frontend/src/app/(dashboard)/glossary/page.tsx b/services/frontend/src/app/(dashboard)/glossary/page.tsx new file mode 100644 index 0000000..fcacccb --- /dev/null +++ b/services/frontend/src/app/(dashboard)/glossary/page.tsx @@ -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 ( + + + + ); +} diff --git a/services/frontend/src/app/(dashboard)/glossary/view.tsx b/services/frontend/src/app/(dashboard)/glossary/view.tsx new file mode 100644 index 0000000..1909809 --- /dev/null +++ b/services/frontend/src/app/(dashboard)/glossary/view.tsx @@ -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 ? : ; +} diff --git a/services/frontend/src/app/(dashboard)/messages/page.tsx b/services/frontend/src/app/(dashboard)/messages/page.tsx index 9c35e37..fd2e560 100644 --- a/services/frontend/src/app/(dashboard)/messages/page.tsx +++ b/services/frontend/src/app/(dashboard)/messages/page.tsx @@ -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} /> ); diff --git a/services/frontend/src/app/(dashboard)/messages/view.tsx b/services/frontend/src/app/(dashboard)/messages/view.tsx index 1736be6..093f187 100644 --- a/services/frontend/src/app/(dashboard)/messages/view.tsx +++ b/services/frontend/src/app/(dashboard)/messages/view.tsx @@ -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( @@ -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 && ( )} + + {edits.data && } ); } diff --git a/services/frontend/src/app/(dashboard)/moderation/view.tsx b/services/frontend/src/app/(dashboard)/moderation/view.tsx index 161cd44..80e4b54 100644 --- a/services/frontend/src/app/(dashboard)/moderation/view.tsx +++ b/services/frontend/src/app/(dashboard)/moderation/view.tsx @@ -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(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({ + {coverage ? ( + + ) : ( + + )} + +
+ {domains ? ( + + ) : ( + + )} +
+
+ {hourly ? ( + + ) : ( + + )} +
+
+ {channels ? ( + + ) : ( + + )} +
+ +
+ +
+
diff --git a/services/frontend/src/components/ActivityHeatmap.tsx b/services/frontend/src/components/ActivityHeatmap.tsx index 5a5f1fc..7cf3341 100644 --- a/services/frontend/src/components/ActivityHeatmap.tsx +++ b/services/frontend/src/components/ActivityHeatmap.tsx @@ -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) { diff --git a/services/frontend/src/components/CategoryDrilldown.tsx b/services/frontend/src/components/CategoryDrilldown.tsx new file mode 100644 index 0000000..43a6527 --- /dev/null +++ b/services/frontend/src/components/CategoryDrilldown.tsx @@ -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 ( + + + {selected ? ( +
+ + + / {selected} ( + {loading ? "loading…" : formatNumber(actions?.length ?? 0)} actions) + +
+ ) : ( +

+ Click a category to list the underlying moderation actions. +

+ )} + + {!selected ? ( +
+ {trends.categories.map((c) => { + const pct = maxCat > 0 ? Math.max(2, (c.count / maxCat) * 100) : 0; + return ( + + ); + })} +
+ ) : ( +
+ {loading &&

Loading…

} + {!loading && actions && actions.length === 0 && ( +

+ No actions in this category. +

+ )} + {actions?.slice(0, 12).map((a) => ( +
+ + {a.severity ?? "none"} + +
+
+ {a.action_type} + {a.username && ( + @{a.username} + )} + + {a.created_at ? formatRelativeTime(a.created_at) : ""} + +
+ {a.content && ( +

+ {a.content} +

+ )} + {a.reason && ( +

+ Reason: {a.reason} +

+ )} + +
+
+ ))} +
+ )} +
+ ); +} diff --git a/services/frontend/src/components/ChannelCultureGlossary.tsx b/services/frontend/src/components/ChannelCultureGlossary.tsx new file mode 100644 index 0000000..99be362 --- /dev/null +++ b/services/frontend/src/components/ChannelCultureGlossary.tsx @@ -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 ( + + 0 ? ( + + ) : null + } + /> + {cultures.length === 0 ? ( +

+ No channel cultures captured yet. +

+ ) : ( +
+ {cultures.map((c) => ( +
+
+ + {c.channel_name ?? c.channel_id} + + {c.last_analyzed_at && ( + + {formatRelativeTime(c.last_analyzed_at)} + + )} +
+ {c.culture_summary ? ( +

{c.culture_summary}

+ ) : ( + + (no summary captured) + + )} +
+ ))} +
+ )} +
+ ); +} diff --git a/services/frontend/src/components/CoverageTiles.tsx b/services/frontend/src/components/CoverageTiles.tsx new file mode 100644 index 0000000..c375c3d --- /dev/null +++ b/services/frontend/src/components/CoverageTiles.tsx @@ -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 ( + + +
+ 90 ? "signal" : "amber"} + icon={} + /> + } + /> + 0 ? "vermilion" : "neutral"} + icon={} + /> + 0 ? "amber" : "neutral"} + icon={} + /> +
+

+ {pct(coverage.failed_rate)} of analysis runs failed. Total runs in + window: {formatNumber(coverage.total)}. +

+
+ ); +} diff --git a/services/frontend/src/components/EditHistory.tsx b/services/frontend/src/components/EditHistory.tsx new file mode 100644 index 0000000..cb37cb2 --- /dev/null +++ b/services/frontend/src/components/EditHistory.tsx @@ -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 ( + + 0 ? ( + + ) : null + } + /> + {edits.length === 0 ? ( +

+ No edited messages recorded recently. +

+ ) : ( +
+ {edits.map((e) => ( +
+
+ + {e.username ?? "unknown"} + + + edited {formatRelativeTime(e.edited_at)} ·{" "} + {e.channel_name ?? e.channel_id ?? "unknown channel"} + +
+
+ +
+                  {e.old_content || (content not available)}
+                
+
+
+ ))} +
+ )} +
+ ); +} diff --git a/services/frontend/src/components/ModerationHeatmap.tsx b/services/frontend/src/components/ModerationHeatmap.tsx new file mode 100644 index 0000000..3bcd609 --- /dev/null +++ b/services/frontend/src/components/ModerationHeatmap.tsx @@ -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 ( + + +

+ Distribution of moderation actions across the day. +

+
+ {hours.map((h) => ( +
+ + {String(h.hour).padStart(2, "0")}:00 + +
+
+
+ + {h.total} + +
+ ))} +
+ + ); +} diff --git a/services/frontend/src/components/ScamDomains.tsx b/services/frontend/src/components/ScamDomains.tsx new file mode 100644 index 0000000..6478e4b --- /dev/null +++ b/services/frontend/src/components/ScamDomains.tsx @@ -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 ( + + 0 ? ( + + ) : null + } + /> + {domains.length === 0 ? ( +

+ No flagged links captured recently. +

+ ) : ( +
+ {domains.map((d) => { + const pct = max > 0 ? Math.max(2, (d.count / max) * 100) : 0; + return ( +
+ + {d.domain} + +
+
+
+ + {formatNumber(d.count)} + +
+ ); + })} +
+ )} + + ); +} diff --git a/services/frontend/src/components/TermGlossary.tsx b/services/frontend/src/components/TermGlossary.tsx new file mode 100644 index 0000000..0b1abc6 --- /dev/null +++ b/services/frontend/src/components/TermGlossary.tsx @@ -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 ( + + 0 ? ( + + ) : null + } + /> + {terms.length === 0 ? ( +

+ No term resolutions cached yet. +

+ ) : ( +
+ {terms.map((t) => ( +
+
+ {t.term} + + {t.hit_count} uses · {formatRelativeTime(t.resolved_at)} + +
+

{t.definition}

+ {t.source_url && ( + + + {t.source_url} + + )} +
+ ))} +
+ )} +
+ ); +} diff --git a/services/frontend/src/components/TopChannels.tsx b/services/frontend/src/components/TopChannels.tsx new file mode 100644 index 0000000..a61cf94 --- /dev/null +++ b/services/frontend/src/components/TopChannels.tsx @@ -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 ( + + 0 ? ( + + ) : null + } + /> + {channels.length === 0 ? ( +

+ No flagged activity in the selected period. +

+ ) : ( +
+ {channels.map((c) => { + const pct = + max > 0 ? Math.max(2, (c.flagged_count / max) * 100) : 0; + return ( +
+ + {c.channel_name ?? c.channel_id} + +
+
+
+ + {formatNumber(c.flagged_count)} + +
+ ); + })} +
+ )} + + ); +} diff --git a/services/frontend/src/hooks/index.ts b/services/frontend/src/hooks/index.ts index 56063fc..093c301 100644 --- a/services/frontend/src/hooks/index.ts +++ b/services/frontend/src/hooks/index.ts @@ -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, diff --git a/services/frontend/src/hooks/use-knowledge.ts b/services/frontend/src/hooks/use-knowledge.ts new file mode 100644 index 0000000..730b259 --- /dev/null +++ b/services/frontend/src/hooks/use-knowledge.ts @@ -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( + ["channel-cultures", limit], + () => knowledgeApi.channelCultures(limit), + { fallbackData: initialData }, + ); +} + +export function useGlossary(limit = 100, initialData?: GlossaryRow[]) { + return useSWR( + ["glossary", limit], + () => knowledgeApi.glossary(limit), + { fallbackData: initialData }, + ); +} diff --git a/services/frontend/src/hooks/use-messages.ts b/services/frontend/src/hooks/use-messages.ts index a768937..0a09a99 100644 --- a/services/frontend/src/hooks/use-messages.ts +++ b/services/frontend/src/hooks/use-messages.ts @@ -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( + ["recent-edits", limit, channelId ?? null], + () => messagesApi.getRecentEdits(limit, channelId), + { fallbackData: initialData }, + ); +} diff --git a/services/frontend/src/hooks/use-moderation.ts b/services/frontend/src/hooks/use-moderation.ts index 346ef6f..cce4ce2 100644 --- a/services/frontend/src/hooks/use-moderation.ts +++ b/services/frontend/src/hooks/use-moderation.ts @@ -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(["moderation-domains", days], () => + moderationApi.getTopDomains(days), + ); +} + +export function useTopFlaggedChannels(days = 30) { + return useSWR(["moderation-channels", days], () => + moderationApi.getTopChannels(days), + ); +} + +export function useHourlyModeration(days = 30) { + return useSWR(["moderation-byhour", days], () => + moderationApi.getHourlyModeration(days), + ); +} + +export function useModerationByCategory(days = 30, category: string | null) { + return useSWR( + category ? ["moderation-bycategory", days, category] : null, + () => moderationApi.getByCategory(days, category as string), + { keepPreviousData: true }, + ); +} + +export function useModerationCoverage(days = 30) { + return useSWR(["moderation-coverage", days], () => + moderationApi.getCoverage(days), + ); +} diff --git a/services/frontend/src/lib/api/index.ts b/services/frontend/src/lib/api/index.ts index 08f64e4..7231686 100644 --- a/services/frontend/src/lib/api/index.ts +++ b/services/frontend/src/lib/api/index.ts @@ -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"; diff --git a/services/frontend/src/lib/api/knowledge.ts b/services/frontend/src/lib/api/knowledge.ts new file mode 100644 index 0000000..835f72f --- /dev/null +++ b/services/frontend/src/lib/api/knowledge.ts @@ -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, + + glossary: (limit = 100, search?: string) => + orpc.knowledge.glossary({ + limit, + search, + }) as unknown as Promise, +}; diff --git a/services/frontend/src/lib/api/messages.ts b/services/frontend/src/lib/api/messages.ts index 8bf26be..acdb97e 100644 --- a/services/frontend/src/lib/api/messages.ts +++ b/services/frontend/src/lib/api/messages.ts @@ -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[] + >, }; diff --git a/services/frontend/src/lib/api/moderation.ts b/services/frontend/src/lib/api/moderation.ts index cb4d579..7269f1a 100644 --- a/services/frontend/src/lib/api/moderation.ts +++ b/services/frontend/src/lib/api/moderation.ts @@ -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, + + getTopDomains: (days = 30) => + orpc.moderation.topDomains({ days }) as unknown as Promise, + + getTopChannels: (days = 30) => + orpc.moderation.topChannels({ days }) as unknown as Promise< + FlaggedChannel[] + >, + + getHourlyModeration: (days = 30) => + orpc.moderation.byHour({ days }) as unknown as Promise, + + 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, }; diff --git a/services/frontend/src/lib/api/server.ts b/services/frontend/src/lib/api/server.ts index 7d3275b..d63b941 100644 --- a/services/frontend/src/lib/api/server.ts +++ b/services/frontend/src/lib/api/server.ts @@ -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 { + return serverOrpc().moderation.trends({ + days, + }) as unknown as Promise; +} +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 { @@ -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; +} +export async function getGlossary(limit = 100) { + return serverOrpc().knowledge.glossary({ + limit, + }) as unknown as Promise; +} + +export async function getRecentEdits(limit = 50): Promise { + return serverOrpc().messages.editHistory({ + limit, + }) as unknown as Promise; +} diff --git a/services/frontend/src/lib/types/index.ts b/services/frontend/src/lib/types/index.ts index f90f4ef..3928b3c 100644 --- a/services/frontend/src/lib/types/index.ts +++ b/services/frontend/src/lib/types/index.ts @@ -1,5 +1,6 @@ export * from "./dashboard"; export * from "./guild"; +export * from "./knowledge"; export * from "./media"; export * from "./message"; export * from "./moderation"; diff --git a/services/frontend/src/lib/types/knowledge.ts b/services/frontend/src/lib/types/knowledge.ts new file mode 100644 index 0000000..3e986c6 --- /dev/null +++ b/services/frontend/src/lib/types/knowledge.ts @@ -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; +} diff --git a/services/frontend/src/lib/types/moderation.ts b/services/frontend/src/lib/types/moderation.ts index 16de4ac..0138447 100644 --- a/services/frontend/src/lib/types/moderation.ts +++ b/services/frontend/src/lib/types/moderation.ts @@ -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; +}