feat(analytics): add moderation actions, AI stats, and attachment stats endpoints

This commit is contained in:
MythEclipse
2026-06-03 18:16:55 +07:00
parent acf6c931e0
commit f1bc36d6d8
5 changed files with 336 additions and 0 deletions
@@ -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);
}
@@ -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<string, unknown> | 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<string, unknown> | 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();
@@ -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;
}
@@ -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();
@@ -454,3 +454,99 @@ export function fetchHeatmap(params: {
});
return request<HeatmapCell[]>(`/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<ModerationActionRecord[]> {
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<ModerationActionRecord[]>(`/api/analytics/moderation-actions?${sp}`);
}
export function fetchAIStats(params: {
guildId: string;
channelId?: string;
hours?: number;
}): Promise<AIStats> {
const sp = new URLSearchParams({
guildId: params.guildId,
...(params.channelId && { channelId: params.channelId }),
...(params.hours && { hours: String(params.hours) }),
});
return request<AIStats>(`/api/analytics/ai-stats?${sp}`);
}
export function fetchAttachmentStats(params: {
guildId: string;
channelId?: string;
hours?: number;
}): Promise<AttachmentStats> {
const sp = new URLSearchParams({
guildId: params.guildId,
...(params.channelId && { channelId: params.channelId }),
...(params.hours && { hours: String(params.hours) }),
});
return request<AttachmentStats>(`/api/analytics/attachment-stats?${sp}`);
}