diff --git a/MEMORY.md b/MEMORY.md deleted file mode 100644 index 8ed8e90..0000000 --- a/MEMORY.md +++ /dev/null @@ -1,92 +0,0 @@ -# Durable Memory Wiki - -Consolidated knowledge and long-term facts. - -## Core Learnings - -- **Topic:** session - **Context:** Failure observation: session - *Promoted on:* 2026-06-10T03:02:46.068Z - -- **Topic:** biome check diagnosticlevelerror - **Context:** Failure observation: Exit code 1 -$ biome check --diagnostic-level=error . -src/modules/voice-recording/recorder/segment.ts:1:1 assist/source/organizeImports FIXABLE ━━━━━━━━━━ - - × Sort these imports - *Promoted on:* 2026-06-10T03:02:46.068Z - -- **Topic:** typecheck scope workspace - **Context:** Failure observation: Exit code 2 -$ pnpm -r run typecheck -Scope: 6 of 7 workspace projects -packages/shared typecheck$ tsc --noEmit -packages/shared typecheck: Done -services/backend typecheck$ tsc --noEmit -services/discord-g - *Promoted on:* 2026-06-10T03:02:46.068Z - -- **Topic:** eisdir illegal operation - **Context:** Failure observation: EISDIR: illegal operation on a directory, read '/mnt/code/bete/packages/shared/src/types/' - *Promoted on:* 2026-06-10T03:02:46.068Z - -- **Topic:** exist current working - **Context:** Failure observation: File does not exist. Note: your current working directory is /mnt/code/bete. - *Promoted on:* 2026-06-10T03:02:46.068Z - -- **Topic:** error 32603 pattern - **Context:** Failure observation: MCP error -32603: pattern must be a non-empty string. - *Promoted on:* 2026-06-10T03:02:46.068Z - -- **Topic:** errpnpmnoscript missing script - **Context:** Failure observation: Exit code 2 -[ERR_PNPM_NO_SCRIPT] Missing script: build:shared - -Command "build:shared" not found. Did you mean "pnpm run build:backend"? -$ tsc -$ pnpm --filter './services/backend' run build -$ tsc -$ pnp - *Promoted on:* 2026-06-10T03:02:46.068Z - -- **Topic:** projects matched filters - **Context:** Failure observation: Exit code 1 -No projects matched the filters "vendor/*" in "/mnt/code/bete" -Scope: 6 of 7 workspace projects -vendor/discord.js-selfbot-v13 test$ npm run lint && npm run test:typescript && npm run docs: - *Promoted on:* 2026-06-10T03:02:46.068Z - -- **Topic:** scope workspace projects - **Context:** Failure observation: Exit code 1 -$ pnpm -r run test -Scope: 6 of 7 workspace projects -vendor/discord.js-selfbot-v13 test$ npm run lint && npm run test:typescript && npm run docs:test -vendor/discord.js-selfbot-v13 test: > d - *Promoted on:* 2026-06-10T03:02:46.068Z - -- **Topic:** error 32603 include - **Context:** Failure observation: MCP error -32603: include must be an array of strings. - *Promoted on:* 2026-06-10T03:02:46.068Z - -- **Topic:** nodeinternalmodulescjsloader1522 throw error - **Context:** Failure observation: Exit code 1 -node:internal/modules/cjs/loader:1522 - throw err; - ^ - -Error: Cannot find module 'ioredis' -Require stack: -- /mnt/code/bete/[eval] - at Module._resolveFilename (node:internal/modules/cjs - *Promoted on:* 2026-06-10T03:02:46.068Z - -- **Topic:** biome check diagnosticlevelerror - **Context:** Failure observation: Exit code 1 -$ biome check --diagnostic-level=error src/ -src/features/messages/index.tsx:4:1 assist/source/organizeImports FIXABLE ━━━━━━━━━━━━━━━━━━━━━━� - *Promoted on:* 2026-06-10T09:16:35.182Z - -- **Topic:** eval1 matches found - **Context:** Failure observation: Exit code 1 -(eval):1: no matches found: tsconfig*.json - *Promoted on:* 2026-06-10T09:49:12.792Z diff --git a/services/backend/src/http/app.ts b/services/backend/src/http/app.ts index 353a1a9..2fef612 100644 --- a/services/backend/src/http/app.ts +++ b/services/backend/src/http/app.ts @@ -9,7 +9,7 @@ import helmet from "helmet"; import { createAnalysisRouter } from "../modules/analysis/analysis.routes.js"; import { createAuthRouter } from "../modules/auth/auth.routes.js"; import { createConfigRouter } from "../modules/config/config.routes.js"; -import { createCorrectionsRouter } from "../modules/corrections/corrections.routes.js"; +import { createDashboardRouter } from "../modules/dashboard/dashboard.routes.js"; import { createHealthRouter } from "../modules/health/health.routes.js"; import { createMascotChatRouter } from "../modules/mascot-chat/mascot-chat.routes.js"; import { createMediaRouter } from "../modules/media/media.routes.js"; @@ -64,7 +64,7 @@ export function createHttpApp(): Express { // API routes app.use("/api", createAuthRouter()); app.use("/api", createConfigRouter()); - app.use("/api", createCorrectionsRouter()); + app.use("/api", createDashboardRouter()); app.use("/api", createMessagesRouter()); app.use("/api", createAnalysisRouter()); app.use("/api", createMascotChatRouter()); diff --git a/services/backend/src/modules/corrections/corrections.repository.ts b/services/backend/src/modules/corrections/corrections.repository.ts deleted file mode 100644 index c4e2c72..0000000 --- a/services/backend/src/modules/corrections/corrections.repository.ts +++ /dev/null @@ -1,116 +0,0 @@ -import { createChildLogger } from "@bete/shared/logger"; -import { - pgCorrectedModerationsTable, - type CorrectedModeration, - type CorrectedModerationInsert, -} from "@bete/shared"; -import { and, desc, lt, eq, sql } from "drizzle-orm"; -import { getDatabase } from "../../shared/database/index.js"; -import type { CorrectionCreate, CorrectionQuery } from "./corrections.schema.js"; - -const logger = createChildLogger("corrections.repository"); - -export interface CorrectionStatsResult { - total_corrections: number; - recent_count_7d: number; - by_flag: Array<{ flag: string; count: number }>; -} - -export class CorrectionsRepository { - async getStats(): Promise { - const db = getDatabase(); - const sevenDaysAgo = Date.now() - 7 * 24 * 60 * 60 * 1000; - - // Total count - const [totalRow] = await db - .select({ count: sql`count(*)::int` }) - .from(pgCorrectedModerationsTable); - - // Recent 7 days count - const [recentRow] = await db - .select({ count: sql`count(*)::int` }) - .from(pgCorrectedModerationsTable) - .where(lt(pgCorrectedModerationsTable.created_at, sevenDaysAgo)); - - // Count by original_flag using JSON array unnest - const byFlagRows = await db.execute(sql` - SELECT flag, count(*)::int as count - FROM corrected_moderations, - json_array_elements_text(original_flags::json) AS flag - GROUP BY flag - ORDER BY count DESC - LIMIT 20 - `); - - const byFlag = (byFlagRows.rows ?? []).map( - (r: Record) => ({ - flag: String(r.flag), - count: Number(r.count), - }), - ); - - return { - total_corrections: totalRow?.count ?? 0, - recent_count_7d: recentRow?.count ?? 0, - by_flag: byFlag, - }; - } - - async list( - query: CorrectionQuery, - ): Promise<{ data: CorrectedModeration[]; nextCursor: string | null }> { - const db = getDatabase(); - const limit = query.limit ?? 20; - const conditions = []; - - if (query.cursor) { - conditions.push( - lt(pgCorrectedModerationsTable.created_at, Number(query.cursor)), - ); - } - - const where = conditions.length > 0 ? and(...conditions) : undefined; - - const rows = await db - .select() - .from(pgCorrectedModerationsTable) - .where(where) - .orderBy(desc(pgCorrectedModerationsTable.created_at)) - .limit(limit + 1); - - const data = rows.slice(0, limit); - const nextCursor = - rows.length > limit ? String(rows[limit].created_at) : null; - - return { data, nextCursor }; - } - - async create(data: CorrectionCreate): Promise { - const db = getDatabase(); - const id = `corr-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`; - - const insert: CorrectedModerationInsert = { - id, - message_id: data.message_id, - original_flags: JSON.stringify(data.original_flags), - corrected_flags: JSON.stringify(data.corrected_flags), - correction_notes: data.correction_notes ?? null, - content_snippet: data.content_snippet, - created_at: Date.now(), - }; - - const [row] = await db - .insert(pgCorrectedModerationsTable) - .values(insert) - .returning(); - - logger.info( - { id, messageId: data.message_id }, - "Correction recorded", - ); - - return row; - } -} - -export const correctionsRepository = new CorrectionsRepository(); diff --git a/services/backend/src/modules/corrections/corrections.routes.ts b/services/backend/src/modules/corrections/corrections.routes.ts deleted file mode 100644 index b31541d..0000000 --- a/services/backend/src/modules/corrections/corrections.routes.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { createChildLogger } from "@bete/shared/logger"; -import type { Request, Response, Router } from "express"; -import express from "express"; -import { asyncHandler } from "../../shared/middlewares/index.js"; -import { correctionsService } from "./corrections.service.js"; - -const logger = createChildLogger("corrections.routes"); - -/** - * Prevents concurrent duplicate correction submissions - * for the same message_id within a short window. - */ -const createInFlight = new Set(); - -export function createCorrectionsRouter(): Router { - const router = express.Router(); - - // GET /api/corrections/stats — aggregated correction statistics - router.get( - "/corrections/stats", - asyncHandler(async (_req: Request, res: Response) => { - const stats = await correctionsService.getStats(); - res.json(stats); - }), - ); - - // GET /api/corrections — paginated correction history - router.get( - "/corrections", - asyncHandler(async (req: Request, res: Response) => { - const limit = Number(req.query.limit) || 20; - const cursor = (req.query.cursor as string) || undefined; - - const result = await correctionsService.list({ limit, cursor }); - res.json(result); - }), - ); - - // POST /api/corrections — submit a new correction - router.post( - "/corrections", - asyncHandler(async (req: Request, res: Response) => { - const { message_id, original_flags, corrected_flags, correction_notes, content_snippet } = (req.body ?? {}) as { - message_id?: string; - original_flags?: string[]; - corrected_flags?: string[]; - correction_notes?: string; - content_snippet?: string; - }; - - // Validation - if (!message_id) { - res.status(400).json({ error: "VALIDATION_ERROR", message: "message_id is required" }); - return; - } - if (!Array.isArray(original_flags) || original_flags.length === 0) { - res.status(400).json({ error: "VALIDATION_ERROR", message: "original_flags must be a non-empty array" }); - return; - } - if (!Array.isArray(corrected_flags)) { - res.status(400).json({ error: "VALIDATION_ERROR", message: "corrected_flags must be an array" }); - return; - } - if (!content_snippet) { - res.status(400).json({ error: "VALIDATION_ERROR", message: "content_snippet is required" }); - return; - } - - // Idempotency guard: prevent duplicate submissions for same message_id - if (createInFlight.has(message_id)) { - res.status(409).json({ error: "CORRECTION_IN_PROGRESS", messageId: message_id }); - return; - } - - createInFlight.add(message_id); - let entry; - try { - entry = await correctionsService.create({ - message_id, - original_flags, - corrected_flags, - correction_notes, - content_snippet, - }); - } finally { - // Clean up after a delay to still prevent rapid duplicates - setTimeout(() => createInFlight.delete(message_id), 5_000); - } - - logger.info( - { messageId: message_id, id: entry.id }, - "Correction submitted", - ); - res.status(201).json(entry); - }), - ); - - return router; -} diff --git a/services/backend/src/modules/corrections/corrections.schema.ts b/services/backend/src/modules/corrections/corrections.schema.ts deleted file mode 100644 index ec463a0..0000000 --- a/services/backend/src/modules/corrections/corrections.schema.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { z } from "zod"; - -export const correctionQuerySchema = z.object({ - limit: z.coerce.number().int().positive().max(100).default(20), - cursor: z.string().optional(), -}); - -export const correctionCreateSchema = z.object({ - message_id: z.string().min(1, "message_id is required"), - original_flags: z - .array(z.string()) - .min(1, "original_flags must be non-empty"), - corrected_flags: z - .array(z.string()) - .min(0) - .refine( - (val) => val.length >= 0, - "corrected_flags must be an array of strings", - ), - correction_notes: z.string().optional(), - content_snippet: z.string().min(1, "content_snippet is required"), -}); - -export type CorrectionQuery = z.infer; -export type CorrectionCreate = z.infer; diff --git a/services/backend/src/modules/corrections/corrections.service.ts b/services/backend/src/modules/corrections/corrections.service.ts deleted file mode 100644 index 5f92177..0000000 --- a/services/backend/src/modules/corrections/corrections.service.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { createChildLogger } from "@bete/shared/logger"; -import type { CorrectedModeration } from "@bete/shared"; -import type { CorrectionCreate, CorrectionQuery } from "./corrections.schema.js"; -import { - correctionsRepository, - type CorrectionStatsResult, -} from "./corrections.repository.js"; - -const logger = createChildLogger("corrections.service"); - -export class CorrectionsService { - async getStats(): Promise { - logger.debug("Fetching correction stats"); - return correctionsRepository.getStats(); - } - - async list( - query: CorrectionQuery, - ): Promise<{ data: CorrectedModeration[]; nextCursor: string | null }> { - logger.debug({ limit: query.limit }, "Listing corrections"); - return correctionsRepository.list(query); - } - - async create(data: CorrectionCreate): Promise { - logger.debug( - { messageId: data.message_id }, - "Creating correction", - ); - return correctionsRepository.create(data); - } -} - -export const correctionsService = new CorrectionsService(); diff --git a/services/backend/src/modules/dashboard/dashboard.repository.ts b/services/backend/src/modules/dashboard/dashboard.repository.ts new file mode 100644 index 0000000..ef94079 --- /dev/null +++ b/services/backend/src/modules/dashboard/dashboard.repository.ts @@ -0,0 +1,238 @@ +import { createChildLogger } from "@bete/shared/logger"; +import { getPool } from "../../shared/database/index.js"; +import type { ListUsersQuery } from "./dashboard.service.js"; + +const logger = createChildLogger("dashboard.repository"); + +export class DashboardRepository { + async getStats() { + const pool = getPool(); + + // Total messages and breakdown by ai_status + const msgResult = await pool.query( + ` + SELECT + COUNT(*)::int AS total_messages, + COUNT(*) FILTER (WHERE ai_status = 'flagged')::int AS total_flagged, + COUNT(*) FILTER (WHERE ai_status = 'clean')::int AS total_clean, + COUNT(*) FILTER (WHERE ai_status = 'warn')::int AS total_warned, + COUNT(*) FILTER (WHERE ai_status = 'error')::int AS total_error, + COUNT(*) FILTER (WHERE ai_status = 'pending')::int AS total_pending, + COUNT(*) FILTER (WHERE ai_status = 'processing')::int AS total_processing, + COUNT(DISTINCT user_id)::int AS total_users, + COUNT(*) FILTER (WHERE created_at >= $1)::int AS today_messages, + COUNT(*) FILTER (WHERE ai_status = 'flagged' AND created_at >= $1)::int AS today_flagged, + COUNT(DISTINCT user_id) FILTER (WHERE created_at >= $2)::int AS active_users_24h + FROM messages + `, + [Date.now() - 86400000, Date.now() - 86400000], + ); + + const msgRow = msgResult.rows[0]; + + // Total voice recordings + const voiceResult = await pool.query(` + SELECT COUNT(*)::int AS count FROM voice_recordings + `); + + // Total AI user profiles + const profileResult = await pool.query(` + SELECT COUNT(*)::int AS count FROM user_profiles + `); + + // Top channels by message count + const topChannels = await pool.query(` + SELECT channel_id, COUNT(*)::int AS message_count + FROM messages + GROUP BY channel_id + ORDER BY COUNT(*) DESC + LIMIT 10 + `); + + return { + total_messages: msgRow?.total_messages ?? 0, + total_users: msgRow?.total_users ?? 0, + total_flagged: msgRow?.total_flagged ?? 0, + total_clean: msgRow?.total_clean ?? 0, + total_warned: msgRow?.total_warned ?? 0, + total_error: msgRow?.total_error ?? 0, + total_voice_recordings: voiceResult.rows[0]?.count ?? 0, + total_profiles: profileResult.rows[0]?.count ?? 0, + today_messages: msgRow?.today_messages ?? 0, + today_flagged: msgRow?.today_flagged ?? 0, + active_users_24h: msgRow?.active_users_24h ?? 0, + top_channels: topChannels.rows.map((r: Record) => ({ + channel_id: String(r.channel_id), + message_count: Number(r.message_count), + })), + moderation_overview: { + pending: msgRow?.total_pending ?? 0, + processing: msgRow?.total_processing ?? 0, + error: msgRow?.total_error ?? 0, + }, + }; + } + + async listUsers(query: ListUsersQuery) { + const pool = getPool(); + const limit = query.limit ?? 20; + const conditions: string[] = []; + const params: unknown[] = []; + let paramIdx = 1; + + if (query.search) { + conditions.push( + `(m.user_id ILIKE $${paramIdx} OR m.username ILIKE $${paramIdx})`, + ); + params.push(`%${query.search}%`); + paramIdx++; + } + + if (query.cursor) { + conditions.push(`m.last_message_at < $${paramIdx}`); + params.push(Number(query.cursor)); + paramIdx++; + } + + const whereClause = + conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : ""; + + const { rows } = await pool.query( + ` + SELECT + m.user_id, + m.username, + m.avatar_url, + p.profile_summary, + m.total_messages, + m.flagged_count, + m.last_message_at, + r.trust_score + FROM ( + SELECT + user_id, + username, + avatar_url, + COUNT(*)::int AS total_messages, + COUNT(*) FILTER (WHERE ai_status = 'flagged')::int AS flagged_count, + MAX(created_at) AS last_message_at + FROM messages + GROUP BY user_id, username, avatar_url + ) m + LEFT JOIN user_profiles p ON p.user_id = m.user_id + LEFT JOIN user_reputations r ON r.user_id = m.user_id + ${whereClause} + ORDER BY m.last_message_at DESC NULLS LAST + LIMIT $${paramIdx} + `, + [...params, limit + 1], + ); + + const data = (rows as Record[]) + .slice(0, limit) + .map((r) => ({ + user_id: String(r.user_id), + username: r.username as string | null, + avatar_url: r.avatar_url as string | null, + profile_summary: r.profile_summary as string | null, + 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; + const nextCursor = + rows.length > limit + ? String(lastRow?.last_message_at ?? lastRow?.total_messages ?? "") + : null; + + return { data, nextCursor }; + } + + async getUserDetail(userId: string) { + const pool = getPool(); + + // Basic user info + profile + reputation + const userResult = await pool.query( + ` + SELECT + m.user_id, + m.username, + m.avatar_url, + m.total_messages, + m.flagged_count, + m.clean_count, + p.profile_summary, + p.last_analyzed_at, + r.trust_score, + r.clean_message_streak, + r.total_infractions + FROM ( + SELECT + user_id, + username, + avatar_url, + COUNT(*)::int AS total_messages, + COUNT(*) FILTER (WHERE ai_status = 'flagged')::int AS flagged_count, + COUNT(*) FILTER (WHERE ai_status = 'clean')::int AS clean_count + FROM messages + WHERE user_id = $1 + GROUP BY user_id, username, avatar_url + ) m + LEFT JOIN user_profiles p ON p.user_id = m.user_id + LEFT JOIN user_reputations r ON r.user_id = m.user_id + `, + [userId], + ); + + const row = userResult.rows[0] as Record | undefined; + if (!row) { + return null; + } + + // Recent messages + const recent = await pool.query( + ` + SELECT id, content, channel_id, created_at, ai_status + FROM messages + WHERE user_id = $1 + ORDER BY created_at DESC + LIMIT 20 + `, + [userId], + ); + + return { + user_id: String(row.user_id), + username: row.username as string | null, + avatar_url: row.avatar_url as string | null, + total_messages: Number(row.total_messages), + flagged_count: Number(row.flagged_count), + clean_count: Number(row.clean_count), + profile_summary: row.profile_summary as string | null, + 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), + channel_id: String(r.channel_id), + created_at: Number(r.created_at), + ai_status: r.ai_status as string | null, + })), + }; + } +} + +export const dashboardRepository = new DashboardRepository(); diff --git a/services/backend/src/modules/dashboard/dashboard.routes.ts b/services/backend/src/modules/dashboard/dashboard.routes.ts new file mode 100644 index 0000000..80f0eac --- /dev/null +++ b/services/backend/src/modules/dashboard/dashboard.routes.ts @@ -0,0 +1,52 @@ +import { createChildLogger } from "@bete/shared/logger"; +import type { Request, Response, Router } from "express"; +import express from "express"; +import { asyncHandler } from "../../shared/middlewares/index.js"; +import { dashboardService } from "./dashboard.service.js"; + +const logger = createChildLogger("dashboard.routes"); + +export function createDashboardRouter(): Router { + const router = express.Router(); + + // GET /api/dashboard/stats — aggregated server statistics + router.get( + "/dashboard/stats", + asyncHandler(async (_req: Request, res: Response) => { + logger.debug("Fetching dashboard stats"); + const stats = await dashboardService.getStats(); + res.json(stats); + }), + ); + + // GET /api/dashboard/users — paginated user list with profiles + router.get( + "/dashboard/users", + asyncHandler(async (req: Request, res: Response) => { + const limit = Number(req.query.limit) || 20; + const cursor = + typeof req.query.cursor === "string" ? req.query.cursor : undefined; + const search = + typeof req.query.search === "string" ? req.query.search : undefined; + + const result = await dashboardService.listUsers({ + limit, + cursor, + search, + }); + res.json(result); + }), + ); + + // GET /api/dashboard/users/:userId — single user detail + router.get( + "/dashboard/users/:userId", + asyncHandler(async (req: Request, res: Response) => { + const userId = String(req.params.userId); + const detail = await dashboardService.getUserDetail(userId); + res.json(detail); + }), + ); + + return router; +} diff --git a/services/backend/src/modules/dashboard/dashboard.service.ts b/services/backend/src/modules/dashboard/dashboard.service.ts new file mode 100644 index 0000000..690a06e --- /dev/null +++ b/services/backend/src/modules/dashboard/dashboard.service.ts @@ -0,0 +1,29 @@ +import { createChildLogger } from "@bete/shared/logger"; +import { dashboardRepository } from "./dashboard.repository.js"; + +const logger = createChildLogger("dashboard.service"); + +export interface ListUsersQuery { + limit: number; + cursor?: string; + search?: string; +} + +export class DashboardService { + async getStats() { + logger.debug("Fetching dashboard stats"); + return dashboardRepository.getStats(); + } + + async listUsers(query: ListUsersQuery) { + logger.debug({ query }, "Listing dashboard users"); + return dashboardRepository.listUsers(query); + } + + async getUserDetail(userId: string) { + logger.debug({ userId }, "Fetching user detail"); + return dashboardRepository.getUserDetail(userId); + } +} + +export const dashboardService = new DashboardService(); diff --git a/services/backend/src/modules/health/health.routes.ts b/services/backend/src/modules/health/health.routes.ts index b9bf54e..e95d242 100644 --- a/services/backend/src/modules/health/health.routes.ts +++ b/services/backend/src/modules/health/health.routes.ts @@ -1,6 +1,6 @@ -import { collectDefaultMetrics, register } from "prom-client"; import type { Router } from "express"; import express from "express"; +import { collectDefaultMetrics, register } from "prom-client"; import { handleHealthCheck, handleMetrics } from "./health.controller.js"; // Initialize default Node.js runtime metrics (event loop lag, memory, GC, etc.) diff --git a/services/backend/tests/placeholder.test.ts b/services/backend/tests/placeholder.test.ts index 2b1d45b..31b6dfc 100644 --- a/services/backend/tests/placeholder.test.ts +++ b/services/backend/tests/placeholder.test.ts @@ -1,17 +1,21 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; - // ─── Shared Error Classes ──────────────────────────────────────────────────── import { AppError, - NotFoundError, - ValidationError, - UnauthorizedError, - DatabaseError, ConfigError, + DatabaseError, + NotFoundError, + UnauthorizedError, + ValidationError, } from "@bete/shared/errors"; - // ─── Shared utilities ───────────────────────────────────────────────────────── -import { delay, retryWithBackoff, encodeCursor, decodeCursor, pageResult } from "@bete/shared/utils"; +import { + decodeCursor, + delay, + encodeCursor, + pageResult, + retryWithBackoff, +} from "@bete/shared/utils"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; // ─── Backend middleware ────────────────────────────────────────────────────── import { asyncHandler, requireParam } from "../src/shared/middlewares/index.js"; @@ -187,7 +191,9 @@ describe("pagination utilities", () => { const notJson = Buffer.from("not-json").toString("base64"); expect(decodeCursor(notJson)).toBeNull(); // Valid JSON but wrong shape (missing created_at / id) - const wrongShape = Buffer.from(JSON.stringify({ foo: "bar" })).toString("base64"); + const wrongShape = Buffer.from(JSON.stringify({ foo: "bar" })).toString( + "base64", + ); expect(decodeCursor(wrongShape)).toBeNull(); }); @@ -255,7 +261,9 @@ describe("requireParam", () => { }); it("throws ValidationError for undefined", () => { - expect(() => requireParam(undefined, "query", "q")).toThrow(ValidationError); + expect(() => requireParam(undefined, "query", "q")).toThrow( + ValidationError, + ); }); it("throws ValidationError for empty string", () => { @@ -263,6 +271,8 @@ describe("requireParam", () => { }); it("throws with a descriptive message", () => { - expect(() => requireParam(null, "header", "X-Token")).toThrow("Missing header: X-Token"); + expect(() => requireParam(null, "header", "X-Token")).toThrow( + "Missing header: X-Token", + ); }); }); diff --git a/services/discord-gateway/src/modules/ai-moderation/llmModerationClient.ts b/services/discord-gateway/src/modules/ai-moderation/llmModerationClient.ts index 764e5bd..b3bf6be 100644 --- a/services/discord-gateway/src/modules/ai-moderation/llmModerationClient.ts +++ b/services/discord-gateway/src/modules/ai-moderation/llmModerationClient.ts @@ -5,6 +5,7 @@ import type { ChatCompletion } from "openai/resources/chat/completions"; import { config } from "../../shared/config/config.js"; import { resizeImageForVision } from "../attachment-upload/imageResizer.js"; import { extractMessageMediaEvidence } from "../message-capture/messageMetadata.js"; +import { getMessageById } from "../message-capture/messageStore.js"; import type { AnalysisResult, AttachmentRecord, @@ -43,9 +44,8 @@ import { upsertCachedMediaByPhash, } from "./textCacheStore.js"; import { extractUrlsFromText, fetchUrlSafely } from "./urlFetcher.js"; -import { initializeUserReputation } from "./userReputationStore.js"; import { getUserProfile } from "./userProfileStore.js"; -import { getMessageById } from "../message-capture/messageStore.js"; +import { initializeUserReputation } from "./userReputationStore.js"; export { sniffImageMimeType } from "./imageMimeSniffer.js"; export { extractJson } from "./jsonExtractor.js"; @@ -805,7 +805,10 @@ async function runTextOnlyBatch( // Log channel culture & user profiles for debugging if (channelCulture) { - log.debug({ channelId, culturePreview: channelCulture.slice(0, 120) }, "Injected channel culture into prompt"); + log.debug( + { channelId, culturePreview: channelCulture.slice(0, 120) }, + "Injected channel culture into prompt", + ); } // Run sub-batches sequentially to avoid rate limits @@ -826,7 +829,9 @@ async function runTextOnlyBatch( const profile = await getUserProfile(msg.user_id); userProfiles.set( msg.user_id, - profile ? `${profile.profile_summary}` : "", + profile + ? `${profile.profile_summary}` + : "", ); } } @@ -1150,7 +1155,10 @@ async function runMediaBatch( // Log channel culture for debugging if (channelCulture) { - log.debug({ channelId, culturePreview: channelCulture.slice(0, 120) }, "Injected channel culture into prompt (media path)"); + log.debug( + { channelId, culturePreview: channelCulture.slice(0, 120) }, + "Injected channel culture into prompt (media path)", + ); } const correctedExamples = await buildCorrectedFewShotExamples(); diff --git a/services/discord-gateway/src/modules/ai-moderation/userProfileLearner.ts b/services/discord-gateway/src/modules/ai-moderation/userProfileLearner.ts index 72221f9..12df91b 100644 --- a/services/discord-gateway/src/modules/ai-moderation/userProfileLearner.ts +++ b/services/discord-gateway/src/modules/ai-moderation/userProfileLearner.ts @@ -6,8 +6,8 @@ import { messagesTable, userProfilesTable, } from "../../shared/database/schema.js"; -import { updateUserProfile } from "./userProfileStore.js"; import { llmChat } from "./llmClient.js"; +import { updateUserProfile } from "./userProfileStore.js"; const PROFILE_LEARNING_INTERVAL = 1000 * 60 * 60 * 12; // 12 hours const log = createChildLogger("userProfileLearner"); diff --git a/services/discord-gateway/src/modules/ai-moderation/userProfileStore.ts b/services/discord-gateway/src/modules/ai-moderation/userProfileStore.ts index dd68f24..edefe20 100644 --- a/services/discord-gateway/src/modules/ai-moderation/userProfileStore.ts +++ b/services/discord-gateway/src/modules/ai-moderation/userProfileStore.ts @@ -55,8 +55,5 @@ export async function updateUserProfile( }, }); - logger.debug( - { userId, guildId, profileSummary }, - "User profile updated", - ); + logger.debug({ userId, guildId, profileSummary }, "User profile updated"); } diff --git a/services/discord-gateway/src/modules/message-capture/messageMetadata.ts b/services/discord-gateway/src/modules/message-capture/messageMetadata.ts index a03293e..aa157ec 100644 --- a/services/discord-gateway/src/modules/message-capture/messageMetadata.ts +++ b/services/discord-gateway/src/modules/message-capture/messageMetadata.ts @@ -237,7 +237,8 @@ export function getMessageMetadata(message: Message): RichMessageMetadata { messageId: message.reference.messageId ?? null, channelId: message.reference.channelId ?? null, guildId: message.reference.guildId ?? null, - type: (message.reference.type as unknown as string | undefined) ?? null, + type: + (message.reference.type as unknown as string | undefined) ?? null, } : null, isCrosspost: message.flags?.has(1 << 1) ?? false, diff --git a/services/discord-gateway/src/shared/database/migrate.ts b/services/discord-gateway/src/shared/database/migrate.ts index 0193f5f..a264151 100644 --- a/services/discord-gateway/src/shared/database/migrate.ts +++ b/services/discord-gateway/src/shared/database/migrate.ts @@ -170,9 +170,20 @@ export async function runMigrations(): Promise { const originalQuery = client.query; client.query = (async (...args: any[]) => { const queryText = args[0]; - const text = typeof queryText === "string" ? queryText : queryText?.text; - if (text && typeof text === "string" && text.includes('CREATE SCHEMA IF NOT EXISTS "public"')) { - return { rows: [], command: "CREATE", rowCount: 0, oid: 0, fields: [] }; + const text = + typeof queryText === "string" ? queryText : queryText?.text; + if ( + text && + typeof text === "string" && + text.includes('CREATE SCHEMA IF NOT EXISTS "public"') + ) { + return { + rows: [], + command: "CREATE", + rowCount: 0, + oid: 0, + fields: [], + }; } return Function.prototype.apply.call(originalQuery, client, args); }) as typeof client.query; diff --git a/services/discord-gateway/tests/placeholder.test.ts b/services/discord-gateway/tests/placeholder.test.ts index 804f479..4e1b1e2 100644 --- a/services/discord-gateway/tests/placeholder.test.ts +++ b/services/discord-gateway/tests/placeholder.test.ts @@ -1,16 +1,15 @@ -import { describe, it, expect, vi, afterEach } from "vitest"; - // ═══════════════════════════════════════════════════════════════════════════════ // 1. AppError Hierarchy // ═══════════════════════════════════════════════════════════════════════════════ import { AppError, - NotFoundError, - ValidationError, - UnauthorizedError, - DatabaseError, ConfigError, + DatabaseError, + NotFoundError, + UnauthorizedError, + ValidationError, } from "@bete/shared/errors"; +import { afterEach, describe, expect, it, vi } from "vitest"; describe("AppError subclasses", () => { it("AppError carries code, statusCode, and details", () => { @@ -47,10 +46,18 @@ describe("AppError subclasses", () => { // ═══════════════════════════════════════════════════════════════════════════════ // 2. Shared Utilities // ═══════════════════════════════════════════════════════════════════════════════ -import { delay, retryWithBackoff, encodeCursor, decodeCursor, pageResult } from "@bete/shared/utils"; +import { + decodeCursor, + delay, + encodeCursor, + pageResult, + retryWithBackoff, +} from "@bete/shared/utils"; describe("delay", () => { - afterEach(() => { vi.useRealTimers(); }); + afterEach(() => { + vi.useRealTimers(); + }); it("resolves after specified time with fake timers", async () => { vi.useFakeTimers(); @@ -61,7 +68,9 @@ describe("delay", () => { }); describe("retryWithBackoff", () => { - afterEach(() => { vi.useRealTimers(); }); + afterEach(() => { + vi.useRealTimers(); + }); it("resolves on first attempt", async () => { const fn = vi.fn().mockResolvedValue(42); @@ -103,7 +112,10 @@ describe("pagination utils", () => { expect(r1.nextCursor).toBeNull(); const r2 = pageResult( - [{ id: "a", created_at: 1 }, { id: "b", created_at: 2 }], + [ + { id: "a", created_at: 1 }, + { id: "b", created_at: 2 }, + ], 1, ); expect(r2.data).toHaveLength(1); @@ -115,25 +127,25 @@ describe("pagination utils", () => { // 3. Redis Channel Constants // ═══════════════════════════════════════════════════════════════════════════════ import { - DISCORD_MESSAGE_CREATED, - DISCORD_MESSAGE_UPDATED, - DISCORD_MESSAGE_DELETED, - DISCORD_MESSAGE_ANALYZED, - DISCORD_ATTACHMENT_CREATED, - DISCORD_VOICE_STARTED, - DISCORD_VOICE_PCM, - DISCORD_ANALYSIS_QUEUE_STATUS, BACKEND_COMMAND, - VOICE_STATUS_KEY, - MEDIA_STATUS_KEY, - COMMAND_VOICE_CONNECT, - COMMAND_VOICE_DISCONNECT, COMMAND_GUILDS_LIST, COMMAND_MEDIA_QUEUE, COMMAND_MEDIA_SKIP, COMMAND_MEDIA_STOP, COMMAND_MEDIA_VOLUME, COMMAND_MODERATION_ACTION, + COMMAND_VOICE_CONNECT, + COMMAND_VOICE_DISCONNECT, + DISCORD_ANALYSIS_QUEUE_STATUS, + DISCORD_ATTACHMENT_CREATED, + DISCORD_MESSAGE_ANALYZED, + DISCORD_MESSAGE_CREATED, + DISCORD_MESSAGE_DELETED, + DISCORD_MESSAGE_UPDATED, + DISCORD_VOICE_PCM, + DISCORD_VOICE_STARTED, + MEDIA_STATUS_KEY, + VOICE_STATUS_KEY, } from "@bete/shared/redis-channels"; describe("Redis channel constants", () => { @@ -174,7 +186,7 @@ vi.hoisted(() => { process.env.DATABASE_URL = "postgres://test:test@localhost:5432/test"; }); -import { loadConfig, configSchema } from "@bete/shared/config"; +import { configSchema, loadConfig } from "@bete/shared/config"; describe("Config validation", () => { it("loadConfig succeeds with minimal valid env", () => { @@ -217,7 +229,9 @@ describe("Config validation", () => { }); it("gateway loadConfig adds EFFECTIVE_TEXT_GUILD_ID from MONITOR_GUILD_ID", async () => { - const { loadConfig: gwLoadConfig } = await import("../src/shared/config/config.js"); + const { loadConfig: gwLoadConfig } = await import( + "../src/shared/config/config.js" + ); const cfg = gwLoadConfig({ DISCORD_TOKEN: "tok", DATABASE_URL: "pg://localhost/db", @@ -227,7 +241,9 @@ describe("Config validation", () => { }); it("gateway loadConfig prefers TEXT_GUILD_ID over MONITOR_GUILD_ID", async () => { - const { loadConfig: gwLoadConfig } = await import("../src/shared/config/config.js"); + const { loadConfig: gwLoadConfig } = await import( + "../src/shared/config/config.js" + ); const cfg = gwLoadConfig({ DISCORD_TOKEN: "tok", DATABASE_URL: "pg://localhost/db", @@ -266,21 +282,27 @@ describe("sniffImageMimeType", () => { it("detects WebP", () => { expect( - sniffImageMimeType(buf(0x52, 0x49, 0x46, 0x46, 0, 0, 0, 0, 0x57, 0x45, 0x42, 0x50)), + sniffImageMimeType( + buf(0x52, 0x49, 0x46, 0x46, 0, 0, 0, 0, 0x57, 0x45, 0x42, 0x50), + ), ).toBe("image/webp"); }); it("detects AVIF", () => { // ftyp box with avif brand at bytes 8-11 expect( - sniffImageMimeType(buf(0, 0, 0, 0, 0x66, 0x74, 0x79, 0x70, 0x61, 0x76, 0x69, 0x66)), + sniffImageMimeType( + buf(0, 0, 0, 0, 0x66, 0x74, 0x79, 0x70, 0x61, 0x76, 0x69, 0x66), + ), ).toBe("image/avif"); }); it("detects HEIC", () => { // ftyp box with heic brand at bytes 8-11 expect( - sniffImageMimeType(buf(0, 0, 0, 0, 0x66, 0x74, 0x79, 0x70, 0x68, 0x65, 0x69, 0x63)), + sniffImageMimeType( + buf(0, 0, 0, 0, 0x66, 0x74, 0x79, 0x70, 0x68, 0x65, 0x69, 0x63), + ), ).toBe("image/heic"); }); @@ -295,8 +317,8 @@ describe("sniffImageMimeType", () => { import { clampScore, - deriveSeverity, deriveRecommendedAction, + deriveSeverity, hasDeferralAnalysis, } from "../src/modules/ai-moderation/severityDeriver.js"; @@ -364,7 +386,9 @@ describe("severityDeriver", () => { }); it("detects English deferral: insufficient context", () => { - expect(hasDeferralAnalysis("insufficient context to moderate")).toBe(true); + expect(hasDeferralAnalysis("insufficient context to moderate")).toBe( + true, + ); }); it("detects cannot determine pattern", () => { @@ -372,12 +396,16 @@ describe("severityDeriver", () => { }); it("returns false for non-deferral text", () => { - expect(hasDeferralAnalysis("This message is perfectly clean")).toBe(false); + expect(hasDeferralAnalysis("This message is perfectly clean")).toBe( + false, + ); }); it("returns false for exception pattern (decisive verdict)", () => { expect( - hasDeferralAnalysis("tidak bisa menentukan karena tidak ada pelanggaran"), + hasDeferralAnalysis( + "tidak bisa menentukan karena tidak ada pelanggaran", + ), ).toBe(false); }); }); diff --git a/services/frontend/src/App.tsx b/services/frontend/src/App.tsx index c39e94b..264372b 100644 --- a/services/frontend/src/App.tsx +++ b/services/frontend/src/App.tsx @@ -1,10 +1,10 @@ import { useEffect, useMemo, useState } from "react"; import { AuthOverlay } from "./features/auth"; +import { DashboardPanel } from "./features/dashboard"; import { LivePanel } from "./features/live"; import { useMediaControl } from "./features/live/hooks/useMediaControl"; import { useVoiceControl } from "./features/live/hooks/useVoiceControl"; import { MessagesPanel } from "./features/messages"; -import { TunerPanel } from "./features/tuner"; import { ModerationAlertListener } from "./features/messages/components/ModerationAlertListener"; import { mergeMessages, @@ -183,11 +183,11 @@ export default function App() { onVolumeChange={media.setVolume} /> ) - ) : activeTab === "tuner" ? ( + ) : activeTab === "dashboard" ? ( !isAuthenticated ? ( setIsAuthenticated(true)} /> ) : ( - + ) ) : ( ; + } + + if (error) { + return ( +
+ +

{error}

+ +
+ ); + } + + if (!stats) { + return ( +
+ +

No data available yet.

+
+ ); + } + + const cards = [ + { + title: "Total Messages", + value: stats.total_messages.toLocaleString(), + icon: MessageSquare, + color: "text-primary", + bg: "bg-primary/10", + }, + { + title: "Today's Messages", + value: stats.today_messages.toLocaleString(), + icon: MessageSquare, + color: "text-emerald-500", + bg: "bg-emerald-100", + }, + { + title: "Total Users", + value: stats.total_users.toLocaleString(), + icon: Users, + color: "text-blue-500", + bg: "bg-blue-100", + }, + { + title: "Active Users (24h)", + value: stats.active_users_24h.toLocaleString(), + icon: UserCheck, + color: "text-violet-500", + bg: "bg-violet-100", + }, + { + title: "Flagged", + value: stats.total_flagged.toLocaleString(), + icon: ShieldAlert, + color: "text-destructive", + bg: "bg-destructive/10", + }, + { + title: "Clean", + value: stats.total_clean.toLocaleString(), + icon: ShieldAlert, + color: "text-emerald-600", + bg: "bg-emerald-100", + }, + { + title: "Voice Recordings", + value: stats.total_voice_recordings.toLocaleString(), + icon: Mic, + color: "text-cyan-500", + bg: "bg-cyan-100", + }, + { + title: "AI Profiles", + value: stats.total_profiles.toLocaleString(), + icon: Users, + color: "text-amber-500", + bg: "bg-amber-100", + }, + ]; + + return ( + + {/* Summary cards grid */} + + {cards.map((card) => ( + + +
+
+

+ {card.title} +

+

+ {card.value} +

+
+
+ +
+
+
+
+ ))} +
+ + {/* Top channels */} + + + + Top Channels + + + {stats.top_channels.length === 0 ? ( +

+ No channel data yet. +

+ ) : ( +
+ {stats.top_channels.map((ch, i) => ( +
+ + #{ch.channel_id} + + + {ch.message_count.toLocaleString()} + +
+ ))} +
+ )} +
+
+
+ + {/* Moderation overview */} + + + + Moderation Queue + + +
+
+

+ {stats.moderation_overview.pending} +

+

Pending

+
+
+

+ {stats.moderation_overview.processing} +

+

Processing

+
+
+

+ {stats.moderation_overview.error} +

+

Errors

+
+
+
+
+
+
+ ); +} + +function StatsSkeleton() { + return ( +
+
+ {Array.from({ length: 8 }).map((_, i) => ( + + +
+ + +
+
+
+ ))} +
+
+ ); +} diff --git a/services/frontend/src/features/dashboard/components/UserProfileDetail.tsx b/services/frontend/src/features/dashboard/components/UserProfileDetail.tsx new file mode 100644 index 0000000..af1e9d5 --- /dev/null +++ b/services/frontend/src/features/dashboard/components/UserProfileDetail.tsx @@ -0,0 +1,277 @@ +import { motion } from "framer-motion"; +import { AlertCircle, ArrowLeft, RefreshCw, User } from "lucide-react"; +import type { DashboardUserDetail } from "../../../shared/api/client"; +import { + cardItem, + cardStagger, + fadeSlideUp, +} from "../../../shared/hooks/useFramerStagger"; +import { cn } from "../../../shared/lib/utils"; +import { + Badge, + Card, + CardContent, + CardHeader, + CardTitle, + Skeleton, +} from "../../../shared/ui"; + +interface UserProfileDetailProps { + detail: DashboardUserDetail | null; + loading: boolean; + error: string | null; + onBack: () => void; + onRefetch: () => void; +} + +export function UserProfileDetail({ + detail, + loading, + error, + onBack, + onRefetch, +}: UserProfileDetailProps) { + return ( + + {/* Back button */} + + + + + {/* Loading */} + {loading && } + + {/* Error */} + {error && ( + + +

{error}

+ +
+ )} + + {detail && !error && ( + <> + {/* Profile header */} + + + +
+ {/* Avatar */} +
+ {detail.avatar_url ? ( + {detail.username + ) : ( +
+ +
+ )} +
+ + {/* Info */} +
+

+ {detail.username ?? detail.user_id} +

+

+ {detail.user_id} +

+ +
+ {detail.trust_score !== null && ( + = 80 + ? "success" + : detail.trust_score >= 50 + ? "warning" + : "destructive" + } + > + Trust: {detail.trust_score} + + )} + {detail.total_infractions !== null && + detail.total_infractions > 0 && ( + + {detail.total_infractions} infractions + + )} + {detail.clean_message_streak !== null && + detail.clean_message_streak > 0 && ( + + Streak: {detail.clean_message_streak} + + )} +
+
+
+ + {/* Profile summary */} + {detail.profile_summary && ( +
+

+ AI Profile Summary +

+

+ {detail.profile_summary} +

+ {detail.last_analyzed_at && ( +

+ Last analyzed:{" "} + {new Date(detail.last_analyzed_at).toLocaleString()} +

+ )} +
+ )} +
+
+
+ + {/* Stats grid */} + + + +

+ {detail.total_messages.toLocaleString()} +

+

+ Total Messages +

+
+
+ + +

+ {detail.clean_count.toLocaleString()} +

+

Clean

+
+
+ + +

0 + ? "text-destructive" + : "text-muted-foreground", + )} + > + {detail.flagged_count.toLocaleString()} +

+

Flagged

+
+
+
+ + {/* Recent messages */} + + + + Recent Messages + + + {detail.recent_messages.length === 0 ? ( +

+ No messages found. +

+ ) : ( +
+ {detail.recent_messages.map((msg) => ( +
+
+

+ {msg.content} +

+ {msg.ai_status && ( + + {msg.ai_status} + + )} +
+

+ #{msg.channel_id} ·{" "} + {new Date(msg.created_at).toLocaleString()} +

+
+ ))} +
+ )} +
+
+
+ + )} +
+ ); +} + +function DetailSkeleton() { + return ( +
+ + +
+ +
+ + +
+ + +
+
+
+ +
+
+
+ {Array.from({ length: 3 }).map((_, i) => ( + + + + + + + ))} +
+
+ ); +} diff --git a/services/frontend/src/features/dashboard/components/UserSummaryList.tsx b/services/frontend/src/features/dashboard/components/UserSummaryList.tsx new file mode 100644 index 0000000..e6abc4f --- /dev/null +++ b/services/frontend/src/features/dashboard/components/UserSummaryList.tsx @@ -0,0 +1,197 @@ +import { motion } from "framer-motion"; +import { AlertCircle, Loader2, RefreshCw, Search, User } from "lucide-react"; +import type { DashboardUser } from "../../../shared/api/client"; +import { cardItem, cardStagger } from "../../../shared/hooks/useFramerStagger"; +import { cn } from "../../../shared/lib/utils"; +import { + Card, + CardContent, + CardHeader, + CardTitle, + Input, + Skeleton, +} from "../../../shared/ui"; + +interface UserSummaryListProps { + users: DashboardUser[]; + loading: boolean; + error: string | null; + search: string; + onSearchChange: (value: string) => void; + onLoadMore: () => void; + hasMore: boolean; + onRefetch: () => void; + onSelectUser: (userId: string) => void; +} + +export function UserSummaryList({ + users, + loading, + error, + search, + onSearchChange, + onLoadMore, + hasMore, + onRefetch, + onSelectUser, +}: UserSummaryListProps) { + return ( + + {/* Search bar */} + +
+ + onSearchChange(e.target.value)} + /> +
+
+ + {/* Error state */} + {error && ( + + +

{error}

+ +
+ )} + + {/* Loading state */} + {loading && users.length === 0 && !error && } + + {/* Empty state */} + {!loading && !error && users.length === 0 && ( + + +

No users found.

+
+ )} + + {/* User cards */} + {users.length > 0 && ( + + {users.map((u) => ( + + ))} + + )} + + {/* Load more */} + {hasMore && ( + + + + )} +
+ ); +} + +function UserListSkeleton() { + return ( +
+ {Array.from({ length: 6 }).map((_, i) => ( + + +
+ +
+ + + +
+
+
+
+ ))} +
+ ); +} diff --git a/services/frontend/src/features/dashboard/hooks/useDashboard.ts b/services/frontend/src/features/dashboard/hooks/useDashboard.ts new file mode 100644 index 0000000..8259dd1 --- /dev/null +++ b/services/frontend/src/features/dashboard/hooks/useDashboard.ts @@ -0,0 +1,135 @@ +import { useCallback, useEffect, useState } from "react"; +import { + type DashboardStats, + type DashboardUser, + type DashboardUserDetail, + getDashboardStats, + getDashboardUserDetail, + listDashboardUsers, +} from "../../../shared/api/client"; + +const logger = console; + +/** + * Fetch dashboard aggregate stats. + */ +export function useDashboardStats() { + const [stats, setStats] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const fetch = useCallback(async () => { + setLoading(true); + setError(null); + try { + const data = await getDashboardStats(); + setStats(data); + } catch (e) { + const msg = e instanceof Error ? e.message : "Failed to load stats"; + setError(msg); + logger.error("[useDashboardStats]", msg); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + fetch().catch(() => undefined); + }, [fetch]); + + return { stats, loading, error, refetch: fetch }; +} + +/** + * Fetch paginated user list with optional search. + */ +export function useDashboardUsers() { + const [users, setUsers] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [nextCursor, setNextCursor] = useState(null); + const [search, setSearch] = useState(""); + + const fetchUsers = useCallback( + async (cursor?: string) => { + setLoading(true); + setError(null); + try { + const result = await listDashboardUsers({ + limit: 20, + cursor, + search: search || undefined, + }); + if (cursor) { + setUsers((prev) => [...prev, ...result.data]); + } else { + setUsers(result.data); + } + setNextCursor(result.nextCursor); + } catch (e) { + const msg = e instanceof Error ? e.message : "Failed to load users"; + setError(msg); + logger.error("[useDashboardUsers]", msg); + } finally { + setLoading(false); + } + }, + [search], + ); + + useEffect(() => { + fetchUsers().catch(() => undefined); + }, [fetchUsers]); + + const loadMore = useCallback(() => { + if (nextCursor && !loading) { + fetchUsers(nextCursor).catch(() => undefined); + } + }, [nextCursor, loading, fetchUsers]); + + return { + users, + loading, + error, + search, + setSearch, + loadMore, + hasMore: !!nextCursor, + refetch: () => fetchUsers().catch(() => undefined), + }; +} + +/** + * Fetch a single user detail by userId. + */ +export function useDashboardUserDetail(userId: string | null) { + const [detail, setDetail] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const fetch = useCallback(async () => { + if (!userId) return; + setLoading(true); + setError(null); + try { + const data = await getDashboardUserDetail(userId); + if (!data) { + setError("User not found"); + return; + } + setDetail(data); + } catch (e) { + const msg = e instanceof Error ? e.message : "Failed to load user detail"; + setError(msg); + logger.error("[useDashboardUserDetail]", msg); + } finally { + setLoading(false); + } + }, [userId]); + + useEffect(() => { + fetch().catch(() => undefined); + }, [fetch]); + + return { detail, loading, error, refetch: fetch }; +} diff --git a/services/frontend/src/features/dashboard/index.tsx b/services/frontend/src/features/dashboard/index.tsx new file mode 100644 index 0000000..dadfb4d --- /dev/null +++ b/services/frontend/src/features/dashboard/index.tsx @@ -0,0 +1,72 @@ +import { useState } from "react"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "../../shared/ui"; +import { DashboardStatsContent } from "./components/DashboardStats"; +import { UserProfileDetail } from "./components/UserProfileDetail"; +import { UserSummaryList } from "./components/UserSummaryList"; +import { + useDashboardUserDetail, + useDashboardUsers, +} from "./hooks/useDashboard"; + +export function DashboardPanel() { + const [activeTab, setActiveTab] = useState("stats"); + const [selectedUserId, setSelectedUserId] = useState(null); + const { + users, + loading: usersLoading, + error: usersError, + search, + setSearch, + loadMore, + hasMore, + refetch: refetchUsers, + } = useDashboardUsers(); + const { + detail, + loading: detailLoading, + error: detailError, + refetch: refetchDetail, + } = useDashboardUserDetail(selectedUserId); + + // Show user detail view + if (selectedUserId) { + return ( + { + setSelectedUserId(null); + }} + onRefetch={refetchDetail} + /> + ); + } + + return ( + + + Stats + Users + + + + + + + + + + + ); +} diff --git a/services/frontend/src/features/tuner/components/CorrectionHistory.tsx b/services/frontend/src/features/tuner/components/CorrectionHistory.tsx deleted file mode 100644 index 1452fb0..0000000 --- a/services/frontend/src/features/tuner/components/CorrectionHistory.tsx +++ /dev/null @@ -1,192 +0,0 @@ -import { motion } from "framer-motion"; -import { AlertCircle, ChevronDown, RefreshCw } from "lucide-react"; -import { useCorrectionHistory } from "../hooks/useCorrections"; -import { - Badge, - Button, - Card, - CardContent, - CardHeader, - CardTitle, - ScrollArea, - Skeleton, -} from "../../../shared/ui"; -import { formatDate } from "../../../shared/lib/utils"; -import { EmptyStateMascot } from "../../../shared/ui"; -import { cardStagger, cardItem } from "../../../shared/hooks/useFramerStagger"; - -function parseFlags(flags: string): string[] { - try { - return JSON.parse(flags) as string[]; - } catch { - return []; - } -} - -function CorrectionRow({ - entry, - index, -}: { - entry: { - id: string; - created_at: number; - original_flags: string; - corrected_flags: string; - content_snippet: string; - correction_notes: string | null; - }; - index: number; -}) { - const originalFlags = parseFlags(entry.original_flags); - const correctedFlags = parseFlags(entry.corrected_flags); - const isCleared = correctedFlags.length === 0; - - return ( - - - {formatDate(entry.created_at)} - - -
- {originalFlags.map((f) => ( - - {f.replace(/_/g, " ")} - - ))} -
- - - {isCleared ? ( - - Cleared - - ) : ( -
- {correctedFlags.map((f) => ( - - {f.replace(/_/g, " ")} - - ))} -
- )} - - - {entry.content_snippet} - - - {entry.correction_notes || "—"} - -
- ); -} - -export function CorrectionHistoryContent() { - const { entries, loading, loadingMore, error, hasMore, loadMore, refetch } = - useCorrectionHistory(); - - if (loading) { - return ( -
- - - - - -
- ); - } - - if (error) { - return ( - - - -

{error}

- -
-
- ); - } - - if (entries.length === 0) { - return ( - - - -

- No corrections submitted yet. Use the Submit tab to record your - first correction. -

-
-
- ); - } - - return ( - - - - Correction History - - - - - - - - - - - - - - - - {entries.map((entry, i) => ( - - ))} - -
DateOriginalCorrectedContentNotes
-
- - {hasMore && ( -
- -
- )} -
-
- ); -} diff --git a/services/frontend/src/features/tuner/components/CorrectionStats.tsx b/services/frontend/src/features/tuner/components/CorrectionStats.tsx deleted file mode 100644 index 24f69a9..0000000 --- a/services/frontend/src/features/tuner/components/CorrectionStats.tsx +++ /dev/null @@ -1,166 +0,0 @@ -import { motion } from "framer-motion"; -import { AlertCircle, RefreshCw } from "lucide-react"; -import { useCorrectionStats } from "../hooks/useCorrections"; -import { - Card, - CardContent, - CardHeader, - CardTitle, - Skeleton, -} from "../../../shared/ui"; -import { EmptyStateMascot } from "../../../shared/ui"; - -function FlagsBar({ - flag, - count, - max, -}: { - flag: string; - count: number; - max: number; -}) { - const pct = max > 0 ? (count / max) * 100 : 0; - return ( -
- - {flag.replace(/_/g, " ")} - -
-
- -
-
- - {count} - -
- ); -} - -export function CorrectionStatsContent() { - const { stats, loading, error, refetch } = useCorrectionStats(); - - if (loading) { - return ( -
- - - -
- ); - } - - if (error) { - return ( - - - -

{error}

- -
-
- ); - } - - if (!stats || stats.total_corrections === 0) { - return ( - - - -

- No corrections yet. When admins correct false positives, statistics - will appear here. -

-
-
- ); - } - - return ( - - {/* Summary cards */} -
- - - - - Total Corrections - - - -

- {stats.total_corrections} -

-
-
-
- - - - - - Last 7 Days - - - -

- {stats.recent_count_7d} -

-
-
-
-
- - {/* Flags bar chart */} - {stats.by_flag.length > 0 && ( - - - - Most Corrected Flags - - - - {stats.by_flag.map((item) => ( - - ))} - - - )} -
- ); -} diff --git a/services/frontend/src/features/tuner/components/SubmitCorrection.tsx b/services/frontend/src/features/tuner/components/SubmitCorrection.tsx deleted file mode 100644 index a25c373..0000000 --- a/services/frontend/src/features/tuner/components/SubmitCorrection.tsx +++ /dev/null @@ -1,229 +0,0 @@ -import { useState } from "react"; -import { motion } from "framer-motion"; -import { AlertCircle, CheckCircle, Send, X } from "lucide-react"; -import { useSubmitCorrection } from "../hooks/useCorrections"; -import { - Badge, - Button, - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, - Input, -} from "../../../shared/ui"; -import { useToast } from "../../../shared/ui"; - -export function SubmitCorrectionContent() { - const { submit, submitting, error, success, reset } = useSubmitCorrection(); - const { addToast } = useToast(); - - const [messageId, setMessageId] = useState(""); - const [contentSnippet, setContentSnippet] = useState(""); - const [correctionNotes, setCorrectionNotes] = useState(""); - - // Pre-selected flags that were wrong - const [originalFlags, setOriginalFlags] = useState([]); - const [flagInput, setFlagInput] = useState(""); - - const [formError, setFormError] = useState(null); - - const addFlag = () => { - const trimmed = flagInput.trim().toLowerCase(); - if (!trimmed) return; - if (originalFlags.includes(trimmed)) return; - setOriginalFlags((prev) => [...prev, trimmed]); - setFlagInput(""); - }; - - const removeFlag = (flag: string) => { - setOriginalFlags((prev) => prev.filter((f) => f !== flag)); - }; - - const handleSubmit = async () => { - setFormError(null); - reset(); - - // Client-side validation - if (!messageId.trim()) { - setFormError("Message ID is required"); - return; - } - if (originalFlags.length === 0) { - setFormError("Add at least one original flag that was incorrect"); - return; - } - if (!contentSnippet.trim()) { - setFormError("Content snippet is required"); - return; - } - - try { - await submit({ - message_id: messageId.trim(), - original_flags: originalFlags, - corrected_flags: [], // Always clearing the false positive flags - correction_notes: correctionNotes.trim() || undefined, - content_snippet: contentSnippet.trim(), - }); - - addToast( - "Correction submitted — the AI prompt will learn from this.", - "success", - ); - - // Reset form - setMessageId(""); - setContentSnippet(""); - setCorrectionNotes(""); - setOriginalFlags([]); - } catch { - addToast(error || "Failed to submit correction", "error"); - } - }; - - return ( - - - - - Submit Correction - - - Record a false positive — a message that was incorrectly flagged by - AI moderation. This helps the system learn and improve accuracy. - - - - {/* Message ID */} -
- - setMessageId(e.target.value)} - /> -
- - {/* Content Snippet */} -
- - setContentSnippet(e.target.value)} - /> -
- - {/* Original Flags (the incorrect ones) */} -
- -

- Add the AI flags that were wrong for this message. -

-
- setFlagInput(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Enter") { - e.preventDefault(); - addFlag(); - } - }} - className="flex-1" - /> - -
- {originalFlags.length > 0 && ( -
- {originalFlags.map((f) => ( - - {f.replace(/_/g, " ")} - - - ))} -
- )} -
- - {/* Correction Notes */} -
- - setCorrectionNotes(e.target.value)} - /> -
- - {/* Error message */} - {(formError || error) && ( -
- - {formError || error} -
- )} - - {/* Success message */} - {success && ( -
- - Correction recorded successfully. -
- )} - - {/* Submit button */} - -
-
-
- ); -} diff --git a/services/frontend/src/features/tuner/hooks/useCorrections.ts b/services/frontend/src/features/tuner/hooks/useCorrections.ts deleted file mode 100644 index 2ce68a0..0000000 --- a/services/frontend/src/features/tuner/hooks/useCorrections.ts +++ /dev/null @@ -1,137 +0,0 @@ -import { useCallback, useEffect, useRef, useState } from "react"; -import { - type CorrectionEntry, - type CorrectionStats, - getCorrectionStats, - listCorrections, - submitCorrection, -} from "../../../shared/api/client"; - -// ─── Stats ────────────────────────────────────────────────────────────────── - -export function useCorrectionStats() { - const [stats, setStats] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - - const fetch = useCallback(async () => { - setLoading(true); - setError(null); - try { - const result = await getCorrectionStats(); - setStats(result); - } catch (err) { - setError(err instanceof Error ? err.message : "Failed to load stats"); - } finally { - setLoading(false); - } - }, []); - - useEffect(() => { - fetch().catch(() => undefined); - }, [fetch]); - - return { stats, loading, error, refetch: fetch }; -} - -// ─── History ──────────────────────────────────────────────────────────────── - -export function useCorrectionHistory() { - const [entries, setEntries] = useState([]); - const [loading, setLoading] = useState(true); - const [loadingMore, setLoadingMore] = useState(false); - const [error, setError] = useState(null); - const cursorRef = useRef(null); - const hasMoreRef = useRef(true); - - const fetchInitial = useCallback(async () => { - setLoading(true); - setError(null); - try { - const result = await listCorrections({ limit: 20 }); - setEntries(result.data); - cursorRef.current = result.nextCursor; - hasMoreRef.current = result.nextCursor !== null; - } catch (err) { - setError( - err instanceof Error ? err.message : "Failed to load corrections", - ); - } finally { - setLoading(false); - } - }, []); - - const loadMore = useCallback(async () => { - if (!cursorRef.current || loadingMore) return; - setLoadingMore(true); - try { - const result = await listCorrections({ - limit: 20, - cursor: cursorRef.current, - }); - setEntries((prev) => [...prev, ...result.data]); - cursorRef.current = result.nextCursor; - hasMoreRef.current = result.nextCursor !== null; - } catch (err) { - setError(err instanceof Error ? err.message : "Failed to load more"); - } finally { - setLoadingMore(false); - } - }, [loadingMore]); - - useEffect(() => { - fetchInitial().catch(() => undefined); - }, [fetchInitial]); - - return { - entries, - loading, - loadingMore, - error, - hasMore: hasMoreRef.current, - loadMore, - refetch: fetchInitial, - }; -} - -// ─── Submit ───────────────────────────────────────────────────────────────── - -export function useSubmitCorrection() { - const [submitting, setSubmitting] = useState(false); - const [error, setError] = useState(null); - const [success, setSuccess] = useState(null); - - const submit = useCallback( - async (data: { - message_id: string; - original_flags: string[]; - corrected_flags: string[]; - correction_notes?: string; - content_snippet: string; - }) => { - setSubmitting(true); - setError(null); - setSuccess(null); - try { - const result = await submitCorrection(data); - setSuccess(result); - return result; - } catch (err) { - const msg = - err instanceof Error ? err.message : "Failed to submit correction"; - setError(msg); - throw err; - } finally { - setSubmitting(false); - } - }, - [], - ); - - const reset = useCallback(() => { - setError(null); - setSuccess(null); - }, []); - - return { submit, submitting, error, success, reset }; -} diff --git a/services/frontend/src/features/tuner/index.tsx b/services/frontend/src/features/tuner/index.tsx deleted file mode 100644 index e276db4..0000000 --- a/services/frontend/src/features/tuner/index.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import { Tabs, TabsContent, TabsList, TabsTrigger } from "../../shared/ui"; -import { CorrectionStatsContent } from "./components/CorrectionStats"; -import { CorrectionHistoryContent } from "./components/CorrectionHistory"; -import { SubmitCorrectionContent } from "./components/SubmitCorrection"; - -export function TunerPanel() { - return ( - - - Stats - History - Submit - - - - - - - - - - - - - - - ); -} diff --git a/services/frontend/src/shared/api/client.ts b/services/frontend/src/shared/api/client.ts index 6bcd737..8812a3c 100644 --- a/services/frontend/src/shared/api/client.ts +++ b/services/frontend/src/shared/api/client.ts @@ -118,7 +118,7 @@ export interface UIState { selectedTextChannel?: string; selectedAnalyticsGuild?: string; selectedAnalyticsChannel?: string; - activeTab?: "live" | "messages" | "tuner"; + activeTab?: "live" | "messages" | "dashboard"; isListening?: boolean; isStreaming?: boolean; } @@ -131,7 +131,7 @@ export interface ChatResponse { response?: string; } -export type DashboardTab = "live" | "messages" | "tuner"; +export type DashboardTab = "live" | "messages" | "dashboard"; // ─── Messages ──────────────────────────────────────────────────────────────── @@ -272,50 +272,73 @@ export function login(password: string): Promise<{ ok: boolean }> { }); } -// ─── Corrections (Adaptive Prompt Tuner) ────────────────────────────────────── +// ─── Dashboard ───────────────────────────────────────────────────────────────── -export interface CorrectionStats { - total_corrections: number; - recent_count_7d: number; - by_flag: Array<{ flag: string; count: number }>; +export interface DashboardStats { + total_messages: number; + total_users: number; + total_flagged: number; + total_clean: number; + total_warned: number; + total_error: number; + total_voice_recordings: number; + total_profiles: number; + today_messages: number; + today_flagged: number; + active_users_24h: number; + top_channels: Array<{ channel_id: string; message_count: number }>; + moderation_overview: { + pending: number; + processing: number; + error: number; + }; } -export interface CorrectionEntry { - id: string; - message_id: string; - original_flags: string; - corrected_flags: string; - correction_notes: string | null; - content_snippet: string; - created_at: number; +export interface DashboardUser { + user_id: string; + username: string | null; + avatar_url: string | null; + profile_summary: string | null; + total_messages: number; + flagged_count: number; + last_message_at: number | null; + trust_score: number | null; } -export function getCorrectionStats(): Promise { - return request("/api/corrections/stats"); +export interface DashboardUserDetail extends DashboardUser { + last_analyzed_at: number | null; + clean_message_streak: number | null; + total_infractions: number | null; + clean_count: number; + recent_messages: Array<{ + id: string; + content: string; + channel_id: string; + created_at: number; + ai_status: string | null; + }>; } -export function listCorrections( - params: { limit?: number; cursor?: string } = {}, -): Promise<{ data: CorrectionEntry[]; nextCursor: string | null }> { +export function getDashboardStats(): Promise { + return request("/api/dashboard/stats"); +} + +export function listDashboardUsers( + params: { limit?: number; cursor?: string; search?: string } = {}, +): Promise<{ data: DashboardUser[]; nextCursor: string | null }> { const sp = new URLSearchParams(); if (params.limit) sp.set("limit", String(params.limit)); if (params.cursor) sp.set("cursor", params.cursor); - return request<{ data: CorrectionEntry[]; nextCursor: string | null }>( - `/api/corrections?${sp}`, + if (params.search) sp.set("search", params.search); + return request<{ data: DashboardUser[]; nextCursor: string | null }>( + `/api/dashboard/users?${sp}`, ); } -export function submitCorrection(data: { - message_id: string; - original_flags: string[]; - corrected_flags: string[]; - correction_notes?: string; - content_snippet: string; -}): Promise { - return request("/api/corrections", { - method: "POST", - body: JSON.stringify(data), - }); +export function getDashboardUserDetail( + userId: string, +): Promise { + return request(`/api/dashboard/users/${userId}`); } // ─── UI State ──────────────────────────────────────────────────────────────── diff --git a/services/frontend/src/shared/ui/MobileTabBar.tsx b/services/frontend/src/shared/ui/MobileTabBar.tsx index 1b8a9f5..e859025 100644 --- a/services/frontend/src/shared/ui/MobileTabBar.tsx +++ b/services/frontend/src/shared/ui/MobileTabBar.tsx @@ -1,11 +1,11 @@ -import { MessageSquare, Radio, SlidersHorizontal } from "lucide-react"; +import { LayoutDashboard, MessageSquare, Radio } from "lucide-react"; import type { DashboardTab } from "../../entities/ui/types"; import { cn } from "../lib/utils"; const tabs: Array<{ id: DashboardTab; label: string; Icon: typeof Radio }> = [ { id: "live", label: "Live", Icon: Radio }, { id: "messages", label: "Messages", Icon: MessageSquare }, - { id: "tuner", label: "Tuner", Icon: SlidersHorizontal }, + { id: "dashboard", label: "Dashboard", Icon: LayoutDashboard }, ]; interface MobileTabBarProps { diff --git a/services/frontend/src/widgets/Header.tsx b/services/frontend/src/widgets/Header.tsx index 42e0ad4..9535127 100644 --- a/services/frontend/src/widgets/Header.tsx +++ b/services/frontend/src/widgets/Header.tsx @@ -10,13 +10,13 @@ import type { WsStatus } from "../shared/ws/socket"; const titles: Record = { live: "Voice & Media", messages: "Messages & Moderation", - tuner: "Prompt Tuner", + dashboard: "Dashboard", }; const subtitles: Record = { live: "Join voice channels, play media, stream audio, and browse recordings.", messages: "Capture, analyse, and moderate Discord messages.", - tuner: "Monitor correction patterns and improve AI moderation accuracy.", + dashboard: "Server statistics, user profiles, and AI moderation overview.", }; interface HeaderProps { diff --git a/services/frontend/src/widgets/Sidebar.tsx b/services/frontend/src/widgets/Sidebar.tsx index 46a3592..06a7970 100644 --- a/services/frontend/src/widgets/Sidebar.tsx +++ b/services/frontend/src/widgets/Sidebar.tsx @@ -1,5 +1,5 @@ import { motion } from "framer-motion"; -import { MessageSquare, Radio, SlidersHorizontal } from "lucide-react"; +import { LayoutDashboard, MessageSquare, Radio } from "lucide-react"; import type { DashboardTab } from "../entities/ui/types"; import type { MessageRecord } from "../shared/api/client"; import { useMascotChat } from "../shared/hooks/useMascotChat"; @@ -11,7 +11,7 @@ const navItems: Array<{ id: DashboardTab; label: string; icon: typeof Radio }> = [ { id: "live", label: "Live", icon: Radio }, { id: "messages", label: "Messages", icon: MessageSquare }, - { id: "tuner", label: "Tuner", icon: SlidersHorizontal }, + { id: "dashboard", label: "Dashboard", icon: LayoutDashboard }, ]; interface SidebarProps {