feat(analytics): add moderation actions, AI stats, and attachment stats endpoints
This commit is contained in:
@@ -141,3 +141,56 @@ export function handleGetTopics(
|
|||||||
res.json(result);
|
res.json(result);
|
||||||
})(req, res, next);
|
})(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),
|
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();
|
export const analyticsRepository = new AnalyticsRepository();
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
import type { Router } from "express";
|
import type { Router } from "express";
|
||||||
import express from "express";
|
import express from "express";
|
||||||
import {
|
import {
|
||||||
|
handleGetAIStats,
|
||||||
|
handleGetAttachmentStats,
|
||||||
handleGetDailyTrend,
|
handleGetDailyTrend,
|
||||||
handleGetHeatmap,
|
handleGetHeatmap,
|
||||||
handleGetHourlyStats,
|
handleGetHourlyStats,
|
||||||
|
handleGetModerationActions,
|
||||||
handleGetModerationStats,
|
handleGetModerationStats,
|
||||||
handleGetOverview,
|
handleGetOverview,
|
||||||
handleGetTopViolators,
|
handleGetTopViolators,
|
||||||
@@ -22,6 +25,9 @@ export function createAnalyticsRouter(): Router {
|
|||||||
router.get("/analytics/stats", handleGetModerationStats);
|
router.get("/analytics/stats", handleGetModerationStats);
|
||||||
router.get("/analytics/heatmap", handleGetHeatmap);
|
router.get("/analytics/heatmap", handleGetHeatmap);
|
||||||
router.get("/analytics/topics", handleGetTopics);
|
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;
|
return router;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -91,6 +91,34 @@ export class AnalyticsService {
|
|||||||
logger.debug({ guildId, channelId, hours }, "Getting topics");
|
logger.debug({ guildId, channelId, hours }, "Getting topics");
|
||||||
return analyticsRepository.getTopics(guildId, channelId, hours);
|
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();
|
export const analyticsService = new AnalyticsService();
|
||||||
|
|||||||
@@ -454,3 +454,99 @@ export function fetchHeatmap(params: {
|
|||||||
});
|
});
|
||||||
return request<HeatmapCell[]>(`/api/analytics/heatmap?${sp}`);
|
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}`);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user