feat(dashboard): add moderation log page + message edit history
- 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
This commit is contained in:
@@ -13,6 +13,7 @@ import { createDashboardRouter } from "../modules/dashboard/index.js";
|
|||||||
import { createHealthRouter } from "../modules/health/index.js";
|
import { createHealthRouter } from "../modules/health/index.js";
|
||||||
import { createMediaRouter } from "../modules/media/index.js";
|
import { createMediaRouter } from "../modules/media/index.js";
|
||||||
import { createMessagesRouter } from "../modules/messages/index.js";
|
import { createMessagesRouter } from "../modules/messages/index.js";
|
||||||
|
import { createModerationRouter } from "../modules/moderation/index.js";
|
||||||
import { createRecordingsRouter } from "../modules/recordings/index.js";
|
import { createRecordingsRouter } from "../modules/recordings/index.js";
|
||||||
import { createUiStateRouter } from "../modules/ui-state/index.js";
|
import { createUiStateRouter } from "../modules/ui-state/index.js";
|
||||||
import { createVoiceRouter } from "../modules/voice/index.js";
|
import { createVoiceRouter } from "../modules/voice/index.js";
|
||||||
@@ -69,6 +70,7 @@ export function createHttpApp(): Express {
|
|||||||
app.use("/api", createUiStateRouter());
|
app.use("/api", createUiStateRouter());
|
||||||
app.use("/api", createMediaRouter());
|
app.use("/api", createMediaRouter());
|
||||||
app.use("/api", createVoiceRouter());
|
app.use("/api", createVoiceRouter());
|
||||||
|
app.use("/api", createModerationRouter());
|
||||||
|
|
||||||
// 404 handler
|
// 404 handler
|
||||||
app.use((_req: Request, res: Response) => {
|
app.use((_req: Request, res: Response) => {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
notInArray,
|
notInArray,
|
||||||
or,
|
or,
|
||||||
type SQL,
|
type SQL,
|
||||||
|
sql,
|
||||||
} from "drizzle-orm";
|
} from "drizzle-orm";
|
||||||
import { config } from "../../shared/config/index.js";
|
import { config } from "../../shared/config/index.js";
|
||||||
import { getDatabase } from "../../shared/database/index.js";
|
import { getDatabase } from "../../shared/database/index.js";
|
||||||
@@ -114,6 +115,27 @@ export class MessagesRepository {
|
|||||||
return mapMessageRow(row as Record<string, unknown>);
|
return mapMessageRow(row as Record<string, unknown>);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<Array<{ old_content: string; edited_at: number }>> {
|
||||||
|
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<string, unknown>[]) || []).map((r) => ({
|
||||||
|
old_content: String(r.old_content ?? ""),
|
||||||
|
edited_at: Number(r.edited_at ?? 0),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
async findByChannel(
|
async findByChannel(
|
||||||
channelId: string,
|
channelId: string,
|
||||||
query: MessageQuery,
|
query: MessageQuery,
|
||||||
|
|||||||
@@ -34,7 +34,12 @@ export class MessagesService {
|
|||||||
throw new NotFoundError(`Message with ID ${id} not found`);
|
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) {
|
async getAttachmentsByChannel(channelId: string, query: MessageQuery) {
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
export { createModerationRouter } from "./moderation.routes.js";
|
||||||
@@ -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<string, unknown>[]) || [];
|
||||||
|
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<string, unknown>[]) || [];
|
||||||
|
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<string, unknown> | undefined;
|
||||||
|
const nextCursor =
|
||||||
|
rows.length > limit ? String(lastRow?.created_at ?? "") : null;
|
||||||
|
|
||||||
|
return { data, nextCursor };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const moderationRepository = new ModerationRepository();
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ModerationSection } from "@/components/moderation/moderation-section";
|
||||||
|
|
||||||
|
export default function ModerationPage() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4 animate-fade-in-up">
|
||||||
|
<ModerationSection />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { ArrowLeft, MessageSquare, MessagesSquare } from "lucide-react";
|
import { ArrowLeft, MessageSquare, MessagesSquare, Pencil } from "lucide-react";
|
||||||
import { GlassCard } from "@/components/glass/card";
|
import { GlassCard } from "@/components/glass/card";
|
||||||
import { getMessageChannelLabel, renderMessageContent } from "@/lib/format";
|
import { getMessageChannelLabel, renderMessageContent } from "@/lib/format";
|
||||||
import type { AttachmentRecord, MessageRecord } from "@/lib/types";
|
import type { AttachmentRecord, MessageRecord } from "@/lib/types";
|
||||||
@@ -52,6 +52,27 @@ export function MessageDetailView({
|
|||||||
) || "(no text content)"}
|
) || "(no text content)"}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Edit history */}
|
||||||
|
{message.edit_history && message.edit_history.length > 0 && (
|
||||||
|
<div className="mb-4 space-y-2 rounded-lg border border-border/40 bg-card/30 p-3">
|
||||||
|
<p className="text-[10px] font-semibold uppercase tracking-wide text-text-secondary/50 flex items-center gap-1">
|
||||||
|
<Pencil className="size-3" />
|
||||||
|
Riwayat edit · {message.edit_history.length} versi sebelumnya
|
||||||
|
</p>
|
||||||
|
{message.edit_history.map((edit, i) => (
|
||||||
|
<div key={`${edit.edited_at}-${i}`} className="space-y-0.5">
|
||||||
|
<p className="text-[10px] font-mono text-text-secondary/40">
|
||||||
|
{new Date(edit.edited_at).toLocaleString("id-ID")}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs leading-relaxed text-text-secondary/80 line-clamp-4 whitespace-pre-wrap">
|
||||||
|
{renderMessageContent(edit.old_content, message.metadata) ||
|
||||||
|
"(kosong)"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Attachments */}
|
{/* Attachments */}
|
||||||
{attachments && attachments.length > 0 && (
|
{attachments && attachments.length > 0 && (
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
|
|||||||
@@ -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<string>("");
|
||||||
|
const [actionType, setActionType] = useState<string>("");
|
||||||
|
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 (
|
||||||
|
<div className="space-y-4 animate-fade-in-up">
|
||||||
|
{/* Summary cards */}
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||||
|
<SummaryCard
|
||||||
|
label="Total aksi"
|
||||||
|
value={s.total}
|
||||||
|
color="text-text-primary"
|
||||||
|
/>
|
||||||
|
<SummaryCard
|
||||||
|
label="Executed"
|
||||||
|
value={s.executed}
|
||||||
|
color="text-green-500"
|
||||||
|
/>
|
||||||
|
<SummaryCard
|
||||||
|
label="Failed"
|
||||||
|
value={s.failed}
|
||||||
|
color="text-red-500"
|
||||||
|
hint={s.total > 0 ? `${s.failed_rate}%` : undefined}
|
||||||
|
/>
|
||||||
|
<SummaryCard label="Pending" value={s.pending} color="text-amber-500" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<div className="flex flex-wrap items-center gap-1.5">
|
||||||
|
<span className="text-[10px] font-semibold uppercase tracking-wide text-text-secondary/50">
|
||||||
|
Status
|
||||||
|
</span>
|
||||||
|
{statusFilters.map((f) => (
|
||||||
|
<FilterChip
|
||||||
|
key={f || "all"}
|
||||||
|
active={status === f}
|
||||||
|
label={
|
||||||
|
f === ""
|
||||||
|
? "Semua"
|
||||||
|
: STATUS_META[f as keyof typeof STATUS_META].label
|
||||||
|
}
|
||||||
|
onClick={() => setStatus(f)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<span className="ml-3 text-[10px] font-semibold uppercase tracking-wide text-text-secondary/50">
|
||||||
|
Tipe
|
||||||
|
</span>
|
||||||
|
{typeFilters.map((f) => (
|
||||||
|
<FilterChip
|
||||||
|
key={f || "all"}
|
||||||
|
active={actionType === f}
|
||||||
|
label={
|
||||||
|
f === "" ? "Semua" : ACTION_META[f as ModerationActionType].label
|
||||||
|
}
|
||||||
|
onClick={() => setActionType(f)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Timeline */}
|
||||||
|
{actionsLoading ? (
|
||||||
|
<LoadingSkeleton count={6} height="h-16" />
|
||||||
|
) : !actions || actions.length === 0 ? (
|
||||||
|
<GlassCard className="p-6">
|
||||||
|
<EmptyState
|
||||||
|
icon={ShieldAlert}
|
||||||
|
title="Belum ada aksi moderasi"
|
||||||
|
description="Aksi auto- moderasi (delete, warn, kick, ban) akan muncul di sini."
|
||||||
|
/>
|
||||||
|
</GlassCard>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{actions.map((a) => (
|
||||||
|
<ActionRow key={a.id} action={a} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className="text-[10px] text-text-secondary/40">
|
||||||
|
{actions?.length ?? 0} aksi ditampilkan · log moderasi gateway Discord
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SummaryCard({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
color,
|
||||||
|
hint,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
color: string;
|
||||||
|
hint?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<GlassCard className="p-4">
|
||||||
|
<p className="text-[10px] uppercase tracking-wide text-text-secondary/50">
|
||||||
|
{label}
|
||||||
|
</p>
|
||||||
|
<p className={cn("mt-1 text-2xl font-bold", color)}>
|
||||||
|
{value}
|
||||||
|
{hint && (
|
||||||
|
<span className="ml-1 text-xs font-medium opacity-80">({hint})</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</GlassCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FilterChip({
|
||||||
|
active,
|
||||||
|
label,
|
||||||
|
onClick,
|
||||||
|
}: {
|
||||||
|
active: boolean;
|
||||||
|
label: string;
|
||||||
|
onClick: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
className={cn(
|
||||||
|
"rounded-full px-3 py-1 text-[11px] transition-colors",
|
||||||
|
active
|
||||||
|
? "bg-primary/20 text-primary"
|
||||||
|
: "text-text-secondary/60 hover:text-text-primary glass",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<GlassCard className="flex items-start gap-3 p-3">
|
||||||
|
<span className={cn("mt-0.5 shrink-0", meta.className)}>
|
||||||
|
<Icon className="size-4" />
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<span className="text-xs font-semibold text-text-primary">
|
||||||
|
{meta.label}
|
||||||
|
</span>
|
||||||
|
{action.username && (
|
||||||
|
<span className="text-xs text-text-secondary">
|
||||||
|
@{action.username}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<Badge variant="outline" className={cn("text-[10px]", st.className)}>
|
||||||
|
<span
|
||||||
|
className={cn("mr-1 inline-block size-1.5 rounded-full", st.dot)}
|
||||||
|
/>
|
||||||
|
{st.label}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
{action.content && (
|
||||||
|
<p className="mt-1 line-clamp-2 text-xs text-text-secondary/80">
|
||||||
|
{renderMessageContent(action.content, null)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{action.reason && (
|
||||||
|
<p className="mt-1 text-[11px] text-text-secondary/60">
|
||||||
|
Alasan: {action.reason}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{action.error && (
|
||||||
|
<p className="mt-1 text-[11px] text-red-500/80 line-clamp-2">
|
||||||
|
Error: {action.error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<p className="mt-1.5 text-[10px] font-mono text-text-secondary/40">
|
||||||
|
dibuat {fmtTime(action.created_at)}
|
||||||
|
{action.executed_at
|
||||||
|
? ` · dieksekusi ${fmtTime(action.executed_at)}`
|
||||||
|
: ""}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{action.status === "executed" ? (
|
||||||
|
<CheckCircle2 className="mt-0.5 size-3.5 shrink-0 text-green-500" />
|
||||||
|
) : action.status === "failed" ? (
|
||||||
|
<XCircle className="mt-0.5 size-3.5 shrink-0 text-red-500" />
|
||||||
|
) : (
|
||||||
|
<Loader2 className="mt-0.5 size-3.5 shrink-0 animate-spin text-amber-500" />
|
||||||
|
)}
|
||||||
|
</GlassCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ModerationSection;
|
||||||
@@ -28,6 +28,10 @@ export {
|
|||||||
useReview,
|
useReview,
|
||||||
useTextChannels,
|
useTextChannels,
|
||||||
} from "./use-messages";
|
} from "./use-messages";
|
||||||
|
export {
|
||||||
|
useModerationActions,
|
||||||
|
useModerationStats,
|
||||||
|
} from "./use-moderation";
|
||||||
export {
|
export {
|
||||||
useDeleteRecording,
|
useDeleteRecording,
|
||||||
useRecordings,
|
useRecordings,
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import useSWR from "swr";
|
||||||
|
import { moderationApi } from "@/lib/api";
|
||||||
|
import type { ModerationStats } from "@/lib/types";
|
||||||
|
|
||||||
|
export function useModerationStats() {
|
||||||
|
return useSWR<ModerationStats>(["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 },
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ export { configApi } from "./config";
|
|||||||
export { dashboardApi } from "./dashboard";
|
export { dashboardApi } from "./dashboard";
|
||||||
export { mediaApi } from "./media";
|
export { mediaApi } from "./media";
|
||||||
export { messagesApi } from "./messages";
|
export { messagesApi } from "./messages";
|
||||||
|
export { moderationApi } from "./moderation";
|
||||||
export { recordingsApi } from "./recordings";
|
export { recordingsApi } from "./recordings";
|
||||||
export { uiStateApi } from "./ui-state";
|
export { uiStateApi } from "./ui-state";
|
||||||
export { voiceApi } from "./voice";
|
export { voiceApi } from "./voice";
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import type { ModerationStats, PaginatedModerationActions } from "@/lib/types";
|
||||||
|
import { api } from "./client";
|
||||||
|
|
||||||
|
export const moderationApi = {
|
||||||
|
getStats: () => api.get<ModerationStats>("/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<PaginatedModerationActions>(
|
||||||
|
`/api/moderation/actions${qs ? `?${qs}` : ""}`,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
Mic,
|
Mic,
|
||||||
Music,
|
Music,
|
||||||
Search,
|
Search,
|
||||||
|
Shield,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
export interface NavItem {
|
export interface NavItem {
|
||||||
@@ -50,6 +51,12 @@ export const navItems: NavItem[] = [
|
|||||||
icon: Headphones,
|
icon: Headphones,
|
||||||
matchPrefix: "/recordings",
|
matchPrefix: "/recordings",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
href: "/moderation",
|
||||||
|
label: "Moderation",
|
||||||
|
icon: Shield,
|
||||||
|
matchPrefix: "/moderation",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
href: "/analysis",
|
href: "/analysis",
|
||||||
label: "Search",
|
label: "Search",
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ export * from "./dashboard";
|
|||||||
export * from "./guild";
|
export * from "./guild";
|
||||||
export * from "./media";
|
export * from "./media";
|
||||||
export * from "./message";
|
export * from "./message";
|
||||||
|
export * from "./moderation";
|
||||||
export * from "./recording";
|
export * from "./recording";
|
||||||
export * from "./ui";
|
export * from "./ui";
|
||||||
export * from "./voice";
|
export * from "./voice";
|
||||||
|
|||||||
@@ -127,6 +127,10 @@ export interface MessageRecord {
|
|||||||
ai_recommended_action?: AiRecommendedAction | null;
|
ai_recommended_action?: AiRecommendedAction | null;
|
||||||
ai_error?: string | null;
|
ai_error?: string | null;
|
||||||
ai_analyzed_at?: number | 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 ──────────────────────────────────────────────
|
// ── Pagination ──────────────────────────────────────────────
|
||||||
|
|||||||
@@ -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<string, ModerationActionType>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaginatedModerationActions {
|
||||||
|
data: ModerationAction[];
|
||||||
|
nextCursor: string | null;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user