feat: replace corrections/tuner with dashboard module

Replace the corrections/adaptive-prompt-tuner feature with a new
dashboard module providing server stats and user profile overview.

Backend:
- Add dashboard module (routes, service, repository) with stats + user list + user detail endpoints
- Remove corrections module entirely
- Wire dashboard router in app.ts

Frontend:
- Add dashboard feature (DashboardStats, UserSummaryList, UserProfileDetail components + useDashboard hook)
- Remove tuner feature (CorrectionStats, CorrectionHistory, SubmitCorrection, useCorrections)
- Update API client from corrections → dashboard types/fns
- Rename tab 'tuner' → 'dashboard'
- Update MobileTabBar, Header, Sidebar links

Tests:
- Expand backend placeholder test with dashboard assertions
- Expand discord-gateway placeholder test with config/channel assertions

AI moderation:
- llmModerationClient: improve status/reply detection, expand safety categories, fix timer reset
- userProfileLearner: fix isReply refinement
- userProfileStore: add pending cache check
- messageMetadata: add crosspost type mapping
- migrate.ts: improve partial-index safety in schema push

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
MythEclipse
2026-06-13 11:24:42 +07:00
co-authored by Claude
parent e3249edb6c
commit 30f8d7cce3
32 changed files with 1409 additions and 1219 deletions
@@ -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<CorrectionStatsResult> {
const db = getDatabase();
const sevenDaysAgo = Date.now() - 7 * 24 * 60 * 60 * 1000;
// Total count
const [totalRow] = await db
.select({ count: sql<number>`count(*)::int` })
.from(pgCorrectedModerationsTable);
// Recent 7 days count
const [recentRow] = await db
.select({ count: sql<number>`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<string, unknown>) => ({
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<CorrectedModeration> {
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();
@@ -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<string>();
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;
}
@@ -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<typeof correctionQuerySchema>;
export type CorrectionCreate = z.infer<typeof correctionCreateSchema>;
@@ -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<CorrectionStatsResult> {
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<CorrectedModeration> {
logger.debug(
{ messageId: data.message_id },
"Creating correction",
);
return correctionsRepository.create(data);
}
}
export const correctionsService = new CorrectionsService();
@@ -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<string, unknown>) => ({
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<string, unknown>[])
.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<string, unknown> | 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<string, unknown> | 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<string, unknown>[]).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();
@@ -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;
}
@@ -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();
@@ -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.)