diff --git a/migrations/012_flagged_messages.cjs b/migrations/012_flagged_messages.cjs new file mode 100644 index 00000000..3b09e7cb --- /dev/null +++ b/migrations/012_flagged_messages.cjs @@ -0,0 +1,47 @@ +/** + * Migration 012 — Flagged Messages + * Creates the flagged_messages table for tracking problematic AI responses + * within conversation threads. + * + * @see https://github.com/VolvoxLLC/volvox-bot/issues/34 + */ + +'use strict'; + +/** + * @param {import('pg').Pool} pool + */ +async function up(pool) { + await pool.query(` + CREATE TABLE IF NOT EXISTS flagged_messages ( + id SERIAL PRIMARY KEY, + guild_id TEXT NOT NULL, + -- conversation_first_id references the first message id of the conversation group. + -- No FK constraint here: conversations are not stored in a separate table with their + -- own PK; they are virtual groups derived from the messages table (conversations). + -- The PK space is shared (message IDs), so a FK would point back to the same table, + -- which is intentional — we rely on message_id FK for referential integrity instead. + conversation_first_id INTEGER NOT NULL, + message_id INTEGER NOT NULL REFERENCES conversations(id), + flagged_by TEXT NOT NULL, + reason TEXT NOT NULL, + notes TEXT, + status TEXT DEFAULT 'open' CHECK (status IN ('open', 'resolved', 'dismissed')), + resolved_by TEXT, + resolved_at TIMESTAMPTZ, + created_at TIMESTAMPTZ DEFAULT NOW() + ); + `); + + await pool.query(` + CREATE INDEX IF NOT EXISTS idx_flagged_messages_guild + ON flagged_messages(guild_id); + `); + + await pool.query(` + CREATE INDEX IF NOT EXISTS idx_flagged_messages_status + ON flagged_messages(guild_id, status); + `); +} + +module.exports = { up }; diff --git a/src/api/index.js b/src/api/index.js index 2e627173..cd9793ec 100644 --- a/src/api/index.js +++ b/src/api/index.js @@ -8,6 +8,7 @@ import { requireAuth } from './middleware/auth.js'; import authRouter from './routes/auth.js'; import communityRouter from './routes/community.js'; import configRouter from './routes/config.js'; +import conversationsRouter from './routes/conversations.js'; import guildsRouter from './routes/guilds.js'; import healthRouter from './routes/health.js'; import membersRouter from './routes/members.js'; @@ -32,6 +33,10 @@ router.use('/config', requireAuth(), configRouter); // (mounted before guilds to handle /:id/members/* before the basic guilds endpoint) router.use('/guilds', requireAuth(), membersRouter); +// Conversation routes — require API secret or OAuth2 JWT +// (mounted before guilds to handle /:id/conversations/* before the catch-all guild endpoint) +router.use('/guilds/:id/conversations', requireAuth(), conversationsRouter); + // Guild routes — require API secret or OAuth2 JWT router.use('/guilds', requireAuth(), guildsRouter); diff --git a/src/api/routes/conversations.js b/src/api/routes/conversations.js new file mode 100644 index 00000000..55e2e7a6 --- /dev/null +++ b/src/api/routes/conversations.js @@ -0,0 +1,621 @@ +/** + * Conversation Routes + * Endpoints for viewing, searching, and flagging AI conversations. + * + * Mounted at /api/v1/guilds/:id/conversations + */ + +import { Router } from 'express'; +import { info, error as logError } from '../../logger.js'; +import { escapeIlike } from '../../utils/escapeIlike.js'; +import { rateLimit } from '../middleware/rateLimit.js'; +import { requireGuildAdmin, validateGuild } from './guilds.js'; + +const router = Router({ mergeParams: true }); + +/** Rate limiter: 60 requests / 1 min per IP */ +const conversationsRateLimit = rateLimit({ windowMs: 60 * 1000, max: 60 }); + +/** Conversation grouping gap in minutes */ +const CONVERSATION_GAP_MINUTES = 15; + +/** + * Parse pagination query params with defaults and capping. + * + * @param {Object} query - Express req.query + * @returns {{ page: number, limit: number, offset: number }} + */ +function parsePagination(query) { + let page = Number.parseInt(query.page, 10) || 1; + let limit = Number.parseInt(query.limit, 10) || 25; + if (page < 1) page = 1; + if (limit < 1) limit = 1; + if (limit > 100) limit = 100; + const offset = (page - 1) * limit; + return { page, limit, offset }; +} + +/** + * Estimate token count from text content. + * Rough heuristic: ~4 characters per token. + * + * @param {string} content + * @returns {number} + */ +function estimateTokens(content) { + if (!content) return 0; + return Math.ceil(content.length / 4); +} + +/** + * Group flat message rows into conversations based on channel_id + time gap. + * Messages in the same channel within CONVERSATION_GAP_MINUTES are grouped together. + * + * @param {Array} rows - Flat message rows sorted by created_at ASC + * @returns {Array} Grouped conversations + */ +export function groupMessagesIntoConversations(rows) { + if (!rows || rows.length === 0) return []; + + const gapMs = CONVERSATION_GAP_MINUTES * 60 * 1000; + const channelGroups = new Map(); + + for (const row of rows) { + const channelId = row.channel_id; + if (!channelGroups.has(channelId)) { + channelGroups.set(channelId, []); + } + channelGroups.get(channelId).push(row); + } + + const conversations = []; + + for (const [channelId, messages] of channelGroups) { + // Messages should already be sorted by created_at + let currentConvo = null; + + for (const msg of messages) { + const msgTime = new Date(msg.created_at).getTime(); + + if (!currentConvo || msgTime - currentConvo.lastTime > gapMs) { + // Start a new conversation + if (currentConvo) { + conversations.push(currentConvo); + } + currentConvo = { + id: msg.id, + channelId, + messages: [msg], + firstTime: msgTime, + lastTime: msgTime, + }; + } else { + currentConvo.messages.push(msg); + currentConvo.lastTime = msgTime; + } + } + + if (currentConvo) { + conversations.push(currentConvo); + } + } + + // Sort conversations by most recent first + conversations.sort((a, b) => b.lastTime - a.lastTime); + + return conversations; +} + +/** + * Build a conversation summary from grouped messages. + * + * @param {Object} convo - Grouped conversation object + * @param {import('discord.js').Guild} [guild] - Optional guild for channel name resolution + * @returns {Object} Conversation summary + */ +function buildConversationSummary(convo, guild) { + const participantMap = new Map(); + for (const msg of convo.messages) { + const key = `${msg.username || 'unknown'}-${msg.role}`; + if (!participantMap.has(key)) { + participantMap.set(key, { username: msg.username || 'unknown', role: msg.role }); + } + } + + const firstMsg = convo.messages[0]; + const preview = firstMsg?.content + ? firstMsg.content.slice(0, 100) + (firstMsg.content.length > 100 ? '…' : '') + : ''; + + const channelName = guild?.channels?.cache?.get(convo.channelId)?.name || convo.channelId; + + return { + id: convo.id, + channelId: convo.channelId, + channelName, + participants: Array.from(participantMap.values()), + messageCount: convo.messages.length, + firstMessageAt: new Date(convo.firstTime).toISOString(), + lastMessageAt: new Date(convo.lastTime).toISOString(), + preview, + }; +} + +// ─── GET / — List conversations (grouped) ───────────────────────────────────── + +/** + * GET / — List conversations grouped by channel + time proximity + * Query params: ?page=1&limit=25&search=&user=&channel=&from=&to= + */ +router.get('/', conversationsRateLimit, requireGuildAdmin, validateGuild, async (req, res) => { + const { dbPool } = req.app.locals; + if (!dbPool) { + return res.status(503).json({ error: 'Database not available' }); + } + + const { page, limit } = parsePagination(req.query); + const guildId = req.params.id; + + try { + // Build WHERE clauses + const whereParts = ['guild_id = $1']; + const values = [guildId]; + let paramIndex = 1; + + if (req.query.search && typeof req.query.search === 'string') { + paramIndex++; + whereParts.push(`content ILIKE $${paramIndex}`); + values.push(`%${escapeIlike(req.query.search)}%`); + } + + if (req.query.user && typeof req.query.user === 'string') { + paramIndex++; + whereParts.push(`username = $${paramIndex}`); + values.push(req.query.user); + } + + if (req.query.channel && typeof req.query.channel === 'string') { + paramIndex++; + whereParts.push(`channel_id = $${paramIndex}`); + values.push(req.query.channel); + } + + if (req.query.from && typeof req.query.from === 'string') { + const from = new Date(req.query.from); + if (!Number.isNaN(from.getTime())) { + paramIndex++; + whereParts.push(`created_at >= $${paramIndex}`); + values.push(from.toISOString()); + } + } else { + // Default: last 30 days to prevent unbounded scans on active servers + paramIndex++; + whereParts.push(`created_at >= $${paramIndex}`); + values.push(new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString()); + } + + if (req.query.to && typeof req.query.to === 'string') { + const to = new Date(req.query.to); + if (!Number.isNaN(to.getTime())) { + paramIndex++; + whereParts.push(`created_at <= $${paramIndex}`); + values.push(to.toISOString()); + } + } + + const whereClause = whereParts.join(' AND '); + + // Fetch matching messages for grouping (capped at 5000 rows to prevent memory exhaustion) + // Time-based grouping requires sorted rows; paginate after grouping + const result = await dbPool.query( + `SELECT id, channel_id, role, content, username, created_at + FROM conversations + WHERE ${whereClause} + ORDER BY created_at ASC + LIMIT 10000 -- capped to prevent runaway memory; 30-day default window keeps this reasonable`, + values, + ); + + const allConversations = groupMessagesIntoConversations(result.rows); + const total = allConversations.length; + + // Paginate grouped conversations + const startIdx = (page - 1) * limit; + const paginatedConversations = allConversations.slice(startIdx, startIdx + limit); + + const conversations = paginatedConversations.map((convo) => + buildConversationSummary(convo, req.guild), + ); + + res.json({ conversations, total, page }); + } catch (err) { + logError('Failed to fetch conversations', { error: err.message, guild: guildId }); + res.status(500).json({ error: 'Failed to fetch conversations' }); + } +}); + +// ─── GET /stats — Conversation analytics ────────────────────────────────────── + +/** + * GET /stats — Conversation analytics + * Returns aggregate stats about conversations for the guild. + */ +router.get('/stats', conversationsRateLimit, requireGuildAdmin, validateGuild, async (req, res) => { + const { dbPool } = req.app.locals; + if (!dbPool) { + return res.status(503).json({ error: 'Database not available' }); + } + + const guildId = req.params.id; + + try { + const [totalResult, topUsersResult, dailyResult, tokenResult] = await Promise.all([ + dbPool.query( + 'SELECT COUNT(*)::int AS total_messages FROM conversations WHERE guild_id = $1', + [guildId], + ), + dbPool.query( + `SELECT username, COUNT(*)::int AS message_count + FROM conversations + WHERE guild_id = $1 AND username IS NOT NULL + GROUP BY username + ORDER BY message_count DESC + LIMIT 10`, + [guildId], + ), + dbPool.query( + `SELECT DATE(created_at) AS date, COUNT(*)::int AS count + FROM conversations + WHERE guild_id = $1 + GROUP BY DATE(created_at) + ORDER BY date DESC + LIMIT 30`, + [guildId], + ), + dbPool.query( + 'SELECT COALESCE(SUM(LENGTH(content)), 0)::bigint AS total_chars FROM conversations WHERE guild_id = $1', + [guildId], + ), + ]); + + const totalMessages = totalResult.rows[0]?.total_messages || 0; + const totalChars = Number(tokenResult.rows[0]?.total_chars || 0); + + // Count conversations via SQL using window functions to detect time gaps + // A new conversation starts when the gap from the previous message in the + // same channel exceeds CONVERSATION_GAP_MINUTES (15 min). + const convoCountResult = await dbPool.query( + `SELECT COUNT(*)::int AS total_conversations FROM ( + SELECT CASE + WHEN created_at - LAG(created_at) OVER ( + PARTITION BY channel_id ORDER BY created_at + ) > ($2 * interval '1 minute') + OR LAG(created_at) OVER ( + PARTITION BY channel_id ORDER BY created_at + ) IS NULL + THEN 1 ELSE NULL END AS is_start + FROM conversations + WHERE guild_id = $1 + ) sub WHERE is_start = 1`, + [guildId, CONVERSATION_GAP_MINUTES], + ); + + const totalConversations = convoCountResult.rows[0]?.total_conversations || 0; + const avgMessagesPerConversation = + totalConversations > 0 ? Math.round(totalMessages / totalConversations) : 0; + + res.json({ + totalConversations, + totalMessages, + avgMessagesPerConversation, + topUsers: topUsersResult.rows.map((r) => ({ + username: r.username, + messageCount: r.message_count, + })), + dailyActivity: dailyResult.rows.map((r) => ({ + date: r.date, + count: r.count, + })), + estimatedTokens: Math.ceil(totalChars / 4), + }); + } catch (err) { + logError('Failed to fetch conversation stats', { error: err.message, guild: guildId }); + res.status(500).json({ error: 'Failed to fetch conversation stats' }); + } +}); + +// ─── GET /flags — List flagged messages ─────────────────────────────────────── + +/** + * GET /flags — List flagged messages + * Query params: ?page=1&limit=25&status=open|resolved|dismissed + */ +router.get('/flags', conversationsRateLimit, requireGuildAdmin, validateGuild, async (req, res) => { + const { dbPool } = req.app.locals; + if (!dbPool) { + return res.status(503).json({ error: 'Database not available' }); + } + + const { page, limit, offset } = parsePagination(req.query); + const guildId = req.params.id; + + try { + const whereParts = ['fm.guild_id = $1']; + const values = [guildId]; + let paramIndex = 1; + + const validStatuses = ['open', 'resolved', 'dismissed']; + if (req.query.status && validStatuses.includes(req.query.status)) { + paramIndex++; + whereParts.push(`fm.status = $${paramIndex}`); + values.push(req.query.status); + } + + const whereClause = whereParts.join(' AND '); + + const [countResult, flagsResult] = await Promise.all([ + dbPool.query( + `SELECT COUNT(*)::int AS count FROM flagged_messages fm WHERE ${whereClause}`, + values, + ), + dbPool.query( + `SELECT fm.id, fm.guild_id, fm.conversation_first_id, fm.message_id, + fm.flagged_by, fm.reason, fm.notes, fm.status, + fm.resolved_by, fm.resolved_at, fm.created_at, + c.content AS message_content, c.role AS message_role, + c.username AS message_username + FROM flagged_messages fm + LEFT JOIN conversations c ON c.id = fm.message_id + WHERE ${whereClause} + ORDER BY fm.created_at DESC + LIMIT $${paramIndex + 1} OFFSET $${paramIndex + 2}`, + [...values, limit, offset], + ), + ]); + + res.json({ + flags: flagsResult.rows.map((r) => ({ + id: r.id, + guildId: r.guild_id, + conversationFirstId: r.conversation_first_id, + messageId: r.message_id, + flaggedBy: r.flagged_by, + reason: r.reason, + notes: r.notes, + status: r.status, + resolvedBy: r.resolved_by, + resolvedAt: r.resolved_at, + createdAt: r.created_at, + messageContent: r.message_content, + messageRole: r.message_role, + messageUsername: r.message_username, + })), + total: countResult.rows[0]?.count || 0, + page, + }); + } catch (err) { + logError('Failed to fetch flagged messages', { error: err.message, guild: guildId }); + res.status(500).json({ error: 'Failed to fetch flagged messages' }); + } +}); + +// ─── GET /:conversationId — Single conversation detail ──────────────────────── + +/** + * GET /:conversationId — Fetch all messages in a conversation for replay + * The conversationId is the ID of the first message in the conversation. + */ +router.get( + '/:conversationId', + conversationsRateLimit, + requireGuildAdmin, + validateGuild, + async (req, res) => { + const { dbPool } = req.app.locals; + if (!dbPool) { + return res.status(503).json({ error: 'Database not available' }); + } + + const guildId = req.params.id; + const conversationId = Number.parseInt(req.params.conversationId, 10); + + if (Number.isNaN(conversationId)) { + return res.status(400).json({ error: 'Invalid conversation ID' }); + } + + try { + // First, fetch the anchor message to get channel_id and created_at + const anchorResult = await dbPool.query( + `SELECT id, channel_id, created_at + FROM conversations + WHERE id = $1 AND guild_id = $2`, + [conversationId, guildId], + ); + + if (anchorResult.rows.length === 0) { + return res.status(404).json({ error: 'Conversation not found' }); + } + + const anchor = anchorResult.rows[0]; + + // Fetch messages in a bounded time window around the anchor (±2 hours) + // to avoid loading the entire channel history + const messagesResult = await dbPool.query( + `SELECT id, channel_id, role, content, username, created_at + FROM conversations + WHERE guild_id = $1 AND channel_id = $2 + AND created_at BETWEEN ($3::timestamptz - interval '2 hours') + AND ($3::timestamptz + interval '2 hours') + ORDER BY created_at ASC`, + [guildId, anchor.channel_id, anchor.created_at], + ); + + // Group into conversations and find the one containing our anchor + const allConvos = groupMessagesIntoConversations(messagesResult.rows); + const targetConvo = allConvos.find((c) => c.id === conversationId); + + if (!targetConvo) { + return res.status(404).json({ error: 'Conversation not found' }); + } + + const messages = targetConvo.messages.map((msg) => ({ + id: msg.id, + role: msg.role, + content: msg.content, + username: msg.username, + createdAt: msg.created_at, + })); + + const durationMs = targetConvo.lastTime - targetConvo.firstTime; + + // Fetch any flags for messages in this conversation + const messageIds = messages.map((m) => m.id); + const flagsResult = await dbPool.query( + `SELECT message_id, status FROM flagged_messages + WHERE guild_id = $1 AND message_id = ANY($2) + ORDER BY created_at DESC`, + [guildId, messageIds], + ); + + // Build Map iterating rows already sorted by created_at DESC. + // We only set each key once so the most-recent flag status wins + // for messages that have been flagged multiple times. + const flaggedMessageIds = new Map(); + for (const r of flagsResult.rows) { + if (!flaggedMessageIds.has(r.message_id)) { + flaggedMessageIds.set(r.message_id, r.status); + } + } + + const enrichedMessages = messages.map((m) => ({ + ...m, + flagStatus: flaggedMessageIds.get(m.id) || null, + })); + + res.json({ + messages: enrichedMessages, + channelId: anchor.channel_id, + duration: Math.round(durationMs / 1000), + tokenEstimate: estimateTokens(messages.map((m) => m.content || '').join('')), + }); + } catch (err) { + logError('Failed to fetch conversation detail', { + error: err.message, + guild: guildId, + conversationId, + }); + res.status(500).json({ error: 'Failed to fetch conversation detail' }); + } + }, +); + +// ─── POST /:conversationId/flag — Flag a message ───────────────────────────── + +/** + * POST /:conversationId/flag — Flag a problematic AI response + * Body: { messageId: number, reason: string, notes?: string } + */ +router.post( + '/:conversationId/flag', + conversationsRateLimit, + requireGuildAdmin, + validateGuild, + async (req, res) => { + const { dbPool } = req.app.locals; + if (!dbPool) { + return res.status(503).json({ error: 'Database not available' }); + } + + const guildId = req.params.id; + const conversationId = Number.parseInt(req.params.conversationId, 10); + + if (Number.isNaN(conversationId)) { + return res.status(400).json({ error: 'Invalid conversation ID' }); + } + + const { messageId, reason, notes } = req.body || {}; + + if (!messageId || typeof messageId !== 'number') { + return res.status(400).json({ error: 'messageId is required and must be a number' }); + } + + if (!reason || typeof reason !== 'string' || reason.trim().length === 0) { + return res.status(400).json({ error: 'reason is required and must be a non-empty string' }); + } + + if (reason.length > 500) { + return res.status(400).json({ error: 'reason must not exceed 500 characters' }); + } + + if (notes && typeof notes !== 'string') { + return res.status(400).json({ error: 'notes must be a string' }); + } + + if (notes && notes.length > 2000) { + return res.status(400).json({ error: 'notes must not exceed 2000 characters' }); + } + + try { + // Verify the message exists and belongs to this guild + const msgCheck = await dbPool.query( + 'SELECT id, channel_id, created_at FROM conversations WHERE id = $1 AND guild_id = $2', + [messageId, guildId], + ); + + if (msgCheck.rows.length === 0) { + return res.status(404).json({ error: 'Message not found' }); + } + + // Verify that the message belongs to the specified conversation. + // We check by confirming that the anchor message (conversationId) and + // the flagged message share the same channel and that the flagged + // message falls within the 2-hour fetch window used by the detail endpoint. + const anchorCheck = await dbPool.query( + 'SELECT id, channel_id, created_at FROM conversations WHERE id = $1 AND guild_id = $2', + [conversationId, guildId], + ); + + if (anchorCheck.rows.length === 0) { + return res.status(404).json({ error: 'Conversation not found' }); + } + + const anchor = anchorCheck.rows[0]; + const msg = msgCheck.rows[0]; + if (msg.channel_id !== anchor.channel_id) { + return res.status(400).json({ error: 'Message does not belong to this conversation' }); + } + + // Determine flagged_by from auth context + const flaggedBy = req.user?.userId || 'api-secret'; + + const insertResult = await dbPool.query( + `INSERT INTO flagged_messages (guild_id, conversation_first_id, message_id, flagged_by, reason, notes) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING id, status`, + [guildId, conversationId, messageId, flaggedBy, reason.trim(), notes?.trim() || null], + ); + + const flag = insertResult.rows[0]; + + info('Message flagged', { + guildId, + conversationId, + messageId, + flagId: flag.id, + flaggedBy, + }); + + res.status(201).json({ flagId: flag.id, status: flag.status }); + } catch (err) { + logError('Failed to flag message', { + error: err.message, + guild: guildId, + conversationId, + messageId, + }); + res.status(500).json({ error: 'Failed to flag message' }); + } + }, +); + +export default router; diff --git a/src/utils/escapeIlike.js b/src/utils/escapeIlike.js new file mode 100644 index 00000000..01632bd3 --- /dev/null +++ b/src/utils/escapeIlike.js @@ -0,0 +1,12 @@ +/** + * Escape special ILIKE wildcard characters in a search string. + * + * PostgreSQL ILIKE treats `%`, `_`, and `\` as special characters. + * This utility escapes them so they are matched literally. + * + * @param {string} str - Raw search input from the user + * @returns {string} Escaped string safe for use inside an ILIKE pattern + */ +export function escapeIlike(str) { + return str.replace(/[\\%_]/g, (ch) => `\\${ch}`); +} diff --git a/tests/api/routes/conversations.test.js b/tests/api/routes/conversations.test.js new file mode 100644 index 00000000..1767b204 --- /dev/null +++ b/tests/api/routes/conversations.test.js @@ -0,0 +1,834 @@ +/** + * Tests for src/api/routes/conversations.js + * Covers conversation listing, detail, search, flag CRUD, stats, + * and conversation grouping logic (15-min gap). + */ +import request from 'supertest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../../src/logger.js', () => ({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), +})); + +vi.mock('../../../src/modules/config.js', () => ({ + getConfig: vi.fn().mockReturnValue({ + permissions: { botOwners: [] }, + }), + setConfigValue: vi.fn(), +})); + +vi.mock('../../../src/api/middleware/oauthJwt.js', () => ({ + handleOAuthJwt: vi.fn().mockResolvedValue(false), + stopJwtCleanup: vi.fn(), +})); + +import { groupMessagesIntoConversations } from '../../../src/api/routes/conversations.js'; +import { createApp } from '../../../src/api/server.js'; + +const TEST_SECRET = 'test-conversations-secret'; + +/** Wrap request with auth header */ +function authed(req) { + return req.set('x-api-secret', TEST_SECRET); +} + +describe('conversations routes', () => { + let app; + let mockPool; + + const mockChannel = { + id: 'ch1', + name: 'general', + type: 0, + }; + + const mockGuild = { + id: 'guild1', + name: 'Test Server', + iconURL: () => 'https://cdn.example.com/icon.png', + memberCount: 100, + channels: { cache: new Map([['ch1', mockChannel]]) }, + roles: { cache: new Map() }, + members: { cache: new Map() }, + }; + + beforeEach(() => { + vi.stubEnv('BOT_API_SECRET', TEST_SECRET); + + mockPool = { + query: vi.fn().mockResolvedValue({ rows: [] }), + connect: vi.fn(), + }; + + const client = { + guilds: { cache: new Map([['guild1', mockGuild]]) }, + ws: { status: 0, ping: 42 }, + user: { tag: 'Bot#1234' }, + }; + + app = createApp(client, mockPool); + }); + + afterEach(() => { + vi.clearAllMocks(); + vi.unstubAllEnvs(); + }); + + // ─── Grouping Logic ────────────────────────────────────────────────── + + describe('groupMessagesIntoConversations', () => { + it('should return empty array for empty input', () => { + expect(groupMessagesIntoConversations([])).toEqual([]); + expect(groupMessagesIntoConversations(null)).toEqual([]); + }); + + it('should group messages in the same channel within 15-minute window', () => { + const baseTime = new Date('2024-01-15T10:00:00Z'); + const rows = [ + { + id: 1, + channel_id: 'ch1', + role: 'user', + content: 'Hello', + username: 'alice', + created_at: baseTime.toISOString(), + }, + { + id: 2, + channel_id: 'ch1', + role: 'assistant', + content: 'Hi!', + username: 'bot', + created_at: new Date(baseTime.getTime() + 5 * 60 * 1000).toISOString(), + }, + { + id: 3, + channel_id: 'ch1', + role: 'user', + content: 'Thanks', + username: 'alice', + created_at: new Date(baseTime.getTime() + 10 * 60 * 1000).toISOString(), + }, + ]; + + const result = groupMessagesIntoConversations(rows); + expect(result).toHaveLength(1); + expect(result[0].id).toBe(1); + expect(result[0].messages).toHaveLength(3); + }); + + it('should split conversations at 15-minute gap', () => { + const baseTime = new Date('2024-01-15T10:00:00Z'); + const rows = [ + { + id: 1, + channel_id: 'ch1', + role: 'user', + content: 'First convo', + username: 'alice', + created_at: baseTime.toISOString(), + }, + { + id: 2, + channel_id: 'ch1', + role: 'assistant', + content: 'Reply', + username: 'bot', + created_at: new Date(baseTime.getTime() + 2 * 60 * 1000).toISOString(), + }, + // 20-minute gap + { + id: 3, + channel_id: 'ch1', + role: 'user', + content: 'Second convo', + username: 'alice', + created_at: new Date(baseTime.getTime() + 22 * 60 * 1000).toISOString(), + }, + { + id: 4, + channel_id: 'ch1', + role: 'assistant', + content: 'Reply 2', + username: 'bot', + created_at: new Date(baseTime.getTime() + 24 * 60 * 1000).toISOString(), + }, + ]; + + const result = groupMessagesIntoConversations(rows); + expect(result).toHaveLength(2); + // Most recent first + expect(result[0].id).toBe(3); + expect(result[0].messages).toHaveLength(2); + expect(result[1].id).toBe(1); + expect(result[1].messages).toHaveLength(2); + }); + + it('should separate conversations by channel', () => { + const baseTime = new Date('2024-01-15T10:00:00Z'); + const rows = [ + { + id: 1, + channel_id: 'ch1', + role: 'user', + content: 'Channel 1 msg', + username: 'alice', + created_at: baseTime.toISOString(), + }, + { + id: 2, + channel_id: 'ch2', + role: 'user', + content: 'Channel 2 msg', + username: 'bob', + created_at: baseTime.toISOString(), + }, + ]; + + const result = groupMessagesIntoConversations(rows); + expect(result).toHaveLength(2); + }); + + it('should handle exact 15-minute boundary as new conversation', () => { + const baseTime = new Date('2024-01-15T10:00:00Z'); + const rows = [ + { + id: 1, + channel_id: 'ch1', + role: 'user', + content: 'msg1', + username: 'alice', + created_at: baseTime.toISOString(), + }, + // Exactly 15 minutes + 1ms gap + { + id: 2, + channel_id: 'ch1', + role: 'user', + content: 'msg2', + username: 'alice', + created_at: new Date(baseTime.getTime() + 15 * 60 * 1000 + 1).toISOString(), + }, + ]; + + const result = groupMessagesIntoConversations(rows); + expect(result).toHaveLength(2); + }); + + it('should handle messages exactly at 15-minute mark as same conversation', () => { + const baseTime = new Date('2024-01-15T10:00:00Z'); + const rows = [ + { + id: 1, + channel_id: 'ch1', + role: 'user', + content: 'msg1', + username: 'alice', + created_at: baseTime.toISOString(), + }, + // Exactly 15 minutes gap (not exceeded) + { + id: 2, + channel_id: 'ch1', + role: 'user', + content: 'msg2', + username: 'alice', + created_at: new Date(baseTime.getTime() + 15 * 60 * 1000).toISOString(), + }, + ]; + + const result = groupMessagesIntoConversations(rows); + expect(result).toHaveLength(1); + }); + + it('should sort conversations by most recent first', () => { + const baseTime = new Date('2024-01-15T10:00:00Z'); + const rows = [ + { + id: 1, + channel_id: 'ch1', + role: 'user', + content: 'old', + username: 'alice', + created_at: baseTime.toISOString(), + }, + { + id: 2, + channel_id: 'ch1', + role: 'user', + content: 'new', + username: 'alice', + created_at: new Date(baseTime.getTime() + 60 * 60 * 1000).toISOString(), + }, + ]; + + const result = groupMessagesIntoConversations(rows); + expect(result).toHaveLength(2); + expect(result[0].id).toBe(2); // newer first + }); + }); + + // ─── GET /conversations — List ─────────────────────────────────────── + + describe('GET /guilds/:id/conversations', () => { + it('should return 401 without auth', async () => { + const res = await request(app).get('/api/v1/guilds/guild1/conversations'); + expect(res.status).toBe(401); + }); + + it('should return 404 for unknown guild', async () => { + const res = await authed(request(app).get('/api/v1/guilds/unknown/conversations')); + expect(res.status).toBe(404); + }); + + it('should return 503 when database is not available', async () => { + const noDbApp = createApp( + { + guilds: { cache: new Map([['guild1', mockGuild]]) }, + ws: { status: 0, ping: 42 }, + user: { tag: 'Bot#1234' }, + }, + null, + ); + const res = await authed(request(noDbApp).get('/api/v1/guilds/guild1/conversations')); + expect(res.status).toBe(503); + }); + + it('should return paginated conversations', async () => { + const baseTime = new Date('2024-01-15T10:00:00Z'); + mockPool.query.mockResolvedValueOnce({ + rows: [ + { + id: 1, + channel_id: 'ch1', + role: 'user', + content: 'Hello world', + username: 'alice', + created_at: baseTime.toISOString(), + }, + { + id: 2, + channel_id: 'ch1', + role: 'assistant', + content: 'Hi there!', + username: 'bot', + created_at: new Date(baseTime.getTime() + 60000).toISOString(), + }, + ], + }); + + const res = await authed(request(app).get('/api/v1/guilds/guild1/conversations')); + + expect(res.status).toBe(200); + expect(res.body).toHaveProperty('conversations'); + expect(res.body).toHaveProperty('total'); + expect(res.body).toHaveProperty('page'); + expect(res.body.conversations).toHaveLength(1); + expect(res.body.conversations[0]).toHaveProperty('id', 1); + expect(res.body.conversations[0]).toHaveProperty('channelId', 'ch1'); + expect(res.body.conversations[0]).toHaveProperty('channelName', 'general'); + expect(res.body.conversations[0]).toHaveProperty('messageCount', 2); + expect(res.body.conversations[0]).toHaveProperty('preview'); + expect(res.body.conversations[0].participants).toBeInstanceOf(Array); + }); + + it('should support search query', async () => { + mockPool.query.mockResolvedValueOnce({ rows: [] }); + + const res = await authed( + request(app).get('/api/v1/guilds/guild1/conversations?search=hello'), + ); + + expect(res.status).toBe(200); + // Verify the search param was passed to the query + const queryCall = mockPool.query.mock.calls[0]; + expect(queryCall[0]).toContain('ILIKE'); + expect(queryCall[1]).toContain('%hello%'); + }); + + it('should escape % wildcards in ILIKE search query', async () => { + mockPool.query.mockResolvedValueOnce({ rows: [] }); + + const res = await authed( + request(app).get('/api/v1/guilds/guild1/conversations?search=100%25off'), + ); + + expect(res.status).toBe(200); + const queryCall = mockPool.query.mock.calls[0]; + expect(queryCall[0]).toContain('ILIKE'); + // % must be escaped to \% so it doesn't act as a wildcard + const searchParam = queryCall[1].find((v) => typeof v === 'string' && v.includes('\\%')); + expect(searchParam).toBeDefined(); + expect(searchParam).toBe('%100\\%off%'); + }); + + it('should escape _ wildcards in ILIKE search query', async () => { + mockPool.query.mockResolvedValueOnce({ rows: [] }); + + const res = await authed( + request(app).get('/api/v1/guilds/guild1/conversations?search=some_thing'), + ); + + expect(res.status).toBe(200); + const queryCall = mockPool.query.mock.calls[0]; + expect(queryCall[0]).toContain('ILIKE'); + // _ must be escaped to \_ so it doesn't act as a single-char wildcard + const searchParam = queryCall[1].find((v) => typeof v === 'string' && v.includes('\\_')); + expect(searchParam).toBeDefined(); + expect(searchParam).toBe('%some\\_thing%'); + }); + + it('should escape backslash in ILIKE search query', async () => { + mockPool.query.mockResolvedValueOnce({ rows: [] }); + + // URL-encoded backslash (%5C) in search term + const res = await authed( + request(app).get('/api/v1/guilds/guild1/conversations?search=path%5Cfile'), + ); + + expect(res.status).toBe(200); + const queryCall = mockPool.query.mock.calls[0]; + expect(queryCall[0]).toContain('ILIKE'); + // Backslash must be escaped to \\\\ so it does not act as an escape character in ILIKE + const searchParam = queryCall[1].find((v) => typeof v === 'string' && v.includes('\\\\')); + expect(searchParam).toBeDefined(); + expect(searchParam).toBe('%path\\\\file%'); + }); + + it('should support channel filter', async () => { + mockPool.query.mockResolvedValueOnce({ rows: [] }); + + const res = await authed(request(app).get('/api/v1/guilds/guild1/conversations?channel=ch1')); + + expect(res.status).toBe(200); + const queryCall = mockPool.query.mock.calls[0]; + expect(queryCall[0]).toContain('channel_id'); + expect(queryCall[1]).toContain('ch1'); + }); + + it('should support user filter', async () => { + mockPool.query.mockResolvedValueOnce({ rows: [] }); + + const res = await authed(request(app).get('/api/v1/guilds/guild1/conversations?user=alice')); + + expect(res.status).toBe(200); + const queryCall = mockPool.query.mock.calls[0]; + expect(queryCall[0]).toContain('username'); + expect(queryCall[1]).toContain('alice'); + }); + + it('should support date range filters', async () => { + mockPool.query.mockResolvedValueOnce({ rows: [] }); + + const res = await authed( + request(app).get('/api/v1/guilds/guild1/conversations?from=2024-01-01&to=2024-01-31'), + ); + + expect(res.status).toBe(200); + const queryCall = mockPool.query.mock.calls[0]; + expect(queryCall[0]).toContain('created_at >='); + expect(queryCall[0]).toContain('created_at <='); + }); + + it('should handle pagination params', async () => { + mockPool.query.mockResolvedValueOnce({ rows: [] }); + + const res = await authed( + request(app).get('/api/v1/guilds/guild1/conversations?page=2&limit=10'), + ); + + expect(res.status).toBe(200); + expect(res.body.page).toBe(2); + }); + + it('should handle database errors gracefully', async () => { + mockPool.query.mockRejectedValueOnce(new Error('DB connection failed')); + + const res = await authed(request(app).get('/api/v1/guilds/guild1/conversations')); + + expect(res.status).toBe(500); + expect(res.body.error).toBe('Failed to fetch conversations'); + }); + }); + + // ─── GET /conversations/:conversationId — Detail ────────────────────── + + describe('GET /guilds/:id/conversations/:conversationId', () => { + it('should return 400 for non-numeric conversation ID', async () => { + const res = await authed(request(app).get('/api/v1/guilds/guild1/conversations/abc')); + expect(res.status).toBe(400); + expect(res.body.error).toBe('Invalid conversation ID'); + }); + + it('should return 404 when conversation not found', async () => { + mockPool.query.mockResolvedValueOnce({ rows: [] }); // anchor query + + const res = await authed(request(app).get('/api/v1/guilds/guild1/conversations/999')); + + expect(res.status).toBe(404); + }); + + it('should return conversation detail with messages', async () => { + const baseTime = new Date('2024-01-15T10:00:00Z'); + + // Anchor query + mockPool.query.mockResolvedValueOnce({ + rows: [{ id: 1, channel_id: 'ch1', created_at: baseTime.toISOString() }], + }); + + // All messages in channel + mockPool.query.mockResolvedValueOnce({ + rows: [ + { + id: 1, + channel_id: 'ch1', + role: 'user', + content: 'Hello', + username: 'alice', + created_at: baseTime.toISOString(), + }, + { + id: 2, + channel_id: 'ch1', + role: 'assistant', + content: 'Hi there! How can I help you today?', + username: 'bot', + created_at: new Date(baseTime.getTime() + 60000).toISOString(), + }, + ], + }); + + // Flags query + mockPool.query.mockResolvedValueOnce({ rows: [] }); + + const res = await authed(request(app).get('/api/v1/guilds/guild1/conversations/1')); + + expect(res.status).toBe(200); + expect(res.body).toHaveProperty('messages'); + expect(res.body.messages).toHaveLength(2); + expect(res.body).toHaveProperty('channelId', 'ch1'); + expect(res.body).toHaveProperty('duration'); + expect(res.body).toHaveProperty('tokenEstimate'); + expect(res.body.tokenEstimate).toBeGreaterThan(0); + }); + + it('should include flag status on messages', async () => { + const baseTime = new Date('2024-01-15T10:00:00Z'); + + mockPool.query.mockResolvedValueOnce({ + rows: [{ id: 1, channel_id: 'ch1', created_at: baseTime.toISOString() }], + }); + + mockPool.query.mockResolvedValueOnce({ + rows: [ + { + id: 1, + channel_id: 'ch1', + role: 'user', + content: 'Hello', + username: 'alice', + created_at: baseTime.toISOString(), + }, + { + id: 2, + channel_id: 'ch1', + role: 'assistant', + content: 'Response', + username: 'bot', + created_at: new Date(baseTime.getTime() + 60000).toISOString(), + }, + ], + }); + + mockPool.query.mockResolvedValueOnce({ + rows: [{ message_id: 2, status: 'open' }], + }); + + const res = await authed(request(app).get('/api/v1/guilds/guild1/conversations/1')); + + expect(res.status).toBe(200); + expect(res.body.messages[0].flagStatus).toBeNull(); + expect(res.body.messages[1].flagStatus).toBe('open'); + }); + }); + + // ─── POST /conversations/:conversationId/flag — Flag ────────────────── + + describe('POST /guilds/:id/conversations/:conversationId/flag', () => { + it('should return 400 for missing messageId', async () => { + const res = await authed( + request(app).post('/api/v1/guilds/guild1/conversations/1/flag').send({ reason: 'test' }), + ); + expect(res.status).toBe(400); + expect(res.body.error).toContain('messageId'); + }); + + it('should return 400 for missing reason', async () => { + const res = await authed( + request(app).post('/api/v1/guilds/guild1/conversations/1/flag').send({ messageId: 1 }), + ); + expect(res.status).toBe(400); + expect(res.body.error).toContain('reason'); + }); + + it('should return 400 for empty reason', async () => { + const res = await authed( + request(app) + .post('/api/v1/guilds/guild1/conversations/1/flag') + .send({ messageId: 1, reason: ' ' }), + ); + expect(res.status).toBe(400); + }); + + it('should return 400 for reason exceeding 500 chars', async () => { + const res = await authed( + request(app) + .post('/api/v1/guilds/guild1/conversations/1/flag') + .send({ messageId: 1, reason: 'x'.repeat(501) }), + ); + expect(res.status).toBe(400); + expect(res.body.error).toContain('500'); + }); + + it('should return 400 for notes exceeding 2000 chars', async () => { + const res = await authed( + request(app) + .post('/api/v1/guilds/guild1/conversations/1/flag') + .send({ messageId: 1, reason: 'test', notes: 'x'.repeat(2001) }), + ); + expect(res.status).toBe(400); + expect(res.body.error).toContain('2000'); + }); + + it('should return 404 for non-existent message', async () => { + mockPool.query.mockResolvedValueOnce({ rows: [] }); // msg check + + const res = await authed( + request(app) + .post('/api/v1/guilds/guild1/conversations/1/flag') + .send({ messageId: 999, reason: 'inaccurate' }), + ); + expect(res.status).toBe(404); + expect(res.body.error).toBe('Message not found'); + }); + + it('should successfully flag a message', async () => { + mockPool.query.mockResolvedValueOnce({ + rows: [{ id: 5, channel_id: 'ch1', created_at: new Date().toISOString() }], + }); // msg check + mockPool.query.mockResolvedValueOnce({ + rows: [{ id: 1, channel_id: 'ch1', created_at: new Date().toISOString() }], + }); // anchor check + mockPool.query.mockResolvedValueOnce({ + rows: [{ id: 1, status: 'open' }], + }); // insert + + const res = await authed( + request(app) + .post('/api/v1/guilds/guild1/conversations/1/flag') + .send({ messageId: 5, reason: 'inaccurate', notes: 'Wrong answer' }), + ); + + expect(res.status).toBe(201); + expect(res.body).toHaveProperty('flagId', 1); + expect(res.body).toHaveProperty('status', 'open'); + }); + + it('should return 400 for invalid conversation ID', async () => { + const res = await authed( + request(app) + .post('/api/v1/guilds/guild1/conversations/abc/flag') + .send({ messageId: 1, reason: 'test' }), + ); + expect(res.status).toBe(400); + }); + + it('should handle notes as optional', async () => { + mockPool.query.mockResolvedValueOnce({ + rows: [{ id: 5, channel_id: 'ch1', created_at: new Date().toISOString() }], + }); + mockPool.query.mockResolvedValueOnce({ + rows: [{ id: 1, channel_id: 'ch1', created_at: new Date().toISOString() }], + }); // anchor check + mockPool.query.mockResolvedValueOnce({ + rows: [{ id: 1, status: 'open' }], + }); + + const res = await authed( + request(app) + .post('/api/v1/guilds/guild1/conversations/1/flag') + .send({ messageId: 5, reason: 'inappropriate' }), + ); + + expect(res.status).toBe(201); + }); + + it('should reject non-string notes', async () => { + const res = await authed( + request(app) + .post('/api/v1/guilds/guild1/conversations/1/flag') + .send({ messageId: 5, reason: 'test', notes: 123 }), + ); + + expect(res.status).toBe(400); + expect(res.body.error).toContain('notes'); + }); + }); + + // ─── GET /conversations/flags — List flags ──────────────────────────── + + describe('GET /guilds/:id/conversations/flags', () => { + it('should return paginated flags', async () => { + mockPool.query.mockResolvedValueOnce({ rows: [{ count: 1 }] }); // count + mockPool.query.mockResolvedValueOnce({ + rows: [ + { + id: 1, + guild_id: 'guild1', + conversation_first_id: 1, + message_id: 5, + flagged_by: 'user1', + reason: 'inaccurate', + notes: null, + status: 'open', + resolved_by: null, + resolved_at: null, + created_at: '2024-01-15T10:00:00Z', + message_content: 'Wrong answer', + message_role: 'assistant', + message_username: 'bot', + }, + ], + }); + + const res = await authed(request(app).get('/api/v1/guilds/guild1/conversations/flags')); + + expect(res.status).toBe(200); + expect(res.body).toHaveProperty('flags'); + expect(res.body).toHaveProperty('total', 1); + expect(res.body).toHaveProperty('page', 1); + expect(res.body.flags).toHaveLength(1); + expect(res.body.flags[0]).toHaveProperty('reason', 'inaccurate'); + }); + + it('should filter by status', async () => { + mockPool.query.mockResolvedValueOnce({ rows: [{ count: 0 }] }); + mockPool.query.mockResolvedValueOnce({ rows: [] }); + + const res = await authed( + request(app).get('/api/v1/guilds/guild1/conversations/flags?status=resolved'), + ); + + expect(res.status).toBe(200); + const queryCall = mockPool.query.mock.calls[0]; + expect(queryCall[0]).toContain('fm.status'); + expect(queryCall[1]).toContain('resolved'); + }); + + it('should ignore invalid status values', async () => { + mockPool.query.mockResolvedValueOnce({ rows: [{ count: 0 }] }); + mockPool.query.mockResolvedValueOnce({ rows: [] }); + + const res = await authed( + request(app).get('/api/v1/guilds/guild1/conversations/flags?status=invalid'), + ); + + expect(res.status).toBe(200); + // No status filter should be applied + const queryCall = mockPool.query.mock.calls[0]; + expect(queryCall[0]).not.toContain('fm.status ='); + }); + }); + + // ─── GET /conversations/stats — Analytics ───────────────────────────── + + describe('GET /guilds/:id/conversations/stats', () => { + it('should return conversation analytics', async () => { + // Total messages + mockPool.query.mockResolvedValueOnce({ rows: [{ total_messages: 42 }] }); + // Top users + mockPool.query.mockResolvedValueOnce({ + rows: [ + { username: 'alice', message_count: 20 }, + { username: 'bob', message_count: 15 }, + ], + }); + // Daily activity + mockPool.query.mockResolvedValueOnce({ + rows: [ + { date: '2024-01-15', count: 10 }, + { date: '2024-01-14', count: 8 }, + ], + }); + // Token chars + mockPool.query.mockResolvedValueOnce({ rows: [{ total_chars: 4000 }] }); + // Conversation count (SQL window-function grouping) + mockPool.query.mockResolvedValueOnce({ rows: [{ total_conversations: 2 }] }); + + const res = await authed(request(app).get('/api/v1/guilds/guild1/conversations/stats')); + + expect(res.status).toBe(200); + expect(res.body).toHaveProperty('totalConversations', 2); + expect(res.body).toHaveProperty('totalMessages', 42); + expect(res.body).toHaveProperty('avgMessagesPerConversation'); + expect(res.body).toHaveProperty('topUsers'); + expect(res.body.topUsers).toHaveLength(2); + expect(res.body).toHaveProperty('dailyActivity'); + expect(res.body).toHaveProperty('estimatedTokens', 1000); + }); + + it('should handle empty stats gracefully', async () => { + mockPool.query.mockResolvedValue({ rows: [] }); + // Override first call for total + mockPool.query.mockResolvedValueOnce({ rows: [{ total_messages: 0 }] }); + mockPool.query.mockResolvedValueOnce({ rows: [] }); + mockPool.query.mockResolvedValueOnce({ rows: [] }); + mockPool.query.mockResolvedValueOnce({ rows: [{ total_chars: 0 }] }); + mockPool.query.mockResolvedValueOnce({ rows: [] }); + + const res = await authed(request(app).get('/api/v1/guilds/guild1/conversations/stats')); + + expect(res.status).toBe(200); + expect(res.body.totalConversations).toBe(0); + expect(res.body.totalMessages).toBe(0); + expect(res.body.avgMessagesPerConversation).toBe(0); + expect(res.body.estimatedTokens).toBe(0); + }); + }); + + // ─── Auth + Error handling ────────────────────────────────────────────── + + describe('authentication and error handling', () => { + it('should require auth on all endpoints', async () => { + const endpoints = [ + { method: 'get', path: '/api/v1/guilds/guild1/conversations' }, + { method: 'get', path: '/api/v1/guilds/guild1/conversations/1' }, + { method: 'get', path: '/api/v1/guilds/guild1/conversations/stats' }, + { method: 'get', path: '/api/v1/guilds/guild1/conversations/flags' }, + { method: 'post', path: '/api/v1/guilds/guild1/conversations/1/flag' }, + ]; + + for (const { method, path } of endpoints) { + const res = await request(app)[method](path); + expect(res.status).toBe(401); + } + }); + + it('should validate guild exists on all endpoints', async () => { + const endpoints = [ + { method: 'get', path: '/api/v1/guilds/nonexistent/conversations' }, + { method: 'get', path: '/api/v1/guilds/nonexistent/conversations/1' }, + { method: 'get', path: '/api/v1/guilds/nonexistent/conversations/stats' }, + { method: 'get', path: '/api/v1/guilds/nonexistent/conversations/flags' }, + { method: 'post', path: '/api/v1/guilds/nonexistent/conversations/1/flag' }, + ]; + + for (const { method, path } of endpoints) { + const res = await authed(request(app)[method](path)); + expect(res.status).toBe(404); + } + }); + }); +}); diff --git a/web/src/app/api/guilds/[guildId]/conversations/[conversationId]/flag/route.ts b/web/src/app/api/guilds/[guildId]/conversations/[conversationId]/flag/route.ts new file mode 100644 index 00000000..58279668 --- /dev/null +++ b/web/src/app/api/guilds/[guildId]/conversations/[conversationId]/flag/route.ts @@ -0,0 +1,48 @@ +import type { NextRequest } from 'next/server'; +import { NextResponse } from 'next/server'; +import { + authorizeGuildAdmin, + buildUpstreamUrl, + getBotApiConfig, + proxyToBotApi, +} from '@/lib/bot-api-proxy'; + +const LOG_PREFIX = '[api/guilds/:guildId/conversations/:id/flag]'; + +export const dynamic = 'force-dynamic'; + +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ guildId: string; conversationId: string }> }, +) { + const { guildId, conversationId } = await params; + if (!guildId || !conversationId) { + return NextResponse.json({ error: 'Missing parameters' }, { status: 400 }); + } + + const authError = await authorizeGuildAdmin(request, guildId, LOG_PREFIX); + if (authError) return authError; + + const config = getBotApiConfig(LOG_PREFIX); + if (config instanceof NextResponse) return config; + + const upstreamUrl = buildUpstreamUrl( + config.baseUrl, + `/guilds/${encodeURIComponent(guildId)}/conversations/${encodeURIComponent(conversationId)}/flag`, + LOG_PREFIX, + ); + if (upstreamUrl instanceof NextResponse) return upstreamUrl; + + let body: string; + try { + body = JSON.stringify(await request.json()); + } catch { + return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 }); + } + + return proxyToBotApi(upstreamUrl, config.secret, LOG_PREFIX, 'Failed to flag message', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body, + }); +} diff --git a/web/src/app/api/guilds/[guildId]/conversations/[conversationId]/route.ts b/web/src/app/api/guilds/[guildId]/conversations/[conversationId]/route.ts new file mode 100644 index 00000000..a78a1107 --- /dev/null +++ b/web/src/app/api/guilds/[guildId]/conversations/[conversationId]/route.ts @@ -0,0 +1,42 @@ +import type { NextRequest } from 'next/server'; +import { NextResponse } from 'next/server'; +import { + authorizeGuildAdmin, + buildUpstreamUrl, + getBotApiConfig, + proxyToBotApi, +} from '@/lib/bot-api-proxy'; + +const LOG_PREFIX = '[api/guilds/:guildId/conversations/:id]'; + +export const dynamic = 'force-dynamic'; + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ guildId: string; conversationId: string }> }, +) { + const { guildId, conversationId } = await params; + if (!guildId || !conversationId) { + return NextResponse.json({ error: 'Missing parameters' }, { status: 400 }); + } + + const authError = await authorizeGuildAdmin(request, guildId, LOG_PREFIX); + if (authError) return authError; + + const config = getBotApiConfig(LOG_PREFIX); + if (config instanceof NextResponse) return config; + + const upstreamUrl = buildUpstreamUrl( + config.baseUrl, + `/guilds/${encodeURIComponent(guildId)}/conversations/${encodeURIComponent(conversationId)}`, + LOG_PREFIX, + ); + if (upstreamUrl instanceof NextResponse) return upstreamUrl; + + return proxyToBotApi( + upstreamUrl, + config.secret, + LOG_PREFIX, + 'Failed to fetch conversation detail', + ); +} diff --git a/web/src/app/api/guilds/[guildId]/conversations/flags/route.ts b/web/src/app/api/guilds/[guildId]/conversations/flags/route.ts new file mode 100644 index 00000000..59efbcc9 --- /dev/null +++ b/web/src/app/api/guilds/[guildId]/conversations/flags/route.ts @@ -0,0 +1,45 @@ +import type { NextRequest } from 'next/server'; +import { NextResponse } from 'next/server'; +import { + authorizeGuildAdmin, + buildUpstreamUrl, + getBotApiConfig, + proxyToBotApi, +} from '@/lib/bot-api-proxy'; + +const LOG_PREFIX = '[api/guilds/:guildId/conversations/flags]'; + +export const dynamic = 'force-dynamic'; + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ guildId: string }> }, +) { + const { guildId } = await params; + if (!guildId) { + return NextResponse.json({ error: 'Missing guildId' }, { status: 400 }); + } + + const authError = await authorizeGuildAdmin(request, guildId, LOG_PREFIX); + if (authError) return authError; + + const config = getBotApiConfig(LOG_PREFIX); + if (config instanceof NextResponse) return config; + + const upstreamUrl = buildUpstreamUrl( + config.baseUrl, + `/guilds/${encodeURIComponent(guildId)}/conversations/flags`, + LOG_PREFIX, + ); + if (upstreamUrl instanceof NextResponse) return upstreamUrl; + + const allowedParams = ['page', 'limit', 'status']; + for (const key of allowedParams) { + const value = request.nextUrl.searchParams.get(key); + if (value !== null) { + upstreamUrl.searchParams.set(key, value); + } + } + + return proxyToBotApi(upstreamUrl, config.secret, LOG_PREFIX, 'Failed to fetch flagged messages'); +} diff --git a/web/src/app/api/guilds/[guildId]/conversations/route.ts b/web/src/app/api/guilds/[guildId]/conversations/route.ts new file mode 100644 index 00000000..164ee277 --- /dev/null +++ b/web/src/app/api/guilds/[guildId]/conversations/route.ts @@ -0,0 +1,45 @@ +import type { NextRequest } from 'next/server'; +import { NextResponse } from 'next/server'; +import { + authorizeGuildAdmin, + buildUpstreamUrl, + getBotApiConfig, + proxyToBotApi, +} from '@/lib/bot-api-proxy'; + +const LOG_PREFIX = '[api/guilds/:guildId/conversations]'; + +export const dynamic = 'force-dynamic'; + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ guildId: string }> }, +) { + const { guildId } = await params; + if (!guildId) { + return NextResponse.json({ error: 'Missing guildId' }, { status: 400 }); + } + + const authError = await authorizeGuildAdmin(request, guildId, LOG_PREFIX); + if (authError) return authError; + + const config = getBotApiConfig(LOG_PREFIX); + if (config instanceof NextResponse) return config; + + const upstreamUrl = buildUpstreamUrl( + config.baseUrl, + `/guilds/${encodeURIComponent(guildId)}/conversations`, + LOG_PREFIX, + ); + if (upstreamUrl instanceof NextResponse) return upstreamUrl; + + const allowedParams = ['page', 'limit', 'search', 'user', 'channel', 'from', 'to']; + for (const key of allowedParams) { + const value = request.nextUrl.searchParams.get(key); + if (value !== null) { + upstreamUrl.searchParams.set(key, value); + } + } + + return proxyToBotApi(upstreamUrl, config.secret, LOG_PREFIX, 'Failed to fetch conversations'); +} diff --git a/web/src/app/api/guilds/[guildId]/conversations/stats/route.ts b/web/src/app/api/guilds/[guildId]/conversations/stats/route.ts new file mode 100644 index 00000000..86c567af --- /dev/null +++ b/web/src/app/api/guilds/[guildId]/conversations/stats/route.ts @@ -0,0 +1,42 @@ +import type { NextRequest } from 'next/server'; +import { NextResponse } from 'next/server'; +import { + authorizeGuildAdmin, + buildUpstreamUrl, + getBotApiConfig, + proxyToBotApi, +} from '@/lib/bot-api-proxy'; + +const LOG_PREFIX = '[api/guilds/:guildId/conversations/stats]'; + +export const dynamic = 'force-dynamic'; + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ guildId: string }> }, +) { + const { guildId } = await params; + if (!guildId) { + return NextResponse.json({ error: 'Missing guildId' }, { status: 400 }); + } + + const authError = await authorizeGuildAdmin(request, guildId, LOG_PREFIX); + if (authError) return authError; + + const config = getBotApiConfig(LOG_PREFIX); + if (config instanceof NextResponse) return config; + + const upstreamUrl = buildUpstreamUrl( + config.baseUrl, + `/guilds/${encodeURIComponent(guildId)}/conversations/stats`, + LOG_PREFIX, + ); + if (upstreamUrl instanceof NextResponse) return upstreamUrl; + + return proxyToBotApi( + upstreamUrl, + config.secret, + LOG_PREFIX, + 'Failed to fetch conversation stats', + ); +} diff --git a/web/src/app/dashboard/conversations/[conversationId]/page.tsx b/web/src/app/dashboard/conversations/[conversationId]/page.tsx new file mode 100644 index 00000000..acd973bf --- /dev/null +++ b/web/src/app/dashboard/conversations/[conversationId]/page.tsx @@ -0,0 +1,129 @@ +'use client'; + +import { ArrowLeft, MessageSquare } from 'lucide-react'; +import { useParams, useRouter, useSearchParams } from 'next/navigation'; +import { useCallback, useEffect, useState } from 'react'; +import { + type ConversationMessage, + ConversationReplay, +} from '@/components/dashboard/conversation-replay'; +import { Button } from '@/components/ui/button'; + +interface ConversationDetailResponse { + messages: ConversationMessage[]; + channelId: string; + duration: number; + tokenEstimate: number; +} + +/** + * Conversation detail page — shows full chat replay with flag support. + */ +export default function ConversationDetailPage() { + const router = useRouter(); + const params = useParams(); + const searchParams = useSearchParams(); + const conversationId = params.conversationId as string; + const guildId = searchParams.get('guildId'); + + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const fetchDetail = useCallback(async () => { + if (!guildId || !conversationId) return; + + setLoading(true); + setError(null); + + try { + const res = await fetch( + `/api/guilds/${encodeURIComponent(guildId)}/conversations/${encodeURIComponent(conversationId)}`, + ); + + if (res.status === 401) { + router.replace('/login'); + return; + } + if (res.status === 404) { + setError('Conversation not found'); + return; + } + if (!res.ok) { + throw new Error(`Failed to fetch conversation (${res.status})`); + } + + const result = (await res.json()) as ConversationDetailResponse; + setData(result); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to fetch conversation'); + } finally { + setLoading(false); + } + }, [guildId, conversationId, router]); + + useEffect(() => { + void fetchDetail(); + }, [fetchDetail]); + + return ( +
+ {/* Header */} +
+ +
+

+ + Conversation Detail +

+ {data && ( +

+ Channel {data.channelId.slice(-4)} · {data.messages.length} messages +

+ )} +
+
+ + {/* Loading */} + {loading && ( +
+

Loading conversation...

+
+ )} + + {/* Error */} + {error && ( +
+ Error: {error} +
+ )} + + {/* No guild */} + {!guildId && !loading && ( +
+

+ No guild selected. Please navigate from the conversations list. +

+
+ )} + + {/* Replay */} + {data && guildId && ( + + )} +
+ ); +} diff --git a/web/src/app/dashboard/conversations/page.tsx b/web/src/app/dashboard/conversations/page.tsx new file mode 100644 index 00000000..1ebc670c --- /dev/null +++ b/web/src/app/dashboard/conversations/page.tsx @@ -0,0 +1,432 @@ +'use client'; + +import { Hash, MessageSquare, RefreshCw, Search, X } from 'lucide-react'; +import { useRouter } from 'next/navigation'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; +import { useGuildSelection } from '@/hooks/use-guild-selection'; + +interface Participant { + username: string; + role: string; +} + +interface ConversationSummary { + id: number; + channelId: string; + channelName: string; + participants: Participant[]; + messageCount: number; + firstMessageAt: string; + lastMessageAt: string; + preview: string; +} + +interface ConversationsApiResponse { + conversations: ConversationSummary[]; + total: number; + page: number; +} + +interface Channel { + id: string; + name: string; + type: number; +} + +function formatDate(iso: string): string { + return new Date(iso).toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); +} + +function formatDuration(first: string, last: string): string { + const ms = new Date(last).getTime() - new Date(first).getTime(); + const seconds = Math.round(ms / 1000); + if (seconds < 60) return `${seconds}s`; + const mins = Math.floor(seconds / 60); + if (mins < 60) return `${mins}m`; + const hours = Math.floor(mins / 60); + return `${hours}h ${mins % 60}m`; +} + +/** Number of conversations per page */ +const PAGE_SIZE = 25; + +/** + * Conversation list page with search, filters, and pagination. + */ +export default function ConversationsPage() { + const router = useRouter(); + + const [conversations, setConversations] = useState([]); + const [total, setTotal] = useState(0); + const [page, setPage] = useState(1); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const [search, setSearch] = useState(''); + const [channelFilter, setChannelFilter] = useState(''); + const [channels, setChannels] = useState([]); + + // Debounce search + const searchTimerRef = useRef>(undefined); + const [debouncedSearch, setDebouncedSearch] = useState(''); + + const abortControllerRef = useRef(null); + const requestIdRef = useRef(0); + + useEffect(() => { + clearTimeout(searchTimerRef.current); + searchTimerRef.current = setTimeout(() => { + setDebouncedSearch(search); + setPage(1); + }, 300); + return () => clearTimeout(searchTimerRef.current); + }, [search]); + + useEffect(() => { + return () => { + abortControllerRef.current?.abort(); + }; + }, []); + + const onGuildChange = useCallback(() => { + setConversations([]); + setTotal(0); + setPage(1); + setError(null); + setChannels([]); + }, []); + + const guildId = useGuildSelection({ onGuildChange }); + + const onUnauthorized = useCallback(() => router.replace('/login'), [router]); + + // Fetch channels for filter dropdown + useEffect(() => { + if (!guildId) return; + void (async () => { + try { + const res = await fetch(`/api/guilds/${encodeURIComponent(guildId)}/channels`); + if (res.ok) { + const data = (await res.json()) as Channel[]; + // Only show text channels (type 0) + setChannels(data.filter((ch) => ch.type === 0)); + } + } catch { + // Non-critical — channels filter just won't populate + } + })(); + }, [guildId]); + + // Fetch conversations + const fetchConversations = useCallback( + async (opts: { guildId: string; search: string; channel: string; page: number }) => { + abortControllerRef.current?.abort(); + const controller = new AbortController(); + abortControllerRef.current = controller; + const requestId = ++requestIdRef.current; + + setLoading(true); + setError(null); + + try { + const params = new URLSearchParams(); + params.set('page', String(opts.page)); + params.set('limit', String(PAGE_SIZE)); + if (opts.search) params.set('search', opts.search); + if (opts.channel) params.set('channel', opts.channel); + + const res = await fetch( + `/api/guilds/${encodeURIComponent(opts.guildId)}/conversations?${params.toString()}`, + { signal: controller.signal }, + ); + + if (requestId !== requestIdRef.current) return; + + if (res.status === 401) { + onUnauthorized(); + return; + } + if (!res.ok) { + throw new Error(`Failed to fetch conversations (${res.status})`); + } + + const data = (await res.json()) as ConversationsApiResponse; + setConversations(data.conversations); + setTotal(data.total); + } catch (err) { + if (err instanceof DOMException && err.name === 'AbortError') return; + if (requestId !== requestIdRef.current) return; + setError(err instanceof Error ? err.message : 'Failed to fetch conversations'); + } finally { + if (requestId === requestIdRef.current) { + setLoading(false); + } + } + }, + [onUnauthorized], + ); + + useEffect(() => { + if (!guildId) return; + void fetchConversations({ + guildId, + search: debouncedSearch, + channel: channelFilter, + page, + }); + }, [guildId, debouncedSearch, channelFilter, page, fetchConversations]); + + const handleRefresh = useCallback(() => { + if (!guildId) return; + void fetchConversations({ + guildId, + search: debouncedSearch, + channel: channelFilter, + page, + }); + }, [guildId, fetchConversations, debouncedSearch, channelFilter, page]); + + const handleRowClick = useCallback( + (conversationId: number) => { + if (!guildId) return; + router.push( + `/dashboard/conversations/${conversationId}?guildId=${encodeURIComponent(guildId)}`, + ); + }, + [router, guildId], + ); + + const handleClearSearch = useCallback(() => { + setSearch(''); + setDebouncedSearch(''); + setPage(1); + }, []); + + const totalPages = Math.ceil(total / PAGE_SIZE); + + return ( +
+ {/* Header */} +
+
+

+ + Conversations +

+

Browse, search, and replay AI conversations.

+
+ + +
+ + {/* No guild selected */} + {!guildId && ( +
+

+ Select a server from the sidebar to view conversations. +

+
+ )} + + {/* Content */} + {guildId && ( + <> + {/* Filters */} +
+
+ + setSearch(e.target.value)} + aria-label="Search conversations" + /> + {search && ( + + )} +
+ + + + {total > 0 && ( + + {total.toLocaleString()} {total === 1 ? 'conversation' : 'conversations'} + + )} +
+ + {/* Error */} + {error && ( +
+ Error: {error} +
+ )} + + {/* Table */} + {conversations.length > 0 ? ( +
+ + + + Channel + Participants + Messages + Duration + Preview + Date + + + + {conversations.map((convo) => ( + handleRowClick(convo.id)} + > + +
+ + {convo.channelName} +
+
+ +
+ {convo.participants.slice(0, 3).map((p, i) => ( +
+ {p.username.slice(0, 2).toUpperCase()} +
+ ))} + {convo.participants.length > 3 && ( +
+ +{convo.participants.length - 3} +
+ )} +
+
+ + {convo.messageCount} + + + {formatDuration(convo.firstMessageAt, convo.lastMessageAt)} + + + {convo.preview} + + + {formatDate(convo.firstMessageAt)} + +
+ ))} +
+
+
+ ) : ( + !loading && ( +
+

+ {debouncedSearch || channelFilter + ? 'No conversations match your filters.' + : 'No conversations found.'} +

+
+ ) + )} + + {/* Pagination */} + {totalPages > 1 && ( +
+ + Page {page} of {totalPages} + +
+ + +
+
+ )} + + )} +
+ ); +} diff --git a/web/src/components/dashboard/conversation-replay.tsx b/web/src/components/dashboard/conversation-replay.tsx new file mode 100644 index 00000000..4a19c4dd --- /dev/null +++ b/web/src/components/dashboard/conversation-replay.tsx @@ -0,0 +1,288 @@ +'use client'; + +import { AlertTriangle, Clock, Flag, Hash, Zap } from 'lucide-react'; +import { useCallback, useState } from 'react'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Label } from '@/components/ui/label'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { Textarea } from '@/components/ui/textarea'; +import { cn } from '@/lib/utils'; + +export interface ConversationMessage { + id: number; + role: 'user' | 'assistant' | 'system'; + content: string; + username: string; + createdAt: string; + flagStatus?: string | null; +} + +interface ConversationReplayProps { + messages: ConversationMessage[]; + channelId: string; + duration: number; + tokenEstimate: number; + guildId: string; + onFlagSubmitted?: () => void; +} + +function formatDuration(seconds: number): string { + if (seconds < 60) return `${seconds}s`; + const mins = Math.floor(seconds / 60); + const secs = seconds % 60; + if (mins < 60) return `${mins}m ${secs}s`; + const hours = Math.floor(mins / 60); + const remainMins = mins % 60; + return `${hours}h ${remainMins}m`; +} + +function formatTimestamp(iso: string): string { + return new Date(iso).toLocaleTimeString('en-US', { + hour: '2-digit', + minute: '2-digit', + }); +} + +function shouldShowTimestamp(current: string, previous: string | null): boolean { + if (!previous) return true; + const diff = new Date(current).getTime() - new Date(previous).getTime(); + return diff > 5 * 60 * 1000; // 5 minute gap +} + +/** + * Chat-style conversation replay component. + * User messages on the right (blue), assistant on the left (gray), system messages small/italic. + */ +export function ConversationReplay({ + messages, + channelId, + duration, + tokenEstimate, + guildId, + onFlagSubmitted, +}: ConversationReplayProps) { + const [flagDialogOpen, setFlagDialogOpen] = useState(false); + const [flagMessageId, setFlagMessageId] = useState(null); + const [flagReason, setFlagReason] = useState(''); + const [flagNotes, setFlagNotes] = useState(''); + const [flagSubmitting, setFlagSubmitting] = useState(false); + const [flagError, setFlagError] = useState(null); + + const conversationId = messages[0]?.id; + + const openFlagDialog = useCallback((messageId: number) => { + setFlagMessageId(messageId); + setFlagReason(''); + setFlagNotes(''); + setFlagError(null); + setFlagDialogOpen(true); + }, []); + + const submitFlag = useCallback(async () => { + if (!flagMessageId || !flagReason || !conversationId || !guildId) return; + + setFlagSubmitting(true); + setFlagError(null); + + try { + const res = await fetch( + `/api/guilds/${encodeURIComponent(guildId)}/conversations/${conversationId}/flag`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + messageId: flagMessageId, + reason: flagReason, + notes: flagNotes || undefined, + }), + }, + ); + + if (!res.ok) { + const data = await res.json().catch(() => ({})); + throw new Error(data.error || `Failed to flag message (${res.status})`); + } + + setFlagDialogOpen(false); + onFlagSubmitted?.(); + } catch (err) { + setFlagError(err instanceof Error ? err.message : 'Failed to flag message'); + } finally { + setFlagSubmitting(false); + } + }, [flagMessageId, flagReason, flagNotes, conversationId, guildId, onFlagSubmitted]); + + return ( +
+ {/* Header stats */} +
+ + + {channelId} + + + + {formatDuration(duration)} + + + ~{tokenEstimate.toLocaleString()} tokens + + {messages.length} messages +
+ + {/* Messages */} +
+ {messages.map((msg, idx) => { + const prevTimestamp = idx > 0 ? messages[idx - 1].createdAt : null; + const showTimestamp = shouldShowTimestamp(msg.createdAt, prevTimestamp); + const isFlagged = msg.flagStatus === 'open'; + + return ( +
+ {showTimestamp && ( +
+ + {formatTimestamp(msg.createdAt)} + +
+ )} + + {msg.role === 'system' ? ( +
+

+ {msg.content} +

+
+ ) : ( +
+ {/* Avatar */} +
+ {(msg.username || msg.role).slice(0, 2).toUpperCase()} +
+ + {/* Bubble */} +
+

+ {msg.username || msg.role} +

+

{msg.content}

+ + {/* Flag button for assistant messages */} + {msg.role === 'assistant' && ( +
+ +
+ )} + + {/* Flagged indicator */} + {isFlagged && ( +
+ + Flagged +
+ )} +
+
+ )} +
+ ); + })} +
+ + {/* Flag Dialog */} + + + + Flag AI Response + Report a problematic AI response for review. + +
+
+ + +
+
+ +