Files
AuroraBot-discord/shared/modules/dashboard/dashboard.service.ts

271 lines
8.7 KiB
TypeScript

import { DrizzleClient } from "@shared/db/DrizzleClient";
import { users, transactions, moderationCases, inventory, lootdrops, 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<number> => {
const result = await DrizzleClient
.select({ count: sql<string>`COUNT(*)` })
.from(users)
.where(sql`${users.isActive} = true`);
return Number(result[0]?.count || 0);
},
/**
* Get total user count
*/
getTotalUserCount: async (): Promise<number> => {
const result = await DrizzleClient
.select({ count: sql<string>`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<number> => {
const result = await DrizzleClient
.select({ total: sql<string>`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<RecentEvent[]> => {
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<RecentEvent[]> => {
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<RecentEvent[]> => {
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<RecentEvent, 'timestamp'>): Promise<void> => {
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<ActivityData[]> => {
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<string>`date_trunc('hour', ${transactions.createdAt})`,
transactions: sql<string>`COUNT(*)`,
commands: sql<string>`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<string, { commands: number, transactions: number }>();
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;
},
/**
* Get active lootdrops
*/
getActiveLootdrops: async () => {
const activeDrops = await DrizzleClient.query.lootdrops.findMany({
where: (lootdrops, { isNull }) => isNull(lootdrops.claimedBy),
limit: 1,
orderBy: desc(lootdrops.createdAt)
});
return activeDrops;
},
/**
* Get leaderboards (Top 3 Levels and Wealth)
*/
getLeaderboards: async () => {
const topLevels = await DrizzleClient.select({
username: users.username,
level: users.level,
})
.from(users)
.orderBy(desc(users.level))
.limit(3);
const topWealth = await DrizzleClient.select({
username: users.username,
balance: users.balance,
})
.from(users)
.orderBy(desc(users.balance))
.limit(3);
return {
topLevels,
topWealth: topWealth.map(u => ({ ...u, balance: (u.balance || 0n).toString() }))
};
}
};
/**
* 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 '🛡️';
}
}