From f999be4fa020a0642caa7c6f23962c25d353f42d Mon Sep 17 00:00:00 2001 From: asepharyana Date: Wed, 5 Aug 2026 11:11:10 +0700 Subject: [PATCH] feat(dashboard): add moderation log page + message edit history MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Backend moderation module: GET /api/moderation/stats (per-status + failed rate) + GET /api/moderation/actions (filter by status/actionType, cursor paging), joins messages for target username + content - Message GET /api/messages/detail/:id now returns edit_count + edit_history (old_content snapshots from message_edits, newest first) - FE: new /moderation page — summary cards (total/executed/failed/pending + failed-rate), status+type filter chips, timeline rows with action icon, target user, reason, status badge, timestamps, error text - FE: message detail shows 'Riwayat edit' panel with previous versions --- services/backend/src/http/app.ts | 2 + .../modules/messages/messages.repository.ts | 22 ++ .../src/modules/messages/messages.service.ts | 7 +- .../backend/src/modules/moderation/index.ts | 1 + .../moderation/moderation.repository.ts | 141 +++++++++ .../modules/moderation/moderation.routes.ts | 43 +++ .../modules/moderation/moderation.service.ts | 21 ++ .../src/app/(dashboard)/moderation/page.tsx | 11 + .../messages/message-detail-view.tsx | 23 +- .../moderation/moderation-section.tsx | 297 ++++++++++++++++++ services/frontend/src/hooks/index.ts | 4 + services/frontend/src/hooks/use-moderation.ts | 20 ++ services/frontend/src/lib/api/index.ts | 1 + services/frontend/src/lib/api/moderation.ts | 23 ++ services/frontend/src/lib/navigation.ts | 7 + services/frontend/src/lib/types/index.ts | 1 + services/frontend/src/lib/types/message.ts | 4 + services/frontend/src/lib/types/moderation.ts | 38 +++ 18 files changed, 664 insertions(+), 2 deletions(-) create mode 100644 services/backend/src/modules/moderation/index.ts create mode 100644 services/backend/src/modules/moderation/moderation.repository.ts create mode 100644 services/backend/src/modules/moderation/moderation.routes.ts create mode 100644 services/backend/src/modules/moderation/moderation.service.ts create mode 100644 services/frontend/src/app/(dashboard)/moderation/page.tsx create mode 100644 services/frontend/src/components/moderation/moderation-section.tsx create mode 100644 services/frontend/src/hooks/use-moderation.ts create mode 100644 services/frontend/src/lib/api/moderation.ts create mode 100644 services/frontend/src/lib/types/moderation.ts diff --git a/services/backend/src/http/app.ts b/services/backend/src/http/app.ts index 5f3f804..03d1999 100644 --- a/services/backend/src/http/app.ts +++ b/services/backend/src/http/app.ts @@ -13,6 +13,7 @@ import { createDashboardRouter } from "../modules/dashboard/index.js"; import { createHealthRouter } from "../modules/health/index.js"; import { createMediaRouter } from "../modules/media/index.js"; import { createMessagesRouter } from "../modules/messages/index.js"; +import { createModerationRouter } from "../modules/moderation/index.js"; import { createRecordingsRouter } from "../modules/recordings/index.js"; import { createUiStateRouter } from "../modules/ui-state/index.js"; import { createVoiceRouter } from "../modules/voice/index.js"; @@ -69,6 +70,7 @@ export function createHttpApp(): Express { app.use("/api", createUiStateRouter()); app.use("/api", createMediaRouter()); app.use("/api", createVoiceRouter()); + app.use("/api", createModerationRouter()); // 404 handler app.use((_req: Request, res: Response) => { diff --git a/services/backend/src/modules/messages/messages.repository.ts b/services/backend/src/modules/messages/messages.repository.ts index 07cb578..a812e33 100644 --- a/services/backend/src/modules/messages/messages.repository.ts +++ b/services/backend/src/modules/messages/messages.repository.ts @@ -9,6 +9,7 @@ import { notInArray, or, type SQL, + sql, } from "drizzle-orm"; import { config } from "../../shared/config/index.js"; import { getDatabase } from "../../shared/database/index.js"; @@ -114,6 +115,27 @@ export class MessagesRepository { return mapMessageRow(row as Record); } + /** + * Edit history for a message: previous content snapshots (newest first). + * Stored in message_edits by the gateway's message-capture module. + */ + async getEditHistory( + messageId: string, + ): Promise> { + const db = getDatabase(); + const result = await db.execute(sql` + SELECT old_content, edited_at + FROM message_edits + WHERE message_id = ${messageId} + ORDER BY edited_at DESC + LIMIT 50 + `); + return ((result.rows as Record[]) || []).map((r) => ({ + old_content: String(r.old_content ?? ""), + edited_at: Number(r.edited_at ?? 0), + })); + } + async findByChannel( channelId: string, query: MessageQuery, diff --git a/services/backend/src/modules/messages/messages.service.ts b/services/backend/src/modules/messages/messages.service.ts index 5b6ac7a..4140630 100644 --- a/services/backend/src/modules/messages/messages.service.ts +++ b/services/backend/src/modules/messages/messages.service.ts @@ -34,7 +34,12 @@ export class MessagesService { throw new NotFoundError(`Message with ID ${id} not found`); } - return message; + const editHistory = await messagesRepository.getEditHistory(id); + return { + ...message, + edit_count: editHistory.length, + edit_history: editHistory, + }; } async getAttachmentsByChannel(channelId: string, query: MessageQuery) { diff --git a/services/backend/src/modules/moderation/index.ts b/services/backend/src/modules/moderation/index.ts new file mode 100644 index 0000000..aa97530 --- /dev/null +++ b/services/backend/src/modules/moderation/index.ts @@ -0,0 +1 @@ +export { createModerationRouter } from "./moderation.routes.js"; diff --git a/services/backend/src/modules/moderation/moderation.repository.ts b/services/backend/src/modules/moderation/moderation.repository.ts new file mode 100644 index 0000000..d73c56e --- /dev/null +++ b/services/backend/src/modules/moderation/moderation.repository.ts @@ -0,0 +1,141 @@ +import { sql } from "drizzle-orm"; +import { getDatabase } from "../../shared/database/index.js"; + +export interface ListModerationQuery { + status?: string; + actionType?: string; + limit?: number; + cursor?: number; +} + +const ACTION_TYPES = [ + "delete_message", + "mute_user", + "warn_user", + "kick_user", + "ban_user", +] as const; +const STATUSES = ["pending", "executed", "failed"] as const; + +export class ModerationRepository { + async getStats() { + const db = getDatabase(); + const result = await db.execute(sql` + SELECT action_type, status, COUNT(*)::int AS c + FROM moderation_actions + GROUP BY action_type, status + `); + + const rows = (result.rows as Record[]) || []; + let executed = 0; + let failed = 0; + let pending = 0; + + const byAction: Record< + string, + { executed: number; failed: number; pending: number } + > = {}; + + for (const r of rows) { + const actionType = String(r.action_type ?? "unknown"); + const status = String(r.status ?? "unknown"); + const count = Number(r.c ?? 0); + byAction[actionType] ??= { executed: 0, failed: 0, pending: 0 }; + if (status === "executed") { + executed += count; + byAction[actionType].executed += count; + } else if (status === "failed") { + failed += count; + byAction[actionType].failed += count; + } else { + pending += count; + byAction[actionType].pending += count; + } + } + + const total = executed + failed + pending; + + return { + total, + executed, + failed, + pending, + failed_rate: total > 0 ? Number(((failed / total) * 100).toFixed(1)) : 0, + by_action: byAction, + }; + } + + async listActions(query: ListModerationQuery) { + const db = getDatabase(); + const limit = Math.min(Math.max(query.limit ?? 50, 1), 200); + const conditions: string[] = []; + + if ( + query.status && + (STATUSES as readonly string[]).includes(query.status) + ) { + conditions.push(`a.status = '${query.status}'`); + } + if ( + query.actionType && + (ACTION_TYPES as readonly string[]).includes(query.actionType) + ) { + conditions.push(`a.action_type = '${query.actionType}'`); + } + if (query.cursor) { + conditions.push(`a.created_at < ${Number(query.cursor)}`); + } + + const whereClause = + conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : ""; + + const result = await db.execute( + sql.raw(` + SELECT + a.id, + a.message_id, + a.user_id, + a.guild_id, + a.action_type, + a.reason, + a.executed_by, + a.status, + a.error, + a.created_at, + a.executed_at, + m.username, + LEFT(m.content, 300) AS content + FROM moderation_actions a + LEFT JOIN messages m ON m.id = a.message_id + ${whereClause} + ORDER BY a.created_at DESC + LIMIT ${limit + 1} + `), + ); + + const rows = (result.rows as Record[]) || []; + const data = rows.slice(0, limit).map((r) => ({ + id: String(r.id ?? ""), + message_id: r.message_id ? String(r.message_id) : null, + user_id: r.user_id ? String(r.user_id) : null, + guild_id: String(r.guild_id ?? ""), + action_type: String(r.action_type ?? "unknown"), + reason: r.reason ? String(r.reason) : null, + executed_by: r.executed_by ? String(r.executed_by) : null, + status: String(r.status ?? "unknown"), + error: r.error ? String(r.error) : null, + created_at: r.created_at ? Number(r.created_at) : null, + executed_at: r.executed_at ? Number(r.executed_at) : null, + username: r.username ? String(r.username) : null, + content: r.content ? String(r.content) : null, + })); + + const lastRow = rows[limit - 1] as Record | undefined; + const nextCursor = + rows.length > limit ? String(lastRow?.created_at ?? "") : null; + + return { data, nextCursor }; + } +} + +export const moderationRepository = new ModerationRepository(); diff --git a/services/backend/src/modules/moderation/moderation.routes.ts b/services/backend/src/modules/moderation/moderation.routes.ts new file mode 100644 index 0000000..afeb835 --- /dev/null +++ b/services/backend/src/modules/moderation/moderation.routes.ts @@ -0,0 +1,43 @@ +import type { Request, Response, Router } from "express"; +import express from "express"; +import { createChildLogger } from "../../shared/logger/index.js"; +import { asyncHandler } from "../../shared/middlewares/index.js"; +import { moderationService } from "./moderation.service.js"; + +const logger = createChildLogger("moderation.routes"); + +export function createModerationRouter(): Router { + const router = express.Router(); + + // GET /api/moderation/stats — moderation action summary + router.get( + "/moderation/stats", + asyncHandler(async (_req: Request, res: Response) => { + const stats = await moderationService.getStats(); + res.json(stats); + }), + ); + + // GET /api/moderation/actions — paginated moderation action log + router.get( + "/moderation/actions", + asyncHandler(async (req: Request, res: Response) => { + const limit = Number(req.query.limit) || 50; + const status = req.query.status as string | undefined; + const actionType = req.query.actionType as string | undefined; + const cursor = req.query.cursor as string | undefined; + + const result = await moderationService.listActions({ + limit, + status, + actionType, + cursor: cursor ? Number(cursor) : undefined, + }); + + logger.debug({ count: result.data.length }, "Moderation actions listed"); + res.json(result); + }), + ); + + return router; +} diff --git a/services/backend/src/modules/moderation/moderation.service.ts b/services/backend/src/modules/moderation/moderation.service.ts new file mode 100644 index 0000000..076e3e1 --- /dev/null +++ b/services/backend/src/modules/moderation/moderation.service.ts @@ -0,0 +1,21 @@ +import { createChildLogger } from "../../shared/logger/index.js"; +import { + type ListModerationQuery, + moderationRepository, +} from "./moderation.repository.js"; + +const logger = createChildLogger("moderation.service"); + +export class ModerationService { + async getStats() { + logger.debug("Fetching moderation stats"); + return moderationRepository.getStats(); + } + + async listActions(query: ListModerationQuery) { + logger.debug({ query }, "Listing moderation actions"); + return moderationRepository.listActions(query); + } +} + +export const moderationService = new ModerationService(); diff --git a/services/frontend/src/app/(dashboard)/moderation/page.tsx b/services/frontend/src/app/(dashboard)/moderation/page.tsx new file mode 100644 index 0000000..d4f1c51 --- /dev/null +++ b/services/frontend/src/app/(dashboard)/moderation/page.tsx @@ -0,0 +1,11 @@ +"use client"; + +import { ModerationSection } from "@/components/moderation/moderation-section"; + +export default function ModerationPage() { + return ( +
+ +
+ ); +} diff --git a/services/frontend/src/components/messages/message-detail-view.tsx b/services/frontend/src/components/messages/message-detail-view.tsx index 32c132d..e1450c2 100644 --- a/services/frontend/src/components/messages/message-detail-view.tsx +++ b/services/frontend/src/components/messages/message-detail-view.tsx @@ -1,6 +1,6 @@ "use client"; -import { ArrowLeft, MessageSquare, MessagesSquare } from "lucide-react"; +import { ArrowLeft, MessageSquare, MessagesSquare, Pencil } from "lucide-react"; import { GlassCard } from "@/components/glass/card"; import { getMessageChannelLabel, renderMessageContent } from "@/lib/format"; import type { AttachmentRecord, MessageRecord } from "@/lib/types"; @@ -52,6 +52,27 @@ export function MessageDetailView({ ) || "(no text content)"} + {/* Edit history */} + {message.edit_history && message.edit_history.length > 0 && ( +
+

+ + Riwayat edit · {message.edit_history.length} versi sebelumnya +

+ {message.edit_history.map((edit, i) => ( +
+

+ {new Date(edit.edited_at).toLocaleString("id-ID")} +

+

+ {renderMessageContent(edit.old_content, message.metadata) || + "(kosong)"} +

+
+ ))} +
+ )} + {/* Attachments */} {attachments && attachments.length > 0 && (
diff --git a/services/frontend/src/components/moderation/moderation-section.tsx b/services/frontend/src/components/moderation/moderation-section.tsx new file mode 100644 index 0000000..f7a4af1 --- /dev/null +++ b/services/frontend/src/components/moderation/moderation-section.tsx @@ -0,0 +1,297 @@ +"use client"; + +import { + AlertTriangle, + Ban, + CheckCircle2, + Loader2, + MicOff, + ShieldAlert, + Trash2, + UserX, + XCircle, +} from "lucide-react"; +import { useState } from "react"; +import { GlassCard } from "@/components/glass/card"; +import { EmptyState, LoadingSkeleton } from "@/components/shared"; +import { Badge } from "@/components/ui/badge"; +import { useModerationActions, useModerationStats } from "@/hooks"; +import { renderMessageContent } from "@/lib/format"; +import type { ModerationAction, ModerationActionType } from "@/lib/types"; +import { cn } from "@/lib/utils"; + +const ACTION_META: Record< + ModerationActionType, + { label: string; Icon: typeof Trash2; className: string } +> = { + delete_message: { + label: "Delete message", + Icon: Trash2, + className: "text-red-500", + }, + mute_user: { label: "Mute user", Icon: MicOff, className: "text-orange-500" }, + warn_user: { + label: "Warn user", + Icon: AlertTriangle, + className: "text-amber-500", + }, + kick_user: { label: "Kick user", Icon: UserX, className: "text-orange-500" }, + ban_user: { label: "Ban user", Icon: Ban, className: "text-red-500" }, +}; + +const STATUS_META: Record< + ModerationAction["status"], + { label: string; className: string; dot: string } +> = { + executed: { + label: "Executed", + className: "border-green-500/40 text-green-500", + dot: "bg-green-500", + }, + failed: { + label: "Failed", + className: "border-red-500/40 text-red-500", + dot: "bg-red-500", + }, + pending: { + label: "Pending", + className: "border-amber-500/40 text-amber-500", + dot: "bg-amber-500", + }, +}; + +function fmtTime(ts: number | null): string { + if (!ts) return "—"; + const d = new Date(ts); + const diff = Date.now() - ts; + const hours = Math.floor(diff / 3600000); + const rel = + hours < 1 + ? "baru saja" + : hours < 24 + ? `${hours} jam lalu` + : `${Math.floor(hours / 24)} hari lalu`; + return `${d.toLocaleString("id-ID")} (${rel})`; +} + +const EMPTY_ACTION_RATE = { + total: 0, + executed: 0, + failed: 0, + pending: 0, + failed_rate: 0, +}; + +export function ModerationSection() { + const [status, setStatus] = useState(""); + const [actionType, setActionType] = useState(""); + const { data: stats } = useModerationStats(); + const { data: actions, isLoading: actionsLoading } = useModerationActions( + status, + actionType, + ); + + const s = stats ?? EMPTY_ACTION_RATE; + + const statusFilters = ["", "executed", "failed", "pending"]; + const typeFilters = [ + "", + "delete_message", + "warn_user", + "kick_user", + "ban_user", + "mute_user", + ]; + + return ( +
+ {/* Summary cards */} +
+ + + 0 ? `${s.failed_rate}%` : undefined} + /> + +
+ + {/* Filters */} +
+ + Status + + {statusFilters.map((f) => ( + setStatus(f)} + /> + ))} + + Tipe + + {typeFilters.map((f) => ( + setActionType(f)} + /> + ))} +
+ + {/* Timeline */} + {actionsLoading ? ( + + ) : !actions || actions.length === 0 ? ( + + + + ) : ( +
+ {actions.map((a) => ( + + ))} +
+ )} + +

+ {actions?.length ?? 0} aksi ditampilkan · log moderasi gateway Discord +

+
+ ); +} + +function SummaryCard({ + label, + value, + color, + hint, +}: { + label: string; + value: number; + color: string; + hint?: string; +}) { + return ( + +

+ {label} +

+

+ {value} + {hint && ( + ({hint}) + )} +

+
+ ); +} + +function FilterChip({ + active, + label, + onClick, +}: { + active: boolean; + label: string; + onClick: () => void; +}) { + return ( + + ); +} + +function ActionRow({ action }: { action: ModerationAction }) { + const meta = ACTION_META[action.action_type] ?? ACTION_META.delete_message; + const st = STATUS_META[action.status]; + const Icon = meta.Icon; + return ( + + + + +
+
+ + {meta.label} + + {action.username && ( + + @{action.username} + + )} + + + {st.label} + +
+ {action.content && ( +

+ {renderMessageContent(action.content, null)} +

+ )} + {action.reason && ( +

+ Alasan: {action.reason} +

+ )} + {action.error && ( +

+ Error: {action.error} +

+ )} +

+ dibuat {fmtTime(action.created_at)} + {action.executed_at + ? ` · dieksekusi ${fmtTime(action.executed_at)}` + : ""} +

+
+ {action.status === "executed" ? ( + + ) : action.status === "failed" ? ( + + ) : ( + + )} +
+ ); +} + +export default ModerationSection; diff --git a/services/frontend/src/hooks/index.ts b/services/frontend/src/hooks/index.ts index d06644a..cd7967c 100644 --- a/services/frontend/src/hooks/index.ts +++ b/services/frontend/src/hooks/index.ts @@ -28,6 +28,10 @@ export { useReview, useTextChannels, } from "./use-messages"; +export { + useModerationActions, + useModerationStats, +} from "./use-moderation"; export { useDeleteRecording, useRecordings, diff --git a/services/frontend/src/hooks/use-moderation.ts b/services/frontend/src/hooks/use-moderation.ts new file mode 100644 index 0000000..d7ca513 --- /dev/null +++ b/services/frontend/src/hooks/use-moderation.ts @@ -0,0 +1,20 @@ +import useSWR from "swr"; +import { moderationApi } from "@/lib/api"; +import type { ModerationStats } from "@/lib/types"; + +export function useModerationStats() { + return useSWR(["moderation-stats"], () => + moderationApi.getStats(), + ); +} + +export function useModerationActions(status?: string, actionType?: string) { + return useSWR( + ["moderation-actions", status ?? "__all__", actionType ?? "__all__"], + async () => { + const res = await moderationApi.listActions(100, status, actionType); + return res.data; + }, + { keepPreviousData: true }, + ); +} diff --git a/services/frontend/src/lib/api/index.ts b/services/frontend/src/lib/api/index.ts index 8aa1419..7bce938 100644 --- a/services/frontend/src/lib/api/index.ts +++ b/services/frontend/src/lib/api/index.ts @@ -4,6 +4,7 @@ export { configApi } from "./config"; export { dashboardApi } from "./dashboard"; export { mediaApi } from "./media"; export { messagesApi } from "./messages"; +export { moderationApi } from "./moderation"; export { recordingsApi } from "./recordings"; export { uiStateApi } from "./ui-state"; export { voiceApi } from "./voice"; diff --git a/services/frontend/src/lib/api/moderation.ts b/services/frontend/src/lib/api/moderation.ts new file mode 100644 index 0000000..d69b67b --- /dev/null +++ b/services/frontend/src/lib/api/moderation.ts @@ -0,0 +1,23 @@ +import type { ModerationStats, PaginatedModerationActions } from "@/lib/types"; +import { api } from "./client"; + +export const moderationApi = { + getStats: () => api.get("/api/moderation/stats"), + + listActions: ( + limit?: number, + status?: string, + actionType?: string, + cursor?: string, + ) => { + const params = new URLSearchParams(); + if (limit) params.set("limit", String(limit)); + if (status) params.set("status", status); + if (actionType) params.set("actionType", actionType); + if (cursor) params.set("cursor", cursor); + const qs = params.toString(); + return api.get( + `/api/moderation/actions${qs ? `?${qs}` : ""}`, + ); + }, +}; diff --git a/services/frontend/src/lib/navigation.ts b/services/frontend/src/lib/navigation.ts index e709adf..b52eb05 100644 --- a/services/frontend/src/lib/navigation.ts +++ b/services/frontend/src/lib/navigation.ts @@ -6,6 +6,7 @@ import { Mic, Music, Search, + Shield, } from "lucide-react"; export interface NavItem { @@ -50,6 +51,12 @@ export const navItems: NavItem[] = [ icon: Headphones, matchPrefix: "/recordings", }, + { + href: "/moderation", + label: "Moderation", + icon: Shield, + matchPrefix: "/moderation", + }, { href: "/analysis", label: "Search", diff --git a/services/frontend/src/lib/types/index.ts b/services/frontend/src/lib/types/index.ts index 01816de..f90f4ef 100644 --- a/services/frontend/src/lib/types/index.ts +++ b/services/frontend/src/lib/types/index.ts @@ -2,6 +2,7 @@ export * from "./dashboard"; export * from "./guild"; export * from "./media"; export * from "./message"; +export * from "./moderation"; export * from "./recording"; export * from "./ui"; export * from "./voice"; diff --git a/services/frontend/src/lib/types/message.ts b/services/frontend/src/lib/types/message.ts index 9c5dd61..0752c23 100644 --- a/services/frontend/src/lib/types/message.ts +++ b/services/frontend/src/lib/types/message.ts @@ -127,6 +127,10 @@ export interface MessageRecord { ai_recommended_action?: AiRecommendedAction | null; ai_error?: string | null; ai_analyzed_at?: number | null; + /** Detail-only: number of past edits (message_edits snapshots) */ + edit_count?: number; + /** Detail-only: previous content snapshots, newest first */ + edit_history?: Array<{ old_content: string; edited_at: number }>; } // ── Pagination ────────────────────────────────────────────── diff --git a/services/frontend/src/lib/types/moderation.ts b/services/frontend/src/lib/types/moderation.ts new file mode 100644 index 0000000..3c4d050 --- /dev/null +++ b/services/frontend/src/lib/types/moderation.ts @@ -0,0 +1,38 @@ +export type ModerationActionType = + | "delete_message" + | "mute_user" + | "warn_user" + | "kick_user" + | "ban_user"; + +export type ModerationStatus = "pending" | "executed" | "failed"; + +export interface ModerationAction { + id: string; + message_id: string | null; + user_id: string | null; + guild_id: string; + action_type: ModerationActionType; + reason: string | null; + executed_by: string | null; + status: ModerationStatus; + error: string | null; + created_at: number | null; + executed_at: number | null; + username: string | null; + content: string | null; +} + +export interface ModerationStats { + total: number; + executed: number; + failed: number; + pending: number; + failed_rate: number; + by_action: Record; +} + +export interface PaginatedModerationActions { + data: ModerationAction[]; + nextCursor: string | null; +}