mirror of
https://github.com/Such-Software/wownerogue.git
synced 2026-10-22 08:38:33 +02:00
feat: admin dashboard, alert service, and docs reorganization
- Add alertService.js with Resend email alerts for low balance, disconnects, failed payouts - Update admin.html with full dashboard UI (stats, charts, tables, payout retry) - Add wallet balance to /health endpoint - Add getBalance() and checkTransactionStatus() to walletRPCService - Fix payoutRetryService query and add alert integration - Update .env.example with alert config (RESEND_API_KEY, ADMIN_EMAIL, etc) - Reorganize docs: move TODO.md, LOGS_AND_BACKUP.md to docs/ - Extract deployment guide from README to docs/DEPLOY.md - Consolidate TEMP_TODO.md into docs/TODO.md
This commit is contained in:
+28
-1
@@ -295,7 +295,34 @@ RPC_POLL_INTERVAL=2000
|
||||
# curl -H "X-Admin-Key: your-key" http://localhost:3000/api/admin/...
|
||||
|
||||
# =============================================================================
|
||||
# SECTION 12: DEVELOPMENT/DEBUG OPTIONS
|
||||
# SECTION 12: SERVER & MULTI-INSTANCE
|
||||
# =============================================================================
|
||||
# Port for HTTP server (default 3000)
|
||||
# Use different ports for multiple instances (e.g., 3000 for WOW, 3001 for XMR)
|
||||
PORT=3000
|
||||
|
||||
# =============================================================================
|
||||
# SECTION 13: ADMIN ALERTS (Resend)
|
||||
# =============================================================================
|
||||
# Email alerts for low wallet balance and critical issues.
|
||||
# Sign up at https://resend.com to get an API key.
|
||||
|
||||
# Resend API key
|
||||
# RESEND_API_KEY=re_xxxxxxxxxxxxx
|
||||
|
||||
# Admin email to receive alerts
|
||||
# ADMIN_EMAIL=admin@example.com
|
||||
|
||||
# Low balance threshold in atomic units (triggers email alert)
|
||||
# Default: 100000000000 (0.1 XMR/WOW)
|
||||
LOW_BALANCE_THRESHOLD=100000000000
|
||||
|
||||
# Alert cooldown in milliseconds (avoid spam)
|
||||
# Default: 3600000 (1 hour)
|
||||
ALERT_COOLDOWN_MS=3600000
|
||||
|
||||
# =============================================================================
|
||||
# SECTION 14: DEVELOPMENT/DEBUG OPTIONS
|
||||
# =============================================================================
|
||||
# These are for testing only. Don't use in production with real money!
|
||||
|
||||
|
||||
+390
-12
@@ -12,6 +12,7 @@ require('dotenv').config();
|
||||
const DatabaseManager = require('./db/databaseManager');
|
||||
const WalletRPCService = require('./payments/walletRPCService');
|
||||
const PayoutRetryService = require('./payments/payoutRetryService');
|
||||
const AlertService = require('./services/alertService');
|
||||
const GameModeManager = require('./game/gameModeManager');
|
||||
const RpcService = require('./rpc/rpcService');
|
||||
const PaymentConfigManager = require('./config/paymentConfig');
|
||||
@@ -197,15 +198,30 @@ app.post('/api/user/:socketId/address', asyncHandler(async (req, res) => {
|
||||
}));
|
||||
|
||||
// Health check endpoint - enhanced for monitoring dashboard
|
||||
app.get('/health', (req, res) => {
|
||||
app.get('/health', async (req, res) => {
|
||||
const memUsage = process.memoryUsage();
|
||||
|
||||
// Get wallet balance if healthy
|
||||
let walletBalance = null;
|
||||
let lowBalanceWarning = false;
|
||||
const lowBalanceThreshold = parseInt(process.env.LOW_BALANCE_THRESHOLD) || 100000000000; // 0.1 XMR default
|
||||
|
||||
if (walletRPCService.isHealthy) {
|
||||
try {
|
||||
walletBalance = await walletRPCService.getBalance();
|
||||
lowBalanceWarning = walletBalance.unlocked_balance < lowBalanceThreshold;
|
||||
} catch (e) {
|
||||
// Ignore balance fetch errors
|
||||
}
|
||||
}
|
||||
|
||||
const health = {
|
||||
status: 'ok',
|
||||
timestamp: new Date().toISOString(),
|
||||
uptime: process.uptime(),
|
||||
nodeVersion: process.version,
|
||||
environment: process.env.NODE_ENV || 'development',
|
||||
|
||||
|
||||
// Memory details
|
||||
memory: {
|
||||
heapUsed: memUsage.heapUsed,
|
||||
@@ -213,7 +229,7 @@ app.get('/health', (req, res) => {
|
||||
rss: memUsage.rss,
|
||||
external: memUsage.external || 0
|
||||
},
|
||||
|
||||
|
||||
// Game statistics
|
||||
games: {
|
||||
active: activeGames.size,
|
||||
@@ -221,32 +237,34 @@ app.get('/health', (req, res) => {
|
||||
connected: io.sockets.sockets.size || 0,
|
||||
mode: gameModeManager.gameMode
|
||||
},
|
||||
|
||||
|
||||
// Blockchain
|
||||
blockHeight: debugManager.getCurrentBlockHeight(),
|
||||
blockSource: process.env.BLOCK_SOURCE || 'daemon',
|
||||
network: process.env.MONERO_NETWORK || 'mainnet',
|
||||
|
||||
|
||||
// Wallet status
|
||||
wallet: {
|
||||
available: gameModeManager.paymentsEnabled,
|
||||
status: walletRPCService.isHealthy ? 'connected' : 'disconnected',
|
||||
endpoint: process.env.PRIMARY_WALLET_ENDPOINT ? '...' + process.env.PRIMARY_WALLET_ENDPOINT.slice(-15) : null,
|
||||
pendingPayouts: 0 // Could track this in gameModeManager
|
||||
pendingPayouts: 0,
|
||||
balance: walletBalance,
|
||||
lowBalanceWarning
|
||||
},
|
||||
|
||||
|
||||
// Rate limiter stats (if available)
|
||||
rateLimiter: socketHandlers?.rateLimiter ? {
|
||||
trackedEntries: socketHandlers.rateLimiter.getTrackedCount?.() || 0,
|
||||
blockedCount: socketHandlers.rateLimiter.getBlockedCount?.() || 0
|
||||
} : null,
|
||||
|
||||
|
||||
// Limits
|
||||
limits: {
|
||||
maxGamesPerHour: parseInt(process.env.MAX_GAMES_PER_HOUR) || 60,
|
||||
maxPayoutsPerDay: parseInt(process.env.MAX_PAYOUTS_PER_DAY) || 100
|
||||
},
|
||||
|
||||
|
||||
// Legacy fields for compatibility
|
||||
gameMode: gameModeManager.gameMode,
|
||||
paymentsEnabled: gameModeManager.paymentsEnabled,
|
||||
@@ -841,6 +859,354 @@ app.get('/api/admin/users/search', adminAuth, asyncHandler(async (req, res) => {
|
||||
});
|
||||
}));
|
||||
|
||||
// =============================================================================
|
||||
// Admin Stats Endpoints
|
||||
// =============================================================================
|
||||
|
||||
// GET /api/admin/stats/overview - Server-wide statistics
|
||||
app.get('/api/admin/stats/overview', adminAuth, asyncHandler(async (req, res) => {
|
||||
// Get user counts
|
||||
const userStats = await db.query(`
|
||||
SELECT
|
||||
COUNT(*) as total_users,
|
||||
SUM(total_games_played) as total_games,
|
||||
SUM(total_games_won) as total_wins,
|
||||
SUM(total_amount_won) as total_payout_volume,
|
||||
COUNT(*) FILTER (WHERE created_at > NOW() - INTERVAL '24 hours') as new_users_24h
|
||||
FROM users
|
||||
`);
|
||||
|
||||
// Get payout counts
|
||||
const payoutStats = await db.query(`
|
||||
SELECT
|
||||
COUNT(*) FILTER (WHERE status = 'pending') as pending_payouts,
|
||||
COUNT(*) FILTER (WHERE status = 'failed') as failed_payouts,
|
||||
COUNT(*) FILTER (WHERE status = 'completed') as completed_payouts,
|
||||
COUNT(*) FILTER (WHERE status = 'completed' AND processed_at > NOW() - INTERVAL '24 hours') as payouts_24h
|
||||
FROM payouts
|
||||
`);
|
||||
|
||||
// Get game counts for last 24h
|
||||
const gameStats = await db.query(`
|
||||
SELECT
|
||||
COUNT(*) as games_24h,
|
||||
COUNT(*) FILTER (WHERE status = 'won') as wins_24h,
|
||||
COUNT(*) FILTER (WHERE status = 'lost') as losses_24h
|
||||
FROM games
|
||||
WHERE created_at > NOW() - INTERVAL '24 hours'
|
||||
`);
|
||||
|
||||
// Get wallet balance
|
||||
let walletBalance = null;
|
||||
if (walletRPCService.isHealthy) {
|
||||
try {
|
||||
walletBalance = await walletRPCService.getBalance();
|
||||
} catch (e) {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
|
||||
const user = userStats.rows[0];
|
||||
const payout = payoutStats.rows[0];
|
||||
const game = gameStats.rows[0];
|
||||
|
||||
res.json({
|
||||
totalUsers: parseInt(user.total_users) || 0,
|
||||
totalGamesPlayed: parseInt(user.total_games) || 0,
|
||||
totalGamesWon: parseInt(user.total_wins) || 0,
|
||||
totalPayoutVolume: user.total_payout_volume || '0',
|
||||
pendingPayouts: parseInt(payout.pending_payouts) || 0,
|
||||
failedPayouts: parseInt(payout.failed_payouts) || 0,
|
||||
completedPayouts: parseInt(payout.completed_payouts) || 0,
|
||||
walletBalance: walletBalance,
|
||||
last24h: {
|
||||
games: parseInt(game.games_24h) || 0,
|
||||
wins: parseInt(game.wins_24h) || 0,
|
||||
losses: parseInt(game.losses_24h) || 0,
|
||||
payouts: parseInt(payout.payouts_24h) || 0,
|
||||
newUsers: parseInt(user.new_users_24h) || 0
|
||||
}
|
||||
});
|
||||
}));
|
||||
|
||||
// GET /api/admin/stats/payouts - Payout details
|
||||
app.get('/api/admin/stats/payouts', adminAuth, asyncHandler(async (req, res) => {
|
||||
const { status, limit = 50, offset = 0 } = req.query;
|
||||
const limitNum = Math.min(parseInt(limit) || 50, 200);
|
||||
const offsetNum = parseInt(offset) || 0;
|
||||
|
||||
let whereClause = '';
|
||||
const params = [limitNum, offsetNum];
|
||||
|
||||
if (status && ['pending', 'failed', 'completed', 'permanently_failed'].includes(status)) {
|
||||
whereClause = 'WHERE status = $3';
|
||||
params.push(status);
|
||||
}
|
||||
|
||||
const result = await db.query(`
|
||||
SELECT p.id, p.game_id, p.user_id, p.amount, p.payout_address, p.status,
|
||||
p.tx_hash, p.fee, p.retry_count, p.last_error, p.created_at, p.processed_at
|
||||
FROM payouts p
|
||||
${whereClause}
|
||||
ORDER BY p.created_at DESC
|
||||
LIMIT $1 OFFSET $2
|
||||
`, params);
|
||||
|
||||
// Get totals
|
||||
const totals = await db.query(`
|
||||
SELECT
|
||||
COUNT(*) FILTER (WHERE status = 'pending') as pending_count,
|
||||
COUNT(*) FILTER (WHERE status = 'failed') as failed_count,
|
||||
COUNT(*) FILTER (WHERE status = 'completed') as completed_count,
|
||||
COUNT(*) FILTER (WHERE status = 'permanently_failed') as permanently_failed_count,
|
||||
COALESCE(SUM(amount) FILTER (WHERE status = 'completed'), 0) as total_volume
|
||||
FROM payouts
|
||||
`);
|
||||
|
||||
res.json({
|
||||
payouts: result.rows.map(p => ({
|
||||
id: p.id,
|
||||
gameId: p.game_id,
|
||||
userId: p.user_id,
|
||||
amount: p.amount,
|
||||
address: p.payout_address,
|
||||
status: p.status,
|
||||
txHash: p.tx_hash,
|
||||
fee: p.fee,
|
||||
retryCount: p.retry_count,
|
||||
lastError: p.last_error,
|
||||
createdAt: p.created_at,
|
||||
processedAt: p.processed_at
|
||||
})),
|
||||
totals: {
|
||||
pending: parseInt(totals.rows[0].pending_count) || 0,
|
||||
failed: parseInt(totals.rows[0].failed_count) || 0,
|
||||
completed: parseInt(totals.rows[0].completed_count) || 0,
|
||||
permanentlyFailed: parseInt(totals.rows[0].permanently_failed_count) || 0,
|
||||
totalVolume: totals.rows[0].total_volume || '0'
|
||||
}
|
||||
});
|
||||
}));
|
||||
|
||||
// GET /api/admin/stats/games - Game statistics
|
||||
app.get('/api/admin/stats/games', adminAuth, asyncHandler(async (req, res) => {
|
||||
const { period = '24h', limit = 50 } = req.query;
|
||||
const limitNum = Math.min(parseInt(limit) || 50, 200);
|
||||
|
||||
// Parse period
|
||||
let interval = '24 hours';
|
||||
if (period === '7d') interval = '7 days';
|
||||
else if (period === '30d') interval = '30 days';
|
||||
|
||||
// Summary stats
|
||||
const summary = await db.query(`
|
||||
SELECT
|
||||
COUNT(*) as total,
|
||||
COUNT(*) FILTER (WHERE status = 'won') as won,
|
||||
COUNT(*) FILTER (WHERE status = 'lost') as lost,
|
||||
COUNT(*) FILTER (WHERE outcome = 'escaped') as escaped,
|
||||
COUNT(*) FILTER (WHERE outcome = 'caught_by_monster') as caught,
|
||||
COUNT(*) FILTER (WHERE outcome = 'expired') as expired,
|
||||
AVG(duration_seconds) as avg_duration
|
||||
FROM games
|
||||
WHERE created_at > NOW() - INTERVAL '${interval}'
|
||||
`);
|
||||
|
||||
// By game mode
|
||||
const byMode = await db.query(`
|
||||
SELECT game_mode, COUNT(*) as count
|
||||
FROM games
|
||||
WHERE created_at > NOW() - INTERVAL '${interval}'
|
||||
GROUP BY game_mode
|
||||
`);
|
||||
|
||||
// Top winners
|
||||
const topWinners = await db.query(`
|
||||
SELECT u.socket_id, u.total_games_won as wins, u.total_amount_won as total_won
|
||||
FROM users u
|
||||
WHERE u.total_games_won > 0
|
||||
ORDER BY u.total_amount_won DESC
|
||||
LIMIT 10
|
||||
`);
|
||||
|
||||
// Recent games
|
||||
const recentGames = await db.query(`
|
||||
SELECT g.id, g.game_mode, g.status, g.outcome, g.duration_seconds, g.created_at,
|
||||
p.amount as payout_amount
|
||||
FROM games g
|
||||
LEFT JOIN payouts p ON p.game_id = g.id AND p.status = 'completed'
|
||||
ORDER BY g.created_at DESC
|
||||
LIMIT $1
|
||||
`, [limitNum]);
|
||||
|
||||
const s = summary.rows[0];
|
||||
const modeMap = {};
|
||||
byMode.rows.forEach(r => { modeMap[r.game_mode] = parseInt(r.count) || 0; });
|
||||
|
||||
res.json({
|
||||
summary: {
|
||||
total: parseInt(s.total) || 0,
|
||||
won: parseInt(s.won) || 0,
|
||||
lost: parseInt(s.lost) || 0,
|
||||
escaped: parseInt(s.escaped) || 0,
|
||||
caught: parseInt(s.caught) || 0,
|
||||
expired: parseInt(s.expired) || 0
|
||||
},
|
||||
avgDuration: parseFloat(s.avg_duration) || 0,
|
||||
byMode: modeMap,
|
||||
topWinners: topWinners.rows.map(w => ({
|
||||
socketId: w.socket_id?.substring(0, 20) + '...',
|
||||
wins: parseInt(w.wins) || 0,
|
||||
totalWon: w.total_won || '0'
|
||||
})),
|
||||
recentGames: recentGames.rows.map(g => ({
|
||||
id: g.id,
|
||||
mode: g.game_mode,
|
||||
status: g.status,
|
||||
outcome: g.outcome,
|
||||
duration: g.duration_seconds,
|
||||
payoutAmount: g.payout_amount,
|
||||
createdAt: g.created_at
|
||||
}))
|
||||
});
|
||||
}));
|
||||
|
||||
// GET /api/admin/users - Paginated user list
|
||||
app.get('/api/admin/users', adminAuth, asyncHandler(async (req, res) => {
|
||||
const { search, limit = 50, offset = 0 } = req.query;
|
||||
const limitNum = Math.min(parseInt(limit) || 50, 200);
|
||||
const offsetNum = parseInt(offset) || 0;
|
||||
|
||||
let whereClause = '';
|
||||
const params = [limitNum, offsetNum];
|
||||
|
||||
if (search && search.length >= 3) {
|
||||
whereClause = 'WHERE socket_id ILIKE $3 OR payout_address ILIKE $3';
|
||||
params.push(`%${search}%`);
|
||||
}
|
||||
|
||||
const result = await db.query(`
|
||||
SELECT id, socket_id, payout_address, credits, total_games_played, total_games_won,
|
||||
total_amount_paid, total_amount_won, total_credits_purchased, created_at
|
||||
FROM users
|
||||
${whereClause}
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $1 OFFSET $2
|
||||
`, params);
|
||||
|
||||
const countResult = await db.query(`
|
||||
SELECT COUNT(*) as total FROM users ${whereClause}
|
||||
`, search && search.length >= 3 ? [`%${search}%`] : []);
|
||||
|
||||
res.json({
|
||||
users: result.rows.map(u => ({
|
||||
id: u.id,
|
||||
socketId: u.socket_id,
|
||||
payoutAddress: u.payout_address ? `${u.payout_address.substring(0, 15)}...` : null,
|
||||
credits: u.credits || 0,
|
||||
gamesPlayed: parseInt(u.total_games_played) || 0,
|
||||
gamesWon: parseInt(u.total_games_won) || 0,
|
||||
totalPaid: u.total_amount_paid || '0',
|
||||
totalWon: u.total_amount_won || '0',
|
||||
creditsPurchased: parseInt(u.total_credits_purchased) || 0,
|
||||
createdAt: u.created_at
|
||||
})),
|
||||
total: parseInt(countResult.rows[0]?.total) || 0,
|
||||
limit: limitNum,
|
||||
offset: offsetNum
|
||||
});
|
||||
}));
|
||||
|
||||
// GET /api/admin/users/:id - Detailed user info
|
||||
app.get('/api/admin/users/:id', adminAuth, asyncHandler(async (req, res) => {
|
||||
const { id } = req.params;
|
||||
|
||||
const userResult = await db.query(`
|
||||
SELECT * FROM users WHERE id = $1
|
||||
`, [id]);
|
||||
|
||||
if (!userResult.rows.length) {
|
||||
throw new NotFoundError('User not found');
|
||||
}
|
||||
|
||||
const user = userResult.rows[0];
|
||||
|
||||
// Get user's games
|
||||
const gamesResult = await db.query(`
|
||||
SELECT id, game_mode, status, outcome, treasure_found, moves_made, duration_seconds, created_at
|
||||
FROM games
|
||||
WHERE user_id = $1
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 50
|
||||
`, [id]);
|
||||
|
||||
// Get user's payments
|
||||
const paymentsResult = await db.query(`
|
||||
SELECT id, amount, status, tx_hash, credits_purchased, created_at
|
||||
FROM payments
|
||||
WHERE user_id = $1
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 50
|
||||
`, [id]);
|
||||
|
||||
// Get user's payouts
|
||||
const payoutsResult = await db.query(`
|
||||
SELECT id, amount, status, tx_hash, fee, payout_address, created_at, processed_at
|
||||
FROM payouts
|
||||
WHERE user_id = $1
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 50
|
||||
`, [id]);
|
||||
|
||||
res.json({
|
||||
user: {
|
||||
id: user.id,
|
||||
socketId: user.socket_id,
|
||||
payoutAddress: user.payout_address,
|
||||
credits: user.credits || 0,
|
||||
gamesPlayed: parseInt(user.total_games_played) || 0,
|
||||
gamesWon: parseInt(user.total_games_won) || 0,
|
||||
totalPaid: user.total_amount_paid || '0',
|
||||
totalWon: user.total_amount_won || '0',
|
||||
creditsPurchased: parseInt(user.total_credits_purchased) || 0,
|
||||
createdAt: user.created_at
|
||||
},
|
||||
games: gamesResult.rows,
|
||||
payments: paymentsResult.rows,
|
||||
payouts: payoutsResult.rows
|
||||
});
|
||||
}));
|
||||
|
||||
// POST /api/admin/payouts/:id/retry - Manually retry a failed payout
|
||||
app.post('/api/admin/payouts/:id/retry', adminAuth, asyncHandler(async (req, res) => {
|
||||
const { id } = req.params;
|
||||
|
||||
const payoutResult = await db.query(`
|
||||
SELECT * FROM payouts WHERE id = $1
|
||||
`, [id]);
|
||||
|
||||
if (!payoutResult.rows.length) {
|
||||
throw new NotFoundError('Payout not found');
|
||||
}
|
||||
|
||||
const payout = payoutResult.rows[0];
|
||||
|
||||
if (payout.status === 'completed') {
|
||||
throw new ValidationError('Payout already completed');
|
||||
}
|
||||
|
||||
// Reset status to pending for retry
|
||||
await db.query(`
|
||||
UPDATE payouts SET status = 'pending', last_error = 'Manual retry requested' WHERE id = $1
|
||||
`, [id]);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Payout queued for retry',
|
||||
payoutId: id
|
||||
});
|
||||
}));
|
||||
|
||||
app.get('/api/game-modes', (req, res) => {
|
||||
const config = paymentConfigManager.getConfig();
|
||||
const decimals = Number(config.currency?.decimals ?? 12);
|
||||
@@ -1110,8 +1476,9 @@ async function startServer() {
|
||||
});
|
||||
|
||||
// Start HTTP server
|
||||
http.listen(3000, function() {
|
||||
console.log('🚀 Wownerogue server listening on *:3000');
|
||||
const PORT = process.env.PORT || 3000;
|
||||
http.listen(PORT, function() {
|
||||
console.log(`🚀 Wownerogue server listening on *:${PORT}`);
|
||||
console.log(`🐛 Debug mode: ${debugManager.getDebugStatus().debugMode ? 'ENABLED' : 'DISABLED'}`);
|
||||
console.log(`💰 Payment system: ${paymentSystemReady ? 'ENABLED' : 'FREE MODE ONLY'}`);
|
||||
const summary = paymentConfigManager.summarize();
|
||||
@@ -1147,14 +1514,25 @@ async function startServer() {
|
||||
// Start payout retry service for failed/stuck payouts
|
||||
const maxRetries = Number(process.env.PAYOUT_MAX_RETRIES) || 3;
|
||||
const retryIntervalMs = Number(process.env.PAYOUT_RETRY_INTERVAL_MS) || 300000; // 5 minutes
|
||||
// Initialize alert service for email notifications
|
||||
const alertService = new AlertService({
|
||||
walletService: walletRPCService,
|
||||
db: databaseManager,
|
||||
debugManager
|
||||
});
|
||||
|
||||
const payoutRetryService = new PayoutRetryService({
|
||||
db: databaseManager,
|
||||
walletService: walletRPCService,
|
||||
debugManager,
|
||||
maxRetries,
|
||||
retryIntervalMs
|
||||
retryIntervalMs,
|
||||
alertService // Pass alert service for failure notifications
|
||||
});
|
||||
payoutRetryService.start();
|
||||
|
||||
// Start periodic alert checks (every 5 minutes)
|
||||
alertService.startPeriodicChecks(300000);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
|
||||
@@ -38,6 +38,7 @@
|
||||
"socket.io-client": "^4.8.1",
|
||||
"uuid": "^9.0.0"
|
||||
,"qrcode": "^1.5.3",
|
||||
"resend": "^4.0.0",
|
||||
"tweetnacl": "^1.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -8,12 +8,13 @@
|
||||
const { AppError } = require('../utils/errors');
|
||||
|
||||
class PayoutRetryService {
|
||||
constructor({ db, walletService, debugManager, maxRetries = 3, retryIntervalMs = 300000 }) {
|
||||
constructor({ db, walletService, debugManager, maxRetries = 3, retryIntervalMs = 300000, alertService = null }) {
|
||||
this.db = db;
|
||||
this.walletService = walletService;
|
||||
this.debugManager = debugManager;
|
||||
this.maxRetries = maxRetries;
|
||||
this.retryIntervalMs = retryIntervalMs; // Default: 5 minutes
|
||||
this.alertService = alertService; // For sending failure notifications
|
||||
this.isProcessing = false;
|
||||
this.retryTimer = null;
|
||||
}
|
||||
@@ -208,6 +209,22 @@ class PayoutRetryService {
|
||||
|
||||
if (newStatus === 'permanently_failed') {
|
||||
console.error(`❌ Payout ${id} permanently failed after ${newRetryCount} attempts: ${error.message}`);
|
||||
|
||||
// Send email alert for permanent failure
|
||||
if (this.alertService) {
|
||||
try {
|
||||
await this.alertService.alertPayoutFailed({
|
||||
id,
|
||||
game_id,
|
||||
amount,
|
||||
payout_address,
|
||||
retry_count: newRetryCount,
|
||||
last_error: error.message
|
||||
});
|
||||
} catch (alertError) {
|
||||
console.error('Failed to send payout failure alert:', alertError.message);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.warn(`⚠️ Payout ${id} retry failed (attempt ${newRetryCount}/${this.maxRetries}): ${error.message}`);
|
||||
}
|
||||
|
||||
@@ -318,6 +318,30 @@ class WalletRPCService {
|
||||
return truncated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get wallet balance for admin monitoring
|
||||
* @returns {Object} - { balance: bigint, unlocked_balance: bigint } in atomic units
|
||||
*/
|
||||
async getBalance() {
|
||||
try {
|
||||
const response = await this.rpcCall('get_balance', {
|
||||
account_index: this.accountIndex
|
||||
});
|
||||
|
||||
return {
|
||||
balance: response.result?.balance || 0,
|
||||
unlocked_balance: response.result?.unlocked_balance || 0
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to get wallet balance:', error.message);
|
||||
return {
|
||||
balance: 0,
|
||||
unlocked_balance: 0,
|
||||
error: error.message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a payout address using wallet RPC
|
||||
* @param {string} address - The address to validate
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
/**
|
||||
* AlertService - Email alerts for admin monitoring
|
||||
*
|
||||
* Sends alerts via Resend for:
|
||||
* - Low wallet balance
|
||||
* - Failed payouts
|
||||
* - Wallet disconnection
|
||||
* - High pending payout queue
|
||||
*/
|
||||
|
||||
class AlertService {
|
||||
constructor({ walletService, db, debugManager }) {
|
||||
this.walletService = walletService;
|
||||
this.db = db;
|
||||
this.debugManager = debugManager;
|
||||
|
||||
// Resend configuration
|
||||
this.resendApiKey = process.env.RESEND_API_KEY;
|
||||
this.adminEmail = process.env.ADMIN_EMAIL;
|
||||
this.fromEmail = process.env.ALERT_FROM_EMAIL || 'alerts@monerogue.app';
|
||||
|
||||
// Thresholds
|
||||
this.lowBalanceThreshold = parseInt(process.env.LOW_BALANCE_THRESHOLD) || 100000000000; // 0.1 XMR
|
||||
this.highPendingThreshold = parseInt(process.env.HIGH_PENDING_THRESHOLD) || 10;
|
||||
|
||||
// Cooldowns (prevent alert spam)
|
||||
this.alertCooldown = parseInt(process.env.ALERT_COOLDOWN_MS) || 3600000; // 1 hour
|
||||
this.lastAlertSent = new Map();
|
||||
|
||||
// Track wallet disconnect duration
|
||||
this.walletDisconnectedSince = null;
|
||||
this.walletDisconnectAlertThreshold = 300000; // 5 minutes
|
||||
|
||||
this.enabled = !!(this.resendApiKey && this.adminEmail);
|
||||
|
||||
if (this.enabled) {
|
||||
console.log('📧 Alert service enabled - alerts will be sent to', this.adminEmail);
|
||||
} else {
|
||||
console.log('📧 Alert service disabled - set RESEND_API_KEY and ADMIN_EMAIL to enable');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an alert type is on cooldown
|
||||
*/
|
||||
isOnCooldown(alertType) {
|
||||
const lastSent = this.lastAlertSent.get(alertType) || 0;
|
||||
return Date.now() - lastSent < this.alertCooldown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send an email alert via Resend
|
||||
*/
|
||||
async sendAlert(type, { subject, html }) {
|
||||
if (!this.enabled) {
|
||||
return { sent: false, reason: 'Alert service not configured' };
|
||||
}
|
||||
|
||||
if (this.isOnCooldown(type)) {
|
||||
return { sent: false, reason: 'On cooldown' };
|
||||
}
|
||||
|
||||
try {
|
||||
// Dynamic import to avoid requiring resend if not configured
|
||||
const { Resend } = require('resend');
|
||||
const resend = new Resend(this.resendApiKey);
|
||||
|
||||
const result = await resend.emails.send({
|
||||
from: this.fromEmail,
|
||||
to: this.adminEmail,
|
||||
subject: subject,
|
||||
html: html
|
||||
});
|
||||
|
||||
this.lastAlertSent.set(type, Date.now());
|
||||
console.log(`📧 Alert sent: ${type} - ${subject}`);
|
||||
|
||||
return { sent: true, id: result.data?.id };
|
||||
} catch (error) {
|
||||
console.error(`❌ Failed to send alert: ${error.message}`);
|
||||
return { sent: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check wallet balance and alert if low
|
||||
*/
|
||||
async checkWalletBalance() {
|
||||
if (!this.walletService?.isHealthy) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const balance = await this.walletService.getBalance();
|
||||
|
||||
if (balance.unlocked_balance < this.lowBalanceThreshold) {
|
||||
const balanceFormatted = (balance.unlocked_balance / 1e12).toFixed(6);
|
||||
const thresholdFormatted = (this.lowBalanceThreshold / 1e12).toFixed(6);
|
||||
const cryptoType = process.env.CRYPTO_TYPE || 'XMR';
|
||||
|
||||
await this.sendAlert('low_balance', {
|
||||
subject: `⚠️ Wallet Balance Low - ${balanceFormatted} ${cryptoType}`,
|
||||
html: `
|
||||
<h2>Low Wallet Balance Warning</h2>
|
||||
<p>The payout wallet balance is running low:</p>
|
||||
<ul>
|
||||
<li><strong>Current Balance:</strong> ${balanceFormatted} ${cryptoType}</li>
|
||||
<li><strong>Unlocked:</strong> ${balanceFormatted} ${cryptoType}</li>
|
||||
<li><strong>Threshold:</strong> ${thresholdFormatted} ${cryptoType}</li>
|
||||
</ul>
|
||||
<p>Please top up the wallet to ensure payouts can continue.</p>
|
||||
<hr>
|
||||
<p style="color: #666; font-size: 12px;">
|
||||
Server: ${process.env.NODE_ENV || 'development'}<br>
|
||||
Time: ${new Date().toISOString()}
|
||||
</p>
|
||||
`
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to check wallet balance for alerts:', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for wallet disconnection
|
||||
*/
|
||||
async checkWalletConnection() {
|
||||
if (this.walletService?.isHealthy) {
|
||||
this.walletDisconnectedSince = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.walletDisconnectedSince) {
|
||||
this.walletDisconnectedSince = Date.now();
|
||||
return;
|
||||
}
|
||||
|
||||
const disconnectedFor = Date.now() - this.walletDisconnectedSince;
|
||||
|
||||
if (disconnectedFor > this.walletDisconnectAlertThreshold) {
|
||||
const minutes = Math.floor(disconnectedFor / 60000);
|
||||
|
||||
await this.sendAlert('wallet_disconnect', {
|
||||
subject: `🔴 Wallet RPC Disconnected (${minutes}+ min)`,
|
||||
html: `
|
||||
<h2>Wallet RPC Connection Lost</h2>
|
||||
<p>The wallet RPC has been unreachable for <strong>${minutes} minutes</strong>.</p>
|
||||
<p>Payouts cannot be processed while the wallet is disconnected.</p>
|
||||
<p>Please check:</p>
|
||||
<ul>
|
||||
<li>Is the wallet-rpc process running?</li>
|
||||
<li>Is the wallet file accessible?</li>
|
||||
<li>Is there enough disk space?</li>
|
||||
</ul>
|
||||
<hr>
|
||||
<p style="color: #666; font-size: 12px;">
|
||||
Endpoint: ${process.env.PRIMARY_WALLET_ENDPOINT || 'not configured'}<br>
|
||||
Time: ${new Date().toISOString()}
|
||||
</p>
|
||||
`
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for pending payouts queue
|
||||
*/
|
||||
async checkPendingPayouts() {
|
||||
if (!this.db) return;
|
||||
|
||||
try {
|
||||
const result = await this.db.query(`
|
||||
SELECT COUNT(*) as count FROM payouts
|
||||
WHERE status = 'pending' OR status = 'failed'
|
||||
`);
|
||||
|
||||
const pendingCount = parseInt(result.rows[0]?.count) || 0;
|
||||
|
||||
if (pendingCount >= this.highPendingThreshold) {
|
||||
await this.sendAlert('high_pending', {
|
||||
subject: `⚠️ High Pending Payouts: ${pendingCount}`,
|
||||
html: `
|
||||
<h2>High Pending Payout Queue</h2>
|
||||
<p>There are <strong>${pendingCount}</strong> pending or failed payouts in the queue.</p>
|
||||
<p>This may indicate:</p>
|
||||
<ul>
|
||||
<li>Wallet connectivity issues</li>
|
||||
<li>Insufficient balance</li>
|
||||
<li>Network congestion</li>
|
||||
</ul>
|
||||
<p>Please check the admin dashboard for details.</p>
|
||||
<hr>
|
||||
<p style="color: #666; font-size: 12px;">
|
||||
Threshold: ${this.highPendingThreshold}<br>
|
||||
Time: ${new Date().toISOString()}
|
||||
</p>
|
||||
`
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to check pending payouts for alerts:', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Alert when a payout permanently fails
|
||||
*/
|
||||
async alertPayoutFailed(payout) {
|
||||
const cryptoType = process.env.CRYPTO_TYPE || 'XMR';
|
||||
const amountFormatted = (payout.amount / 1e12).toFixed(6);
|
||||
|
||||
await this.sendAlert('payout_failed', {
|
||||
subject: `💀 Payout Permanently Failed - ${amountFormatted} ${cryptoType}`,
|
||||
html: `
|
||||
<h2>Payout Permanently Failed</h2>
|
||||
<p>A payout has failed after all retry attempts:</p>
|
||||
<ul>
|
||||
<li><strong>Payout ID:</strong> ${payout.id}</li>
|
||||
<li><strong>Game ID:</strong> ${payout.game_id}</li>
|
||||
<li><strong>Amount:</strong> ${amountFormatted} ${cryptoType}</li>
|
||||
<li><strong>Address:</strong> ${payout.payout_address?.substring(0, 20)}...</li>
|
||||
<li><strong>Retry Count:</strong> ${payout.retry_count}</li>
|
||||
<li><strong>Last Error:</strong> ${payout.last_error || 'Unknown'}</li>
|
||||
</ul>
|
||||
<p>Manual intervention may be required.</p>
|
||||
<hr>
|
||||
<p style="color: #666; font-size: 12px;">
|
||||
Time: ${new Date().toISOString()}
|
||||
</p>
|
||||
`
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Run all checks (call this periodically)
|
||||
*/
|
||||
async runChecks() {
|
||||
await this.checkWalletBalance();
|
||||
await this.checkWalletConnection();
|
||||
await this.checkPendingPayouts();
|
||||
}
|
||||
|
||||
/**
|
||||
* Start periodic checking
|
||||
*/
|
||||
startPeriodicChecks(intervalMs = 300000) { // Default 5 minutes
|
||||
if (!this.enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Run initial check after a short delay
|
||||
setTimeout(() => this.runChecks(), 10000);
|
||||
|
||||
// Then run periodically
|
||||
this.checkInterval = setInterval(() => {
|
||||
this.runChecks();
|
||||
}, intervalMs);
|
||||
|
||||
console.log(`📧 Alert checks will run every ${intervalMs / 60000} minutes`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop periodic checking
|
||||
*/
|
||||
stopPeriodicChecks() {
|
||||
if (this.checkInterval) {
|
||||
clearInterval(this.checkInterval);
|
||||
this.checkInterval = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = AlertService;
|
||||
Reference in New Issue
Block a user