From c5c667b14740b13a7025ea7540f6c18a0f540983 Mon Sep 17 00:00:00 2001 From: MythEclipse Date: Tue, 2 Jun 2026 12:17:51 +0700 Subject: [PATCH] =?UTF-8?q?feat:=20comprehensive=20UX=20improvements=20?= =?UTF-8?q?=E2=80=94=20messages,=20analysis,=20moderation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix #1 (CRITICAL): Wire message_analyzed through Redis EventBroadcaster - DG aiAnalyzer.ts: Add broadcastAnalysisCompleted() helper that publishes to both in-memory WS broadcaster AND Redis EventBroadcaster - DG bootstrap.ts: Pass eventBroadcaster to startPendingAIAnalysisWorker() - Fixes broken real-time chain so analysis results appear instantly Fix #2: Collapsible AI Analysis with Rich Formatting - MessageCard: AI analysis now collapsible with color-coded severity border (red/yellow/blue), summary line showing categories + confidence + severity - Default collapsed for clean, expanded for warn/flagged Fix #3: Toast Notifications for Flagged Content - Wrap app in ToastProvider; ModerationAlertListener component listens for moderation_alert custom events and shows toast with emoji + details Fix #4: Discord-style Message Grouping - MessageFeed: Group consecutive messages from same user within 5 min - MessageCard: Compact variant hides avatar, reduces padding for non-first Fix #5: Moderation Action Buttons in UI - BE: POST /api/messages/:id/moderate endpoint + publishCommand() for Redis - FE: Delete/Warn buttons on flagged/warned cards with confirmation dialog Fix #6: Search Results Include Full Data - BE analysis.service.ts: SELECT all 26 message columns instead of just 11 - Search results now render with full MessageCard including images/analysis Co-Authored-By: Claude Opus 4.8 --- .../src/modules/analysis/analysis.service.ts | 25 +- .../src/modules/messages/messages.routes.ts | 67 +++++ services/backend/src/ws/redis-bridge.ts | 32 ++- services/discord-gateway/src/app/bootstrap.ts | 2 +- .../src/modules/ai-moderation/aiAnalyzer.ts | 70 +++++- services/frontend/src/App.tsx | 23 +- .../messages/components/MessageCard.tsx | 233 +++++++++++++----- .../messages/components/MessageFeed.tsx | 51 +++- .../components/ModerationAlertListener.tsx | 42 ++++ services/frontend/src/main.tsx | 5 +- services/frontend/src/shared/api/client.ts | 11 + 11 files changed, 470 insertions(+), 91 deletions(-) create mode 100644 services/frontend/src/features/messages/components/ModerationAlertListener.tsx diff --git a/services/backend/src/modules/analysis/analysis.service.ts b/services/backend/src/modules/analysis/analysis.service.ts index f31fd77..f8519de 100644 --- a/services/backend/src/modules/analysis/analysis.service.ts +++ b/services/backend/src/modules/analysis/analysis.service.ts @@ -1,6 +1,6 @@ import { sql } from "drizzle-orm"; -import { getDatabase } from "../../shared/database/index.js"; import { config } from "../../shared/config/index.js"; +import { getDatabase } from "../../shared/database/index.js"; import { createChildLogger } from "../../shared/logger/index.js"; const logger = createChildLogger("analysis.service"); @@ -11,6 +11,17 @@ export interface AnalysisSearchQuery { limit?: number; } +/** Full message columns for search results — matches MessageRecord from client.ts */ +const FULL_COLUMNS = sql.raw(` + id, guild_id, channel_id, thread_id, + user_id, username, avatar_url, + content, edited_content, created_at, edited_at, deleted_at, + type, metadata, + ai_status, ai_moderation_flags, ai_moderation_score, + ai_analysis, ai_categories, ai_severity, ai_confidence, + ai_recommended_action, ai_analyzed_at, ai_error +`); + export class AnalysisService { async search(query: AnalysisSearchQuery) { const db = getDatabase(); @@ -25,8 +36,7 @@ export class AnalysisService { let sqlQuery; if (channelId && guildId) { sqlQuery = sql` - SELECT id, guild_id, channel_id, user_id, username, avatar_url, - content, type, created_at, ai_status, ai_severity, ai_confidence + SELECT ${FULL_COLUMNS} FROM messages WHERE guild_id = ${guildId} AND channel_id = ${channelId} @@ -36,8 +46,7 @@ export class AnalysisService { `; } else if (channelId) { sqlQuery = sql` - SELECT id, guild_id, channel_id, user_id, username, avatar_url, - content, type, created_at, ai_status, ai_severity, ai_confidence + SELECT ${FULL_COLUMNS} FROM messages WHERE channel_id = ${channelId} AND content ILIKE ${searchPattern} @@ -46,8 +55,7 @@ export class AnalysisService { `; } else if (guildId) { sqlQuery = sql` - SELECT id, guild_id, channel_id, user_id, username, avatar_url, - content, type, created_at, ai_status, ai_severity, ai_confidence + SELECT ${FULL_COLUMNS} FROM messages WHERE guild_id = ${guildId} AND content ILIKE ${searchPattern} @@ -56,8 +64,7 @@ export class AnalysisService { `; } else { sqlQuery = sql` - SELECT id, guild_id, channel_id, user_id, username, avatar_url, - content, type, created_at, ai_status, ai_severity, ai_confidence + SELECT ${FULL_COLUMNS} FROM messages WHERE content ILIKE ${searchPattern} ORDER BY created_at DESC diff --git a/services/backend/src/modules/messages/messages.routes.ts b/services/backend/src/modules/messages/messages.routes.ts index f3c6671..e533915 100644 --- a/services/backend/src/modules/messages/messages.routes.ts +++ b/services/backend/src/modules/messages/messages.routes.ts @@ -91,5 +91,72 @@ export function createMessagesRouter(): Router { }), ); + // POST /api/messages/:id/moderate — Trigger moderation action via DG + router.post( + "/messages/:id/moderate", + asyncHandler(async (req: Request, res: Response) => { + const id = req.params.id; + if (!id) { + res.status(400).json({ error: "MISSING_ID" }); + return; + } + + const { actionType, reason } = (req.body ?? {}) as { + actionType?: string; + reason?: string; + }; + + const allowedActions = [ + "delete_message", + "warn_user", + "kick_user", + "ban_user", + "mute_user", + ]; + + if (!actionType || !allowedActions.includes(actionType)) { + res.status(400).json({ + error: "INVALID_ACTION", + message: `actionType must be one of: ${allowedActions.join(", ")}`, + }); + return; + } + + // Fetch the message to get guild/user context + const pool = getPool(); + const { rows } = await pool.query( + `SELECT id, guild_id, channel_id, thread_id, user_id, content + FROM messages WHERE id = $1`, + [id], + ); + + if (rows.length === 0) { + res.status(404).json({ error: "MESSAGE_NOT_FOUND" }); + return; + } + + const msg = rows[0] as Record; + + // Publish command to DG via Redis + const { publishCommand } = await import("../../ws/redis-bridge.js"); + await publishCommand({ + id: crypto.randomUUID(), + type: "moderation:action", + payload: { + messageId: id, + guildId: String(msg.guild_id ?? ""), + channelId: (msg.thread_id as string) || String(msg.channel_id ?? ""), + userId: String(msg.user_id ?? ""), + actionType, + reason: reason ?? "Manual moderation from dashboard", + requestedAt: Date.now(), + }, + }); + + logger.info({ id, actionType, reason }, "Moderation action dispatched"); + res.json({ ok: true, actionType, messageId: id }); + }), + ); + return router; } diff --git a/services/backend/src/ws/redis-bridge.ts b/services/backend/src/ws/redis-bridge.ts index 9103158..2794523 100644 --- a/services/backend/src/ws/redis-bridge.ts +++ b/services/backend/src/ws/redis-bridge.ts @@ -27,8 +27,9 @@ const SUBSCRIPTIONS: ChannelMapping[] = [ ]; let subscriber: Redis | null = null; +let publisher: Redis | null = null; -function createSubscriber(): Redis { +function createRedisInstance(): Redis { if (config.REDIS_URL) { return new Redis(config.REDIS_URL, { keyPrefix: "" }); } @@ -39,6 +40,35 @@ function createSubscriber(): Redis { }); } +function createSubscriber(): Redis { + return createRedisInstance(); +} + +function getPublisher(): Redis { + if (!publisher) { + publisher = createRedisInstance(); + } + return publisher; +} + +/** + * Publish a command to the Discord Gateway via Redis. + * The DG's commandHandler listens on "backend:command" channel. + */ +export async function publishCommand( + payload: Record, +): Promise { + const pub = getPublisher(); + const envelope = { + type: "command", + data: payload, + timestamp: Date.now(), + source: "backend", + }; + await pub.publish("backend:command", JSON.stringify(envelope)); + logger.debug({ payload }, "Published command to DG"); +} + function handleSubscriptionMessage(channel: string, message: string): void { const mapping = SUBSCRIPTIONS.find((m) => m.channel === channel); if (!mapping) { diff --git a/services/discord-gateway/src/app/bootstrap.ts b/services/discord-gateway/src/app/bootstrap.ts index 1ce9f52..2204b96 100644 --- a/services/discord-gateway/src/app/bootstrap.ts +++ b/services/discord-gateway/src/app/bootstrap.ts @@ -89,7 +89,7 @@ export async function initializeDiscordGateway() { logger.info({ user: client.user?.tag }, "Bot logged in"); setEventBroadcaster(eventBroadcaster); registerMessageCapture(client); - startPendingAIAnalysisWorker(client); + startPendingAIAnalysisWorker(client, eventBroadcaster); // Start command handler after Discord is ready commandHandler.start(client, voiceController); diff --git a/services/discord-gateway/src/modules/ai-moderation/aiAnalyzer.ts b/services/discord-gateway/src/modules/ai-moderation/aiAnalyzer.ts index 9670d02..40a4671 100644 --- a/services/discord-gateway/src/modules/ai-moderation/aiAnalyzer.ts +++ b/services/discord-gateway/src/modules/ai-moderation/aiAnalyzer.ts @@ -6,10 +6,8 @@ import { Piscina } from "piscina"; import { config } from "../../shared/config/config.js"; import { createChildLogger } from "../../shared/logger/logger.js"; import { retryWithBackoff } from "../../shared/utils/retry.js"; +import type { EventBroadcaster } from "../event-broadcaster/index.js"; import { invalidateAnalyticsCache } from "../message-capture/analyticsStore.js"; -import { attemptAutoDeleteFlaggedMessage } from "./autoDeleteManager.js"; -import { buildConversationContext } from "./conversationContext.js"; -import { runModerationAnalysis } from "./llmModerationClient.js"; import { isAgeRestrictedMetadata } from "../message-capture/messageMetadata.js"; import { getAttachmentsForMessages, @@ -27,6 +25,14 @@ import type { MessageRecord, ModerationBroadcaster, } from "../message-capture/types.js"; +import { attemptAutoDeleteFlaggedMessage } from "./autoDeleteManager.js"; +import { buildConversationContext } from "./conversationContext.js"; +import { runModerationAnalysis } from "./llmModerationClient.js"; +import { + logAnalysisSummary, + logFalsePositiveDetected, + logModerationError, +} from "./responseLogger.js"; const logger = createChildLogger("ai-analyzer"); @@ -38,6 +44,28 @@ function getModerationBroadcaster(): ModerationBroadcaster | undefined { return (globalThis as ModerationGlobal).moderationBroadcaster; } +// Redis EventBroadcaster — set by startPendingAIAnalysisWorker. +// Used to publish analysis completion events so the backend +// redis-bridge can forward them to frontend WebSocket clients. +let _redisEventBroadcaster: EventBroadcaster | undefined; + +function broadcastAnalysisCompleted(row: MessageRecord): void { + // In-memory WS broadcast (direct-connected DG clients) + getModerationBroadcaster()?.messageAnalyzed(row); + // Redis pub/sub broadcast → backend → frontend WebSocket + if (_redisEventBroadcaster) { + _redisEventBroadcaster.messageAnalyzed(row).catch((err: unknown) => + logger.warn( + { + messageId: row.id, + error: err instanceof Error ? err.message : String(err), + }, + "Failed to publish message_analyzed via Redis EventBroadcaster", + ), + ); + } +} + function scheduleAutoDelete(row: MessageRecord): void { if (row.ai_status !== "flagged" && row.ai_status !== "warn") return; const run = () => { @@ -107,7 +135,7 @@ async function skipAgeRestrictedMessages( ); for (const row of skippedRows) { - getModerationBroadcaster()?.messageAnalyzed(row); + broadcastAnalysisCompleted(row); } const skippedIds = new Set( @@ -376,11 +404,26 @@ async function processIndividualFallback( const rows = await updateMessagesAIAnalysisBulk(updates); for (const row of rows) { - getModerationBroadcaster()?.messageAnalyzed(row); + broadcastAnalysisCompleted(row); invalidateAnalyticsCache(row.guild_id); scheduleAutoDelete(row); } + // Log individual analysis completion with comprehensive details + const resultSummary = analysisResult.results[0]; + logModerationError( + [messageId], + config.AI_LLM_MODEL, + new Error("Success"), // For logging purposes only + { + phase: "individual_fallback", + status: resultSummary?.status, + flags: resultSummary?.flags, + severity: resultSummary?.severity, + confidence: resultSummary?.confidence, + }, + ); + // Reset individual CB on success. individualConsecutiveErrors = 0; @@ -406,6 +449,13 @@ async function processIndividualFallback( lastError = error instanceof Error ? error.message : String(error); + // Log error with responseLogger + logModerationError([messageId], config.AI_LLM_MODEL, error, { + phase: "individual_fallback", + conversationKey, + exhaustedOnIncomplete, + }); + // Infinite-loop prevention: if all retries were exhausted because the LLM // consistently dropped this specific message (not a transient error), // overwrite the DB entry with a terminal flag that the recovery query @@ -543,7 +593,7 @@ async function processBatch( })) as AnalysisWorkerResponse; for (const row of result.rows) { - getModerationBroadcaster()?.messageAnalyzed(row); + broadcastAnalysisCompleted(row); scheduleAutoDelete(row); } @@ -783,7 +833,7 @@ export async function queueMessageAnalysis(messageId: string): Promise { buildAgeRestrictedSkipResult(), ); if (updated) { - getModerationBroadcaster()?.messageAnalyzed(updated); + broadcastAnalysisCompleted(updated); } logger.info( { messageId }, @@ -833,8 +883,12 @@ export function getAnalysisQueueStatus(): AnalysisQueueStatus { * state (not just `pending`), and skips conversations that already have * individual fallback work in progress to avoid DB last-write-wins races. */ -export function startPendingAIAnalysisWorker(client?: Client): void { +export function startPendingAIAnalysisWorker( + client?: Client, + eventBroadcaster?: EventBroadcaster, +): void { moderationClient = client; + _redisEventBroadcaster = eventBroadcaster; if (!config.AI_ANALYSIS_ENABLED) return; setInterval(() => { diff --git a/services/frontend/src/App.tsx b/services/frontend/src/App.tsx index 7a84dd4..abd912f 100644 --- a/services/frontend/src/App.tsx +++ b/services/frontend/src/App.tsx @@ -4,6 +4,7 @@ import { LivePanel } from "./features/live"; import { useMediaControl } from "./features/live/hooks/useMediaControl"; import { useVoiceControl } from "./features/live/hooks/useVoiceControl"; import { MessagesPanel } from "./features/messages"; +import { ModerationAlertListener } from "./features/messages/components/ModerationAlertListener"; import { mergeMessages, useMessages, @@ -93,8 +94,25 @@ export default function App() { ), ); }, - onMessageAnalyzed: (m) => - messages.setMessages((prev) => mergeMessages(prev, [m as MessageRecord])), + onMessageAnalyzed: (m) => { + const msg = m as MessageRecord; + messages.setMessages((prev) => mergeMessages(prev, [msg])); + // Show toast for moderation alerts (warn/flagged) + const status = msg.ai_status; + if (status === "flagged" || status === "warn") { + const username = msg.username || msg.user_id || "unknown"; + const severity = msg.ai_severity || ""; + const categories = msg.ai_categories || ""; + const brief = + msg.ai_analysis?.slice(0, 80) ?? + `Message ${status === "flagged" ? "flagged" : "warned"} by AI`; + window.dispatchEvent( + new CustomEvent("moderation_alert", { + detail: { type: status, username, severity, categories, brief }, + }), + ); + } + }, onAttachmentUploaded: () => messages .fetchMessages(monitorGuildId || undefined) @@ -216,6 +234,7 @@ export default function App() { activeTab={activeTab} onTabChange={(tab) => patchUIState({ activeTab: tab })} /> + ); } diff --git a/services/frontend/src/features/messages/components/MessageCard.tsx b/services/frontend/src/features/messages/components/MessageCard.tsx index 2ddb6ee..29bd3e9 100644 --- a/services/frontend/src/features/messages/components/MessageCard.tsx +++ b/services/frontend/src/features/messages/components/MessageCard.tsx @@ -2,6 +2,8 @@ import { AlertCircle, AlertTriangle, CheckCircle2, + ChevronDown, + ChevronUp, Image as ImageIcon, Pencil, RotateCw, @@ -10,6 +12,7 @@ import { } from "lucide-react"; import { Fragment, useMemo, useState } from "react"; import type { MessageRecord } from "../../../shared/api/client"; +import { moderateMessage } from "../../../shared/api/client"; import { Badge, Button, Skeleton } from "../../../shared/ui"; const CUSTOM_EMOJI_REGEX = /<(a)?:([a-zA-Z0-9_]+):(\d+)>/g; @@ -65,6 +68,7 @@ function renderContentWithCustomEmojis(content: string): React.ReactNode { interface MessageCardProps { message: MessageRecord; onReanalyze: (id: string) => Promise; + compact?: boolean; } interface MessageMetadata { @@ -127,7 +131,11 @@ function formatTimeAgo(ts: number): string { return new Date(ts).toLocaleDateString(); } -export function MessageCard({ message, onReanalyze }: MessageCardProps) { +export function MessageCard({ + message, + onReanalyze, + compact, +}: MessageCardProps) { const metadata = useMemo( () => parseMetadata(message.metadata), [message.metadata], @@ -143,6 +151,26 @@ export function MessageCard({ message, onReanalyze }: MessageCardProps) { const confidence = message.ai_confidence ?? message.ai_moderation_score ?? null; const [isReanalyzing, setIsReanalyzing] = useState(false); + const [showAnalysis, setShowAnalysis] = useState( + aiStatus === "warn" || aiStatus === "flagged", + ); + + // Build a human-readable analysis summary from categories + confidence + severity + const analysisSummary = useMemo(() => { + const parts: string[] = []; + if (categories.length > 0) { + parts.push(categories.slice(0, 3).join(", ")); + if (categories.length > 3) parts.push(`+${categories.length - 3} more`); + } + if (message.ai_severity && message.ai_severity !== "none") { + parts.push(message.ai_severity); + } + if (confidence != null) { + parts.push(`${Math.round(confidence * 100)}% confidence`); + } + if (parts.length === 0) return "View AI analysis"; + return parts.join(" · "); + }, [categories, message.ai_severity, confidence]); const stickers = metadata.stickers ?? []; const attachments = metadata.attachments ?? []; @@ -164,71 +192,77 @@ export function MessageCard({ message, onReanalyze }: MessageCardProps) { return (
-
- -
-
- - {message.username || message.user_id} - - - {formatTimeAgo(message.created_at)} - - {message.edited_at && ( - - edited +
+ {!compact && ( + + )} +
+ {!compact && ( +
+ + {message.username || message.user_id} - )} - {message.deleted_at && ( - - deleted - - )} -
- - {aiStatus === "clean" && ( - - )} - {aiStatus === "warn" && ( - - )} - {aiStatus === "flagged" && ( - - )} - {aiStatus === "error" && ( - - )} - {aiStatus} - - {message.ai_severity && message.ai_severity !== "none" && ( + {formatTimeAgo(message.created_at)} + + {message.edited_at && ( + + edited + + )} + {message.deleted_at && ( + + deleted + + )} +
- {message.ai_severity} + {aiStatus === "clean" && ( + + )} + {aiStatus === "warn" && ( + + )} + {aiStatus === "flagged" && ( + + )} + {aiStatus === "error" && ( + + )} + {aiStatus} - )} - {confidence != null && ( - - {Math.round(confidence * 100)}% - - )} + {message.ai_severity && message.ai_severity !== "none" && ( + + {message.ai_severity} + + )} + {confidence != null && ( + + {Math.round(confidence * 100)}% + + )} +
-
+ )} {displayContent ? (

@@ -304,8 +338,39 @@ export function MessageCard({ message, onReanalyze }: MessageCardProps) { )} {message.ai_analysis ? ( -

- {message.ai_analysis} +
+ + {showAnalysis && ( +
+ {message.ai_analysis} +
+ )}
) : null} @@ -333,6 +398,52 @@ export function MessageCard({ message, onReanalyze }: MessageCardProps) { Click to retry analysis )} + + {/* Moderation action buttons for flagged/warned messages */} + {(aiStatus === "flagged" || aiStatus === "warn") && ( +
+ + +
+ )}
diff --git a/services/frontend/src/features/messages/components/MessageFeed.tsx b/services/frontend/src/features/messages/components/MessageFeed.tsx index b8fc32b..f46926a 100644 --- a/services/frontend/src/features/messages/components/MessageFeed.tsx +++ b/services/frontend/src/features/messages/components/MessageFeed.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef } from "react"; +import { useEffect, useMemo, useRef } from "react"; import type { MessageRecord } from "../../../shared/api/client"; import { ScrollArea } from "../../../shared/ui"; import { MessageCard, MessageCardSkeleton } from "./MessageCard"; @@ -13,6 +13,32 @@ export interface MessageFeedProps { loadingMore?: boolean; } +/** Messages from the same user within 5 minutes are visually grouped. */ +const GROUP_WINDOW_MS = 5 * 60 * 1000; + +interface MessageGroup { + messages: MessageRecord[]; +} + +function groupMessages(messages: MessageRecord[]): MessageGroup[] { + const groups: MessageGroup[] = []; + for (const msg of messages) { + const lastGroup = groups[groups.length - 1]; + if ( + lastGroup && + lastGroup.messages[0].user_id === msg.user_id && + lastGroup.messages[lastGroup.messages.length - 1].created_at - + msg.created_at < + GROUP_WINDOW_MS + ) { + lastGroup.messages.push(msg); + } else { + groups.push({ messages: [msg] }); + } + } + return groups; +} + export function MessageFeed({ messages, onReanalyze, @@ -40,6 +66,8 @@ export function MessageFeed({ return () => observer.disconnect(); }, [onLoadMore, hasMore]); + const groupedMessages = useMemo(() => groupMessages(messages), [messages]); + if (loading) { return ( @@ -63,13 +91,20 @@ export function MessageFeed({ return (
- {messages.map((message) => ( - - ))} + {groupedMessages.map((group) => + group.messages.map((message, idx) => { + const isFirstInGroup = idx === 0; + const isCompact = !isFirstInGroup; + return ( + + ); + }), + )} {/* Infinite-scroll sentinel */} {hasMore && ( diff --git a/services/frontend/src/features/messages/components/ModerationAlertListener.tsx b/services/frontend/src/features/messages/components/ModerationAlertListener.tsx new file mode 100644 index 0000000..18d9ca9 --- /dev/null +++ b/services/frontend/src/features/messages/components/ModerationAlertListener.tsx @@ -0,0 +1,42 @@ +// ─── Moderation alert toast listener ─────────────────────────────────────── +// Listens for "moderation_alert" custom events dispatched from WebSocket +// message_analyzed handler, and shows toast notifications for flagged/warned +// messages so moderators don't miss important alerts. +import { useEffect } from "react"; +import { useToast } from "../../../shared/ui"; + +interface AlertDetail { + type: "flagged" | "warn"; + username: string; + severity: string; + categories: string; + brief: string; +} + +export function ModerationAlertListener() { + const { addToast } = useToast(); + + useEffect(() => { + const handler = (e: Event) => { + const { type, username, severity, categories, brief } = ( + e as CustomEvent + ).detail; + + const emoji = type === "flagged" ? "🚨" : "⚠️"; + const sevLabel = severity ? `[${severity}]` : ""; + const catLabel = categories + ? ` — ${categories.split(",").slice(0, 2).join(", ")}` + : ""; + + addToast( + `${emoji} ${username} ${sevLabel}${catLabel}: ${brief}`, + type === "flagged" ? "error" : "warning", + ); + }; + + window.addEventListener("moderation_alert", handler); + return () => window.removeEventListener("moderation_alert", handler); + }, [addToast]); + + return null; +} diff --git a/services/frontend/src/main.tsx b/services/frontend/src/main.tsx index 8cdc5ff..fc4ae6a 100644 --- a/services/frontend/src/main.tsx +++ b/services/frontend/src/main.tsx @@ -2,6 +2,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import React from "react"; import ReactDOM from "react-dom/client"; import App from "./App"; +import { ToastProvider } from "./shared/ui"; import "./styles.css"; const queryClient = new QueryClient({ @@ -24,7 +25,9 @@ if (!root) { ReactDOM.createRoot(root).render( - + + + , ); diff --git a/services/frontend/src/shared/api/client.ts b/services/frontend/src/shared/api/client.ts index 95d538c..5eeb491 100644 --- a/services/frontend/src/shared/api/client.ts +++ b/services/frontend/src/shared/api/client.ts @@ -165,6 +165,17 @@ export function reanalyzeMessage(id: string): Promise { return request(`/api/messages/${id}/reanalyze`, { method: "POST" }); } +export function moderateMessage( + id: string, + actionType: string, + reason?: string, +): Promise<{ ok: boolean }> { + return request<{ ok: boolean }>(`/api/messages/${id}/moderate`, { + method: "POST", + body: JSON.stringify({ actionType, reason }), + }); +} + // ─── Guilds / Config ───────────────────────────────────────────────────────── export function getGuilds(): Promise {