import type { SQL } from "drizzle-orm"; import { sql } from "drizzle-orm"; import { getDatabase } from "../../shared/database/index.js"; import { pgChannelCulturesTable, pgMessagesTable, pgUserProfilesTable, pgVoiceRecordingsTable, } from "../../shared/index.js"; import type { ListUsersQuery } from "./dashboard.service.js"; export class DashboardRepository { async getStats() { const db = getDatabase(); const oneDayAgo = Date.now() - 86400000; // Total messages and breakdown by ai_status const msgResult = await db.execute(sql` SELECT COUNT(*)::int AS total_messages, COUNT(*) FILTER (WHERE ai_status = 'flagged')::int AS total_flagged, COUNT(*) FILTER (WHERE ai_status = 'clean')::int AS total_clean, COUNT(*) FILTER (WHERE ai_status = 'warn')::int AS total_warned, COUNT(*) FILTER (WHERE ai_status = 'error')::int AS total_error, COUNT(*) FILTER (WHERE ai_status = 'pending')::int AS total_pending, COUNT(*) FILTER (WHERE ai_status = 'processing')::int AS total_processing, COUNT(DISTINCT user_id)::int AS total_users, COUNT(*) FILTER (WHERE created_at >= ${oneDayAgo})::int AS today_messages, COUNT(*) FILTER (WHERE ai_status = 'flagged' AND created_at >= ${oneDayAgo})::int AS today_flagged, COUNT(DISTINCT user_id) FILTER (WHERE created_at >= ${oneDayAgo})::int AS active_users_24h FROM ${pgMessagesTable} `); const msgRow = msgResult.rows[0] as Record | undefined; // Total voice recordings const voiceResult = await db.execute(sql` SELECT COUNT(*)::int AS count FROM ${pgVoiceRecordingsTable} `); // Total AI user profiles const profileResult = await db.execute(sql` SELECT COUNT(*)::int AS count FROM ${pgUserProfilesTable} `); // Top channels by message count const topChannels = await db.execute(sql` SELECT channel_id, COALESCE(NULLIF((metadata::jsonb -> 'channel' ->> 'channelName'), ''), channel_id) AS channel_name, COUNT(*)::int AS message_count FROM ${pgMessagesTable} WHERE metadata IS NOT NULL AND metadata != '' GROUP BY channel_id, (metadata::jsonb -> 'channel' ->> 'channelName') ORDER BY COUNT(*) DESC LIMIT 10 `); return { total_messages: msgRow?.total_messages ?? 0, total_users: msgRow?.total_users ?? 0, total_flagged: msgRow?.total_flagged ?? 0, total_clean: msgRow?.total_clean ?? 0, total_warned: msgRow?.total_warned ?? 0, total_error: msgRow?.total_error ?? 0, total_voice_recordings: voiceResult.rows[0]?.count ?? 0, total_profiles: profileResult.rows[0]?.count ?? 0, today_messages: msgRow?.today_messages ?? 0, today_flagged: msgRow?.today_flagged ?? 0, active_users_24h: msgRow?.active_users_24h ?? 0, top_channels: topChannels.rows.map((r: Record) => ({ channel_id: String(r.channel_id), channel_name: r.channel_name ? String(r.channel_name) : null, message_count: Number(r.message_count), })), moderation_overview: { pending: msgRow?.total_pending ?? 0, processing: msgRow?.total_processing ?? 0, error: msgRow?.total_error ?? 0, }, }; } async getActivity(days: number) { const db = getDatabase(); const sinceMs = Date.now() - days * 86400000; const dayAgoMs = Date.now() - 86400000; // Daily buckets (last N days) const daily = await db.execute(sql` SELECT to_char(to_timestamp(created_at / 1000), 'YYYY-MM-DD') AS day, COUNT(*)::int AS messages, COUNT(*) FILTER (WHERE ai_status = 'flagged')::int AS flagged, COUNT(DISTINCT user_id)::int AS active_users FROM ${pgMessagesTable} WHERE created_at >= ${sinceMs} GROUP BY day ORDER BY day `); // Hourly distribution (last 24h) const hourly = await db.execute(sql` SELECT EXTRACT(HOUR FROM to_timestamp(created_at / 1000))::int AS hour, COUNT(*)::int AS messages, COUNT(*) FILTER (WHERE ai_status = 'flagged')::int AS flagged FROM ${pgMessagesTable} WHERE created_at >= ${dayAgoMs} GROUP BY hour ORDER BY hour `); return { days, daily: (daily.rows as Record[]).map((r) => ({ day: String(r.day), messages: Number(r.messages), flagged: Number(r.flagged), active_users: Number(r.active_users), })), hourly: (hourly.rows as Record[]).map((r) => ({ hour: Number(r.hour), messages: Number(r.messages), flagged: Number(r.flagged), })), }; } async listUsers(query: ListUsersQuery) { const db = getDatabase(); const limit = query.limit ?? 20; const conditions: SQL[] = []; if (query.search) { conditions.push( sql`(m.user_id ILIKE ${`%${query.search}%`} OR m.username ILIKE ${`%${query.search}%`})`, ); } if (query.cursor) { conditions.push(sql`m.last_message_at < ${Number(query.cursor)}`); } const whereClause = conditions.length > 0 ? sql`WHERE ${sql.join(conditions, sql` AND `)}` : sql``; const { rows } = await db.execute(sql` SELECT m.user_id, m.username, m.avatar_url, p.profile_summary, m.total_messages, m.flagged_count, m.last_message_at FROM ( SELECT user_id, username, avatar_url, COUNT(*)::int AS total_messages, COUNT(*) FILTER (WHERE ai_status = 'flagged')::int AS flagged_count, MAX(created_at) AS last_message_at FROM ${pgMessagesTable} GROUP BY user_id, username, avatar_url ) m LEFT JOIN ${pgUserProfilesTable} p ON p.user_id = m.user_id ${whereClause} ORDER BY m.last_message_at DESC NULLS LAST LIMIT ${limit + 1} `); const data = (rows as Record[]) .slice(0, limit) .map((r) => ({ user_id: String(r.user_id), username: r.username as string | null, avatar_url: r.avatar_url as string | null, profile_summary: r.profile_summary as string | null, total_messages: Number(r.total_messages), flagged_count: Number(r.flagged_count), last_message_at: r.last_message_at ? Number(r.last_message_at) : null, })); const lastRow = rows[limit - 1] as Record | undefined; const nextCursor = rows.length > limit ? String(lastRow?.last_message_at ?? lastRow?.total_messages ?? "") : null; return { data, nextCursor }; } async listChannels(query: ListUsersQuery & { guildId?: string }) { const db = getDatabase(); const limit = query.limit ?? 20; const conditions: SQL[] = []; if (query.search) { conditions.push( sql`(m.channel_id ILIKE ${`%${query.search}%`} OR m.channel_name ILIKE ${`%${query.search}%`})`, ); } if (query.guildId) { conditions.push(sql`m.guild_id = ${query.guildId}`); } const whereClause = conditions.length > 0 ? sql`WHERE ${sql.join(conditions, sql` AND `)}` : sql``; const { rows } = await db.execute(sql` SELECT m.channel_id, m.channel_name, m.guild_id, m.total_messages, m.flagged_count, m.last_message_at, c.culture_summary, c.last_analyzed_at FROM ( SELECT channel_id, guild_id, COALESCE(NULLIF((metadata::jsonb -> 'channel' ->> 'channelName'), ''), channel_id) AS channel_name, COUNT(*)::int AS total_messages, COUNT(*) FILTER (WHERE ai_status = 'flagged')::int AS flagged_count, MAX(created_at) AS last_message_at FROM ${pgMessagesTable} GROUP BY channel_id, guild_id, (metadata::jsonb -> 'channel' ->> 'channelName') ) m LEFT JOIN ${pgChannelCulturesTable} c ON c.channel_id = m.channel_id ${whereClause} ORDER BY m.total_messages DESC LIMIT ${limit + 1} `); const data = ((rows as Record[]) || []) .slice(0, limit) .map((r) => ({ channel_id: String(r.channel_id), channel_name: r.channel_name as string | null, guild_id: r.guild_id as string | null, total_messages: Number(r.total_messages), flagged_count: Number(r.flagged_count), last_message_at: r.last_message_at ? Number(r.last_message_at) : null, culture_summary: r.culture_summary as string | null, last_analyzed_at: r.last_analyzed_at ? Number(r.last_analyzed_at) : null, })); const lastRow = rows[limit - 1] as Record | undefined; const nextCursor = rows.length > limit ? String(lastRow?.total_messages ?? "") : null; return { data, nextCursor }; } async getChannelDetail(channelId: string) { const db = getDatabase(); const channelResult = await db.execute(sql` SELECT m.channel_id, m.channel_name, m.guild_id, m.total_messages, m.flagged_count, m.clean_count, c.culture_summary, c.last_analyzed_at FROM ( SELECT channel_id, guild_id, COALESCE(NULLIF((metadata::jsonb -> 'channel' ->> 'channelName'), ''), channel_id) AS channel_name, COUNT(*)::int AS total_messages, COUNT(*) FILTER (WHERE ai_status = 'flagged')::int AS flagged_count, COUNT(*) FILTER (WHERE ai_status = 'clean')::int AS clean_count FROM ${pgMessagesTable} WHERE channel_id = ${channelId} GROUP BY channel_id, guild_id, (metadata::jsonb -> 'channel' ->> 'channelName') ) m LEFT JOIN ${pgChannelCulturesTable} c ON c.channel_id = m.channel_id `); const row = channelResult.rows[0] as Record | undefined; if (!row) return null; const recent = await db.execute(sql` SELECT id, content, channel_id, created_at, ai_status, username FROM ${pgMessagesTable} WHERE channel_id = ${channelId} ORDER BY created_at DESC LIMIT 20 `); return { channel_id: String(row.channel_id), channel_name: row.channel_name as string | null, guild_id: row.guild_id as string | null, total_messages: Number(row.total_messages), flagged_count: Number(row.flagged_count), clean_count: Number(row.clean_count), culture_summary: row.culture_summary as string | null, last_analyzed_at: row.last_analyzed_at ? Number(row.last_analyzed_at) : null, recent_messages: (recent.rows as Record[]).map((r) => ({ id: String(r.id), content: String(r.content), channel_id: String(r.channel_id), created_at: Number(r.created_at), ai_status: r.ai_status as string | null, username: r.username as string | null, })), }; } async getTopReactions(limit: number) { const db = getDatabase(); const cap = Math.min(Math.max(limit || 20, 1), 50); // Top messages by net reactions (adds minus removes), joined to message content const result = await db.execute(sql` SELECT m.id AS message_id, m.content, m.username, m.channel_id, m.created_at, COALESCE(NULLIF((m.metadata::jsonb -> 'channel' ->> 'channelName'), ''), m.channel_id) AS channel_name, r.reaction_count::int FROM ( SELECT message_id, (COUNT(*) FILTER (WHERE reaction_type = 'add') - COUNT(*) FILTER (WHERE reaction_type = 'remove'))::int AS reaction_count FROM message_reactions GROUP BY message_id ) r JOIN messages m ON m.id = r.message_id WHERE r.reaction_count > 0 ORDER BY r.reaction_count DESC LIMIT ${cap} `); const rows = (result.rows as Record[]) || []; if (rows.length === 0) return []; // Top emoji per message (adds only) for the breakdown const ids = rows.map((r) => String(r.message_id)); const emojiResult = await db.execute(sql` SELECT message_id, emoji, COUNT(*)::int AS c FROM message_reactions WHERE reaction_type = 'add' AND message_id IN (${sql.join(ids, sql`, `)}) GROUP BY message_id, emoji ORDER BY message_id, c DESC `); const emojiByMessage = new Map< string, Array<{ emoji: string; count: number }> >(); for (const e of emojiResult.rows as Record[]) { const mid = String(e.message_id); const list = emojiByMessage.get(mid) ?? []; list.push({ emoji: String(e.emoji), count: Number(e.c) }); emojiByMessage.set(mid, list); } return rows.map((r) => ({ message_id: String(r.message_id), content: r.content ? String(r.content) : "", username: r.username ? String(r.username) : null, channel_id: String(r.channel_id), channel_name: r.channel_name ? String(r.channel_name) : null, created_at: r.created_at ? Number(r.created_at) : null, reaction_count: Number(r.reaction_count), top_emojis: (emojiByMessage.get(String(r.message_id)) ?? []).slice(0, 3), })); } async getTopReactors(limit: number) { const db = getDatabase(); const cap = Math.min(Math.max(limit || 20, 1), 50); // Top users by net reactions given (adds minus removes) const result = await db.execute(sql` SELECT user_id, username, (COUNT(*) FILTER (WHERE reaction_type = 'add') - COUNT(*) FILTER (WHERE reaction_type = 'remove'))::int AS net_count, COUNT(*) FILTER (WHERE reaction_type = 'add')::int AS adds_count, COUNT(DISTINCT message_id)::int AS messages_reacted, COUNT(DISTINCT emoji)::int AS emojis_used FROM message_reactions GROUP BY user_id, username ORDER BY net_count DESC LIMIT ${cap} `); return ((result.rows as Record[]) || []).map((r) => ({ user_id: String(r.user_id), username: String(r.username ?? "unknown"), net_count: Number(r.net_count), adds_count: Number(r.adds_count), messages_reacted: Number(r.messages_reacted), emojis_used: Number(r.emojis_used), })); } async getUserDetail(userId: string) { const db = getDatabase(); const userResult = await db.execute(sql` SELECT m.user_id, m.username, m.avatar_url, m.total_messages, m.flagged_count, m.clean_count, p.profile_summary, p.last_analyzed_at FROM ( SELECT user_id, username, avatar_url, COUNT(*)::int AS total_messages, COUNT(*) FILTER (WHERE ai_status = 'flagged')::int AS flagged_count, COUNT(*) FILTER (WHERE ai_status = 'clean')::int AS clean_count FROM ${pgMessagesTable} WHERE user_id = ${userId} GROUP BY user_id, username, avatar_url ) m LEFT JOIN ${pgUserProfilesTable} p ON p.user_id = m.user_id `); const row = userResult.rows[0] as Record | undefined; if (!row) { return null; } const recent = await db.execute(sql` SELECT id, content, channel_id, created_at, ai_status FROM ${pgMessagesTable} WHERE user_id = ${userId} ORDER BY created_at DESC LIMIT 20 `); return { user_id: String(row.user_id), username: row.username as string | null, avatar_url: row.avatar_url as string | null, total_messages: Number(row.total_messages), flagged_count: Number(row.flagged_count), clean_count: Number(row.clean_count), profile_summary: row.profile_summary as string | null, last_analyzed_at: row.last_analyzed_at ? Number(row.last_analyzed_at) : null, recent_messages: (recent.rows as Record[]).map((r) => ({ id: String(r.id), content: String(r.content), channel_id: String(r.channel_id), created_at: Number(r.created_at), ai_status: r.ai_status as string | null, })), }; } } export const dashboardRepository = new DashboardRepository();