import { DrizzleClient } from "@shared/db/DrizzleClient"; import { users, transactions, moderationCases, inventory, type User } from "@db/schema"; import { desc, sql, gte } from "drizzle-orm"; import type { RecentEvent, ActivityData } from "./dashboard.types"; import { TransactionType } from "@shared/lib/constants"; export const dashboardService = { /** * Get count of active users from database */ getActiveUserCount: async (): Promise => { const result = await DrizzleClient .select({ count: sql`COUNT(*)` }) .from(users) .where(sql`${users.isActive} = true`); return Number(result[0]?.count || 0); }, /** * Get total user count */ getTotalUserCount: async (): Promise => { const result = await DrizzleClient .select({ count: sql`COUNT(*)` }) .from(users); return Number(result[0]?.count || 0); }, /** * Get economy statistics */ getEconomyStats: async (): Promise<{ totalWealth: bigint; avgLevel: number; topStreak: number; }> => { const allUsers = await DrizzleClient.select().from(users); const totalWealth = allUsers.reduce( (acc: bigint, u: User) => acc + (u.balance || 0n), 0n ); const avgLevel = allUsers.length > 0 ? Math.round( allUsers.reduce((acc: number, u: User) => acc + (u.level || 1), 0) / allUsers.length ) : 1; const topStreak = allUsers.reduce( (max: number, u: User) => Math.max(max, u.dailyStreak || 0), 0 ); return { totalWealth, avgLevel, topStreak }; }, /** * Get total items in circulation */ getTotalItems: async (): Promise => { const result = await DrizzleClient .select({ total: sql`COALESCE(SUM(${inventory.quantity}), 0)` }) .from(inventory); return Number(result[0]?.total || 0); }, /** * Get recent transactions as events (last 24 hours) */ getRecentTransactions: async (limit: number = 10): Promise => { const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000); const recentTx = await DrizzleClient.query.transactions.findMany({ limit, orderBy: [desc(transactions.createdAt)], where: gte(transactions.createdAt, oneDayAgo), with: { user: true, }, }); return recentTx.map((tx) => ({ type: 'info' as const, message: `${tx.user?.username || 'Unknown'}: ${tx.description || 'Transaction'}`, timestamp: tx.createdAt || new Date(), icon: getTransactionIcon(tx.type), })); }, /** * Get recent moderation cases as events (last 24 hours) */ getRecentModerationCases: async (limit: number = 10): Promise => { const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000); const recentCases = await DrizzleClient.query.moderationCases.findMany({ limit, orderBy: [desc(moderationCases.createdAt)], where: gte(moderationCases.createdAt, oneDayAgo), }); return recentCases.map((modCase) => ({ type: modCase.type === 'warn' || modCase.type === 'ban' ? 'error' : 'info', message: `${modCase.type.toUpperCase()}: ${modCase.username} - ${modCase.reason}`, timestamp: modCase.createdAt || new Date(), icon: getModerationIcon(modCase.type as string), })); }, /** * Get combined recent events (transactions + moderation) */ getRecentEvents: async (limit: number = 10): Promise => { const [txEvents, modEvents] = await Promise.all([ dashboardService.getRecentTransactions(limit), dashboardService.getRecentModerationCases(limit), ]); // Combine and sort by timestamp const allEvents = [...txEvents, ...modEvents] .sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()) .slice(0, limit); return allEvents; }, /** * Records a new internal event and broadcasts it via WebSocket */ recordEvent: async (event: Omit): Promise => { const fullEvent: RecentEvent = { ...event, timestamp: new Date(), }; // Broadcast to WebSocket clients try { const { systemEvents, EVENTS } = await import("@shared/lib/events"); systemEvents.emit(EVENTS.DASHBOARD.NEW_EVENT, { ...fullEvent, timestamp: (fullEvent.timestamp instanceof Date) ? fullEvent.timestamp.toISOString() : fullEvent.timestamp }); } catch (e) { console.error("Failed to emit system event:", e); } }, /** * Get hourly activity aggregation for the last 24 hours */ getActivityAggregation: async (): Promise => { const twentyFourHoursAgo = new Date(Date.now() - 24 * 60 * 60 * 1000); // Postgres aggregation // We treat everything as a transaction. // We treat everything except TRANSFER_IN as a 'command' (to avoid double counting transfers) const result = await DrizzleClient .select({ hour: sql`date_trunc('hour', ${transactions.createdAt})`, transactions: sql`COUNT(*)`, commands: sql`COUNT(*) FILTER (WHERE ${transactions.type} != ${TransactionType.TRANSFER_IN})` }) .from(transactions) .where(gte(transactions.createdAt, twentyFourHoursAgo)) .groupBy(sql`1`) .orderBy(sql`1`); // Map into a record for easy lookups const dataMap = new Map(); result.forEach(row => { if (!row.hour) return; const dateStr = new Date(row.hour).toISOString(); dataMap.set(dateStr, { commands: Number(row.commands), transactions: Number(row.transactions) }); }); // Generate the last 24 hours of data const activity: ActivityData[] = []; const current = new Date(); current.setHours(current.getHours(), 0, 0, 0); for (let i = 23; i >= 0; i--) { const h = new Date(current.getTime() - i * 60 * 60 * 1000); const iso = h.toISOString(); const existing = dataMap.get(iso); activity.push({ hour: iso, commands: existing?.commands || 0, transactions: existing?.transactions || 0 }); } return activity; }, }; /** * Helper to get icon for transaction type */ function getTransactionIcon(type: string): string { if (type.includes("LOOT")) return "🌠"; if (type.includes("GIFT")) return "🎁"; if (type.includes("SHOP")) return "πŸ›’"; if (type.includes("DAILY")) return "β˜€οΈ"; if (type.includes("QUEST")) return "πŸ“œ"; if (type.includes("TRANSFER")) return "πŸ’Έ"; return "πŸ’«"; } /** * Helper to get icon for moderation type */ function getModerationIcon(type: string): string { switch (type) { case 'warn': return '⚠️'; case 'timeout': return '⏸️'; case 'kick': return 'πŸ‘’'; case 'ban': return 'πŸ”¨'; case 'note': return 'πŸ“'; case 'prune': return '🧹'; default: return 'πŸ›‘οΈ'; } }