diff --git a/services/backend/src/modules/analytics/analytics.controller.ts b/services/backend/src/modules/analytics/analytics.controller.ts index a0601c4..34890dd 100644 --- a/services/backend/src/modules/analytics/analytics.controller.ts +++ b/services/backend/src/modules/analytics/analytics.controller.ts @@ -141,3 +141,56 @@ export function handleGetTopics( res.json(result); })(req, res, next); } + +export function handleGetModerationActions( + req: Request, + res: Response, + next: NextFunction, +) { + return asyncHandler(async (req: Request, res: Response) => { + const query = analyticsQuerySchema.parse(req.query); + const limit = req.query.limit ? Number(req.query.limit) : 20; + logger.debug({ query, limit }, "Handling get moderation actions"); + const result = await analyticsService.getModerationActions( + query.guildId, + query.channelId, + query.hours, + limit, + ); + res.json(result); + })(req, res, next); +} + +export function handleGetAIStats( + req: Request, + res: Response, + next: NextFunction, +) { + return asyncHandler(async (req: Request, res: Response) => { + const query = analyticsQuerySchema.parse(req.query); + logger.debug({ query }, "Handling get AI stats"); + const result = await analyticsService.getAIStats( + query.guildId, + query.channelId, + query.hours, + ); + res.json(result); + })(req, res, next); +} + +export function handleGetAttachmentStats( + req: Request, + res: Response, + next: NextFunction, +) { + return asyncHandler(async (req: Request, res: Response) => { + const query = analyticsQuerySchema.parse(req.query); + logger.debug({ query }, "Handling get attachment stats"); + const result = await analyticsService.getAttachmentStats( + query.guildId, + query.channelId, + query.hours, + ); + res.json(result); + })(req, res, next); +} diff --git a/services/backend/src/modules/analytics/analytics.repository.ts b/services/backend/src/modules/analytics/analytics.repository.ts index 6355793..31f0e02 100644 --- a/services/backend/src/modules/analytics/analytics.repository.ts +++ b/services/backend/src/modules/analytics/analytics.repository.ts @@ -337,6 +337,159 @@ export class AnalyticsRepository { score: Number(r.score ?? 0), })); } + + // ── New endpoints ───────────────────────────────────────────────────────── + + async getModerationActions( + guildId: string, + channelId?: string, + hours = 24, + limit = 20, + ) { + logger.debug({ guildId, channelId, hours, limit }, "Getting moderation actions"); + const pool = getPool(); + const filter = buildTimeFilter(guildId, channelId, hours); + + const { rows } = await pool.query( + ` + SELECT + ma.id, + ma.message_id, + ma.user_id, + ma.guild_id, + ma.action_type, + ma.reason, + ma.executed_by, + ma.status, + ma.error, + ma.created_at, + ma.executed_at, + m.username, + m.content + FROM moderation_actions ma + LEFT JOIN messages m ON m.id = ma.message_id + ${filter.where.replace('guild_id', 'ma.guild_id').replace('created_at', 'ma.created_at')} + ORDER BY ma.created_at DESC + LIMIT $${filter.params.length + 1} + `, + [...filter.params, limit], + ); + + return rows.map((r) => ({ + id: r.id as string, + message_id: (r.message_id as string) ?? null, + user_id: r.user_id as string, + guild_id: r.guild_id as string, + action_type: r.action_type as string, + reason: (r.reason as string) ?? null, + executed_by: (r.executed_by as string) ?? null, + status: r.status as string, + error: (r.error as string) ?? null, + created_at: Number(r.created_at ?? 0), + executed_at: r.executed_at ? Number(r.executed_at) : null, + username: (r.username as string) ?? "Unknown", + content: (r.content as string) ?? null, + })); + } + + async getAIStats(guildId: string, channelId?: string, hours = 24) { + logger.debug({ guildId, channelId, hours }, "Getting AI stats"); + const pool = getPool(); + const filter = buildTimeFilter(guildId, channelId, hours); + + const { rows } = await pool.query( + ` + SELECT + COUNT(*)::int AS total_analyzed, + COUNT(*) FILTER (WHERE ai_severity = 'none')::int AS severity_none, + COUNT(*) FILTER (WHERE ai_severity = 'low')::int AS severity_low, + COUNT(*) FILTER (WHERE ai_severity = 'medium')::int AS severity_medium, + COUNT(*) FILTER (WHERE ai_severity = 'high')::int AS severity_high, + COUNT(*) FILTER (WHERE ai_severity = 'critical')::int AS severity_critical, + COUNT(*) FILTER (WHERE ai_recommended_action = 'none')::int AS action_none, + COUNT(*) FILTER (WHERE ai_recommended_action = 'monitor')::int AS action_monitor, + COUNT(*) FILTER (WHERE ai_recommended_action = 'warn')::int AS action_warn, + COUNT(*) FILTER (WHERE ai_recommended_action = 'review')::int AS action_review, + COUNT(*) FILTER (WHERE ai_recommended_action = 'delete')::int AS action_delete, + COUNT(*) FILTER (WHERE ai_recommended_action = 'escalate')::int AS action_escalate, + COUNT(*) FILTER (WHERE ai_status = 'error')::int AS analysis_errors, + COUNT(*) FILTER (WHERE ai_status = 'pending')::int AS analysis_pending, + COALESCE(AVG(ai_confidence), 0)::real AS avg_confidence, + COALESCE(AVG(ai_moderation_score), 0)::real AS avg_score + FROM messages + ${filter.where} + `, + filter.params, + ); + + const row = rows[0] as Record | undefined; + + return { + total_analyzed: Number(row?.total_analyzed ?? 0), + severity: { + none: Number(row?.severity_none ?? 0), + low: Number(row?.severity_low ?? 0), + medium: Number(row?.severity_medium ?? 0), + high: Number(row?.severity_high ?? 0), + critical: Number(row?.severity_critical ?? 0), + }, + recommended_actions: { + none: Number(row?.action_none ?? 0), + monitor: Number(row?.action_monitor ?? 0), + warn: Number(row?.action_warn ?? 0), + review: Number(row?.action_review ?? 0), + delete: Number(row?.action_delete ?? 0), + escalate: Number(row?.action_escalate ?? 0), + }, + analysis_errors: Number(row?.analysis_errors ?? 0), + analysis_pending: Number(row?.analysis_pending ?? 0), + avg_confidence: Number(row?.avg_confidence ?? 0), + avg_score: Number(row?.avg_score ?? 0), + }; + } + + async getAttachmentStats(guildId: string, channelId?: string, hours = 24) { + logger.debug({ guildId, channelId, hours }, "Getting attachment stats"); + const pool = getPool(); + const filter = buildTimeFilter(guildId, channelId, hours); + + const { rows } = await pool.query( + ` + SELECT + COUNT(*)::int AS total_attachments, + COUNT(*) FILTER (WHERE a.upload_status = 'uploaded')::int AS uploaded, + COUNT(*) FILTER (WHERE a.upload_status = 'pending')::int AS pending, + COUNT(*) FILTER (WHERE a.upload_status = 'failed')::int AS failed, + COALESCE(SUM(a.size), 0)::bigint AS total_size_bytes, + COUNT(DISTINCT a.user_id)::int AS unique_uploaders, + (SELECT a2.type FROM attachments a2 + WHERE a2.guild_id = $1 + ${channelId ? "AND a2.channel_id = $" + (filter.paramOffset - 1) : ""} + AND a2.created_at > (EXTRACT(EPOCH FROM NOW()) * 1000 - $${filter.paramOffset}) + GROUP BY a2.type ORDER BY COUNT(*) DESC LIMIT 1 + ) AS top_mime_type + FROM attachments a + WHERE a.guild_id = $1 + ${channelId ? "AND a.channel_id = $" + (filter.paramOffset - 1) : ""} + AND a.created_at > (EXTRACT(EPOCH FROM NOW()) * 1000 - $${filter.paramOffset}) + `, + channelId + ? [guildId, channelId, hours * 3_600_000] + : [guildId, hours * 3_600_000], + ); + + const row = rows[0] as Record | undefined; + + return { + total_attachments: Number(row?.total_attachments ?? 0), + uploaded: Number(row?.uploaded ?? 0), + pending: Number(row?.pending ?? 0), + failed: Number(row?.failed ?? 0), + total_size_bytes: Number(row?.total_size_bytes ?? 0), + unique_uploaders: Number(row?.unique_uploaders ?? 0), + top_mime_type: (row?.top_mime_type as string) ?? null, + }; + } } export const analyticsRepository = new AnalyticsRepository(); diff --git a/services/backend/src/modules/analytics/analytics.routes.ts b/services/backend/src/modules/analytics/analytics.routes.ts index 2e8284e..0a1f46e 100644 --- a/services/backend/src/modules/analytics/analytics.routes.ts +++ b/services/backend/src/modules/analytics/analytics.routes.ts @@ -1,9 +1,12 @@ import type { Router } from "express"; import express from "express"; import { + handleGetAIStats, + handleGetAttachmentStats, handleGetDailyTrend, handleGetHeatmap, handleGetHourlyStats, + handleGetModerationActions, handleGetModerationStats, handleGetOverview, handleGetTopViolators, @@ -22,6 +25,9 @@ export function createAnalyticsRouter(): Router { router.get("/analytics/stats", handleGetModerationStats); router.get("/analytics/heatmap", handleGetHeatmap); router.get("/analytics/topics", handleGetTopics); + router.get("/analytics/moderation-actions", handleGetModerationActions); + router.get("/analytics/ai-stats", handleGetAIStats); + router.get("/analytics/attachment-stats", handleGetAttachmentStats); return router; } diff --git a/services/backend/src/modules/analytics/analytics.service.ts b/services/backend/src/modules/analytics/analytics.service.ts index 11037b8..a4fa252 100644 --- a/services/backend/src/modules/analytics/analytics.service.ts +++ b/services/backend/src/modules/analytics/analytics.service.ts @@ -91,6 +91,34 @@ export class AnalyticsService { logger.debug({ guildId, channelId, hours }, "Getting topics"); return analyticsRepository.getTopics(guildId, channelId, hours); } + + async getModerationActions( + guildId: string, + channelId?: string, + hours = 24, + limit = 20, + ) { + this.assertMonitorGuild(guildId); + logger.debug({ guildId, channelId, hours, limit }, "Getting moderation actions"); + return analyticsRepository.getModerationActions( + guildId, + channelId, + hours, + limit, + ); + } + + async getAIStats(guildId: string, channelId?: string, hours = 24) { + this.assertMonitorGuild(guildId); + logger.debug({ guildId, channelId, hours }, "Getting AI stats"); + return analyticsRepository.getAIStats(guildId, channelId, hours); + } + + async getAttachmentStats(guildId: string, channelId?: string, hours = 24) { + this.assertMonitorGuild(guildId); + logger.debug({ guildId, channelId, hours }, "Getting attachment stats"); + return analyticsRepository.getAttachmentStats(guildId, channelId, hours); + } } export const analyticsService = new AnalyticsService(); diff --git a/services/frontend/src/shared/api/client.ts b/services/frontend/src/shared/api/client.ts index 2efeb3b..86a6ea7 100644 --- a/services/frontend/src/shared/api/client.ts +++ b/services/frontend/src/shared/api/client.ts @@ -454,3 +454,99 @@ export function fetchHeatmap(params: { }); return request(`/api/analytics/heatmap?${sp}`); } + +// ── New analytics types & endpoints ──────────────────────────────────────── + +export interface ModerationActionRecord { + id: string; + message_id: string | null; + user_id: string; + guild_id: string; + action_type: string; + reason: string | null; + executed_by: string | null; + status: string; + error: string | null; + created_at: number; + executed_at: number | null; + username: string; + content: string | null; +} + +export interface AISeverityBreakdown { + none: number; + low: number; + medium: number; + high: number; + critical: number; +} + +export interface AIRecommendedActions { + none: number; + monitor: number; + warn: number; + review: number; + delete: number; + escalate: number; +} + +export interface AIStats { + total_analyzed: number; + severity: AISeverityBreakdown; + recommended_actions: AIRecommendedActions; + analysis_errors: number; + analysis_pending: number; + avg_confidence: number; + avg_score: number; +} + +export interface AttachmentStats { + total_attachments: number; + uploaded: number; + pending: number; + failed: number; + total_size_bytes: number; + unique_uploaders: number; + top_mime_type: string | null; +} + +export function fetchModerationActions(params: { + guildId: string; + channelId?: string; + hours?: number; + limit?: number; +}): Promise { + const sp = new URLSearchParams({ + guildId: params.guildId, + ...(params.channelId && { channelId: params.channelId }), + ...(params.hours && { hours: String(params.hours) }), + ...(params.limit && { limit: String(params.limit) }), + }); + return request(`/api/analytics/moderation-actions?${sp}`); +} + +export function fetchAIStats(params: { + guildId: string; + channelId?: string; + hours?: number; +}): Promise { + const sp = new URLSearchParams({ + guildId: params.guildId, + ...(params.channelId && { channelId: params.channelId }), + ...(params.hours && { hours: String(params.hours) }), + }); + return request(`/api/analytics/ai-stats?${sp}`); +} + +export function fetchAttachmentStats(params: { + guildId: string; + channelId?: string; + hours?: number; +}): Promise { + const sp = new URLSearchParams({ + guildId: params.guildId, + ...(params.channelId && { channelId: params.channelId }), + ...(params.hours && { hours: String(params.hours) }), + }); + return request(`/api/analytics/attachment-stats?${sp}`); +}