fix: chat scrollback and admin control

This commit is contained in:
jwinterm
2026-02-03 12:02:52 -05:00
parent 10dfd932d8
commit a56063867e
8 changed files with 565 additions and 52 deletions
+172
View File
@@ -284,6 +284,7 @@
<button class="tab-btn" data-tab="payouts">Payouts</button>
<button class="tab-btn" data-tab="games">Games</button>
<button class="tab-btn" data-tab="users">Users</button>
<button class="tab-btn" data-tab="chat">Chat</button>
</div>
<!-- Tab: Overview -->
@@ -406,6 +407,40 @@
<div class="pagination" id="usersPagination"></div>
</div>
</div>
<!-- Tab: Chat -->
<div id="tab-chat" class="tab-content">
<div class="table-container">
<div class="table-header">
<h3>Chat Moderation</h3>
<div style="display: flex; gap: 10px; align-items: center;">
<label style="color:#888; font-size:12px;">
<input type="checkbox" id="showDeletedMessages" onchange="loadChat()"> Show deleted
</label>
<button onclick="loadChat()" style="background:#055; color:#0f0; border:1px solid #0f0; padding:4px 10px; cursor:pointer;">🔄 Refresh</button>
</div>
</div>
<p style="color:#888; margin: 10px 0; font-size:12px;">
Delete offensive messages (they'll be removed from all clients). Ban users from chat if needed.
</p>
<table>
<thead>
<tr>
<th>ID</th>
<th>Time</th>
<th>Player</th>
<th>Message</th>
<th>User ID</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="chatTable">
<tr><td colspan="7" class="loading">Loading...</td></tr>
</tbody>
</table>
</div>
</div>
</main>
</div>
@@ -500,6 +535,7 @@
else if (tab === 'payouts') loadPayouts();
else if (tab === 'games') loadGames();
else if (tab === 'users') loadUsers();
else if (tab === 'chat') loadChat();
}
async function loadDashboard() {
@@ -806,6 +842,142 @@
loadUsers(0);
}
// Chat moderation functions
async function loadChat() {
const includeDeleted = document.getElementById('showDeletedMessages')?.checked || false;
try {
const url = `/api/admin/chat?limit=100&includeDeleted=${includeDeleted}`;
const res = await fetch(url, { headers: { 'X-Admin-Key': apiKey } });
const data = await res.json();
const tbody = document.getElementById('chatTable');
if (!data.messages?.length) {
tbody.innerHTML = '<tr><td colspan="7" class="loading">No chat messages</td></tr>';
return;
}
tbody.innerHTML = data.messages.map(m => {
const isDeleted = !!m.deletedAt;
const isBanned = m.chatBanned;
const rowStyle = isDeleted ? 'opacity: 0.5; text-decoration: line-through;' : '';
return `
<tr style="${rowStyle}">
<td>${m.id}</td>
<td>${formatTimeAgo(new Date(m.timestamp))}</td>
<td>${m.playerId || m.username || '-'}</td>
<td style="max-width: 300px; overflow: hidden; text-overflow: ellipsis;" title="${escapeHtml(m.message)}">${escapeHtml(m.message)}</td>
<td>${m.userId || '-'}</td>
<td>
${isDeleted ? '<span class="status-badge status-failed">Deleted</span>' : ''}
${isBanned ? '<span class="status-badge status-failed">Banned</span>' : ''}
${!isDeleted && !isBanned ? '<span class="status-badge status-completed">Active</span>' : ''}
</td>
<td>
${!isDeleted ? `<button class="btn btn-primary" onclick="deleteMessage(${m.id})" style="background:#c33;">Delete</button>` : ''}
${m.userId && !isBanned ? `<button class="btn btn-primary" onclick="banUser(${m.userId})" style="background:#a00; margin-left:4px;">Ban</button>` : ''}
${m.userId && isBanned ? `<button class="btn btn-primary" onclick="unbanUser(${m.userId})" style="background:#060; margin-left:4px;">Unban</button>` : ''}
</td>
</tr>
`;
}).join('');
} catch (e) {
console.error('Failed to load chat:', e);
document.getElementById('chatTable').innerHTML = '<tr><td colspan="7" class="loading">Error loading chat</td></tr>';
}
}
async function deleteMessage(messageId) {
if (!confirm('Delete this message? It will be removed from all clients.')) return;
try {
const res = await fetch(`/api/admin/chat/${messageId}`, {
method: 'DELETE',
headers: {
'X-Admin-Key': apiKey,
'Content-Type': 'application/json'
},
body: JSON.stringify({ reason: 'Admin deleted' })
});
const data = await res.json();
if (data.success) {
loadChat();
} else {
alert('Failed to delete: ' + (data.message || 'Unknown error'));
}
} catch (e) {
console.error('Failed to delete message:', e);
alert('Error deleting message');
}
}
async function banUser(userId) {
const reason = prompt('Ban reason (optional):');
if (reason === null) return; // Cancelled
try {
const res = await fetch(`/api/admin/users/${userId}/chat-ban`, {
method: 'POST',
headers: {
'X-Admin-Key': apiKey,
'Content-Type': 'application/json'
},
body: JSON.stringify({ banned: true, reason: reason || 'Admin banned' })
});
const data = await res.json();
if (data.success) {
loadChat();
} else {
alert('Failed to ban: ' + (data.message || 'Unknown error'));
}
} catch (e) {
console.error('Failed to ban user:', e);
alert('Error banning user');
}
}
async function unbanUser(userId) {
if (!confirm('Unban this user from chat?')) return;
try {
const res = await fetch(`/api/admin/users/${userId}/chat-ban`, {
method: 'POST',
headers: {
'X-Admin-Key': apiKey,
'Content-Type': 'application/json'
},
body: JSON.stringify({ banned: false })
});
const data = await res.json();
if (data.success) {
loadChat();
} else {
alert('Failed to unban: ' + (data.message || 'Unknown error'));
}
} catch (e) {
console.error('Failed to unban user:', e);
alert('Error unbanning user');
}
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function formatTimeAgo(date) {
const seconds = Math.floor((Date.now() - date.getTime()) / 1000);
if (seconds < 60) return 'just now';
if (seconds < 3600) return Math.floor(seconds / 60) + 'm ago';
if (seconds < 86400) return Math.floor(seconds / 3600) + 'h ago';
return Math.floor(seconds / 86400) + 'd ago';
}
// Auto-refresh every 30 seconds
setInterval(() => {
if (apiKey && currentTab === 'overview') {
+2 -1
View File
@@ -205,7 +205,8 @@
<!-- Chat input in its own row -->
<div class="input-container">
<form id="chatForm" action="">
<input id="chatInput" autocomplete="off" placeholder="Type a command..." />
<input id="chatInput" autocomplete="off" placeholder="Type a command..." maxlength="200" />
<span id="charCounter" style="color:#666; font-size:10px; margin-left:5px; min-width:45px;">0/200</span>
<button type="submit">Send</button>
</form>
</div>
+23 -5
View File
@@ -21,22 +21,39 @@ const InputHandler = {
},
setupChatForm: function() {
const MAX_CHAT_LENGTH = 200;
// Character counter update
$('#chatInput').on('input', function() {
const len = $(this).val().length;
const counter = $('#charCounter');
counter.text(len + '/' + MAX_CHAT_LENGTH);
// Change color when approaching limit
if (len >= MAX_CHAT_LENGTH) {
counter.css('color', '#f66');
} else if (len >= MAX_CHAT_LENGTH * 0.8) {
counter.css('color', '#fa0');
} else {
counter.css('color', '#666');
}
});
$('#chatForm').submit(function(e) {
e.preventDefault();
var msg = $('#chatInput').val().trim();
// Don't send empty messages
if (!msg) return false;
// If the message is "enter", always send it to server
// The server will handle queueing logic and timing
if (msg.toLowerCase() === 'enter') {
socket.emit('chat message', msg);
// Don't automatically switch to waiting screen - let the server control screen state
// The server will send appropriate events (payment_created, waiting_status, etc.)
// to control what the user sees
// Add visual feedback that request was sent
$('#messages').append($('<li style="color: #0f0;">').text("🔑 Processing game entry request..."));
UI.scrollChat();
@@ -44,8 +61,9 @@ const InputHandler = {
// Send other chat messages normally
socket.emit('chat message', msg);
}
$('#chatInput').val('');
$('#charCounter').text('0/' + MAX_CHAT_LENGTH).css('color', '#666');
return false;
});
+44 -8
View File
@@ -120,7 +120,9 @@ const SocketHandlers = {
socket.on('status_update', this.onStatusUpdate);
socket.on('chat_broadcast', this.onChatBroadcast);
socket.on('chat_history', this.onChatHistory);
socket.on('chat_deleted', this.onChatDeleted);
socket.on('chat_error', this.onChatError);
// Game state handlers
socket.on('waiting_status', this.onWaitingStatus);
socket.on('queue_joined', this.onQueueJoined);
@@ -298,27 +300,61 @@ const SocketHandlers = {
onChatHistory: function(data) {
if (!data || !data.messages || !Array.isArray(data.messages)) return;
// Add a separator for history messages
if (data.messages.length > 0) {
$('#messages').append($('<li class="chat-history-header" style="color: #666; font-style: italic; border-bottom: 1px solid #333; margin-bottom: 5px; padding-bottom: 5px;">').text('--- Recent Chat History ---'));
}
// Add each historical message
// Add each historical message with data-msg-id for deletion support
data.messages.forEach(function(msg) {
const msgElement = $('<li style="color: #888;">');
const username = msg.username || (msg.socketId ? msg.socketId.substring(0, 6) : 'anon');
msgElement.html('<strong>' + username + ':</strong> ' + msg.message);
if (msg.id) {
msgElement.attr('data-msg-id', msg.id);
}
const username = msg.playerId || msg.username || (msg.socketId ? msg.socketId.substring(0, 6) : 'anon');
const isSystem = msg.type === 'system' || msg.socketId === 'system';
if (isSystem) {
msgElement.addClass('status');
}
// Add timestamp if available
let timeStr = '';
if (msg.timestamp) {
timeStr = '<span style="color:#555;font-size:10px;">[' + SocketHandlers._formatTimeAgo(new Date(msg.timestamp)) + ']</span> ';
}
msgElement.html(timeStr + '<strong>' + username + ':</strong> ' + msg.message);
$('#messages').append(msgElement);
});
if (data.messages.length > 0) {
$('#messages').append($('<li class="chat-history-footer" style="color: #666; font-style: italic; border-top: 1px solid #333; margin-top: 5px; padding-top: 5px;">').text('--- End of History ---'));
}
UI.scrollChat();
},
onChatDeleted: function(data) {
if (!data || !data.messageId) return;
// Remove deleted message from view with fade effect
$('#messages li[data-msg-id="' + data.messageId + '"]').fadeOut(200, function() {
$(this).remove();
});
},
onChatError: function(data) {
if (!data || !data.message) return;
$('#messages').append($('<li class="error" style="color:#f66;">').text(data.message));
UI.scrollChat();
},
_formatTimeAgo: function(date) {
const seconds = Math.floor((Date.now() - date.getTime()) / 1000);
if (seconds < 60) return 'just now';
if (seconds < 3600) return Math.floor(seconds / 60) + 'm ago';
if (seconds < 86400) return Math.floor(seconds / 3600) + 'h ago';
return Math.floor(seconds / 86400) + 'd ago';
},
onWaitingStatus: function(data) {
if (data.status === 'waiting') {
$('#messages').append($('<li style="color:#ff0;">').text(data.message));
+87
View File
@@ -996,6 +996,93 @@ app.post('/api/admin/queue/remove', adminAuth, asyncHandler(async (req, res) =>
});
}));
// =============================================================================
// ADMIN CHAT MODERATION ENDPOINTS
// =============================================================================
/**
* GET /api/admin/chat
* List recent chat messages for moderation
* Query: ?limit=100&includeDeleted=false
*/
app.get('/api/admin/chat', adminAuth, asyncHandler(async (req, res) => {
const limit = Math.min(parseInt(req.query.limit) || 100, 500);
const includeDeleted = req.query.includeDeleted === 'true';
const messages = await socketHandlers.chatHandler.chatHistory.getMessagesForAdmin(limit, includeDeleted);
res.json({
messages,
count: messages.length
});
}));
/**
* DELETE /api/admin/chat/:id
* Soft delete a chat message
* Body: { reason?: string }
*/
app.delete('/api/admin/chat/:id', adminAuth, asyncHandler(async (req, res) => {
const messageId = parseInt(req.params.id);
const { reason } = req.body;
if (!messageId || isNaN(messageId)) {
throw new ValidationError('Invalid message ID');
}
const deleted = await socketHandlers.chatHandler.chatHistory.deleteMessage(
messageId,
'admin',
reason || 'Admin deleted'
);
if (deleted) {
// Broadcast deletion to all connected clients
io.emit('chat_deleted', { messageId });
console.log(`[Admin] Chat message deleted: id=${messageId}, reason="${reason || 'No reason provided'}"`);
}
res.json({
success: deleted,
message: deleted ? 'Message deleted' : 'Message not found or already deleted'
});
}));
/**
* POST /api/admin/users/:id/chat-ban
* Ban or unban a user from chat
* Body: { banned: boolean, reason?: string }
*/
app.post('/api/admin/users/:id/chat-ban', adminAuth, asyncHandler(async (req, res) => {
const userId = parseInt(req.params.id);
const { banned, reason } = req.body;
if (!userId || isNaN(userId)) {
throw new ValidationError('Invalid user ID');
}
if (typeof banned !== 'boolean') {
throw new ValidationError('banned must be a boolean');
}
await db.query(`
UPDATE users
SET chat_banned = $2,
chat_banned_at = CASE WHEN $2 THEN NOW() ELSE NULL END,
chat_banned_reason = $3
WHERE id = $1
`, [userId, banned, reason || null]);
console.log(`[Admin] User ${userId} chat ${banned ? 'banned' : 'unbanned'}: reason="${reason || 'No reason provided'}"`);
res.json({
success: true,
userId,
banned,
reason: reason || null
});
}));
/**
* GET /api/admin/users/search
* Search for users by socket ID prefix or payout address
+44
View File
@@ -0,0 +1,44 @@
-- Chat Moderation Migration
-- Adds moderation capabilities to chat system
-- Add moderation fields to chat_messages
ALTER TABLE chat_messages ADD COLUMN IF NOT EXISTS user_id INTEGER REFERENCES users(id);
ALTER TABLE chat_messages ADD COLUMN IF NOT EXISTS player_id VARCHAR(10);
ALTER TABLE chat_messages ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMP;
ALTER TABLE chat_messages ADD COLUMN IF NOT EXISTS deleted_by VARCHAR(255);
ALTER TABLE chat_messages ADD COLUMN IF NOT EXISTS delete_reason VARCHAR(255);
-- Index for non-deleted messages
CREATE INDEX IF NOT EXISTS idx_chat_messages_active
ON chat_messages(created_at DESC)
WHERE deleted_at IS NULL;
-- Add chat ban fields to users table
ALTER TABLE users ADD COLUMN IF NOT EXISTS chat_banned BOOLEAN DEFAULT FALSE;
ALTER TABLE users ADD COLUMN IF NOT EXISTS chat_banned_at TIMESTAMP;
ALTER TABLE users ADD COLUMN IF NOT EXISTS chat_banned_reason VARCHAR(255);
-- Seed initial messages (only insert if table is empty or no system messages exist)
-- These give context and make the chat feel alive on fresh deploy
INSERT INTO chat_messages (socket_id, username, player_id, message, message_type, created_at)
SELECT 'system', 'SERVER', 'SERVER', msg, 'system', ts
FROM (VALUES
('Welcome to Wownerogue! Use arrow keys or WASD to move.', NOW() - INTERVAL '1 hour'),
('Escape the dungeon to win. Collect treasure for bonus payout!', NOW() - INTERVAL '59 minutes'),
('Watch out for monsters - they''re fast!', NOW() - INTERVAL '58 minutes'),
('Type /help for commands.', NOW() - INTERVAL '57 minutes'),
('Good luck and have fun!', NOW() - INTERVAL '56 minutes')
) AS seed_data(msg, ts)
WHERE NOT EXISTS (
SELECT 1 FROM chat_messages WHERE socket_id = 'system' LIMIT 1
);
-- Comment on new columns
COMMENT ON COLUMN chat_messages.user_id IS 'Reference to users table for registered users';
COMMENT ON COLUMN chat_messages.player_id IS 'Display ID like PiMjKP shown in chat';
COMMENT ON COLUMN chat_messages.deleted_at IS 'Soft delete timestamp for moderation';
COMMENT ON COLUMN chat_messages.deleted_by IS 'Admin who deleted the message';
COMMENT ON COLUMN chat_messages.delete_reason IS 'Reason for deletion';
COMMENT ON COLUMN users.chat_banned IS 'Whether user is banned from chat';
COMMENT ON COLUMN users.chat_banned_at IS 'When the chat ban was applied';
COMMENT ON COLUMN users.chat_banned_reason IS 'Reason for chat ban';
+38 -7
View File
@@ -223,13 +223,42 @@ class ChatHandler {
const trimmed = msg.trim();
if (!trimmed) return;
// Message length limit (reject, don't truncate)
const MAX_MESSAGE_LENGTH = 200;
if (trimmed.length > MAX_MESSAGE_LENGTH) {
this.broadcastManager.sendStatusUpdate(socket.id, 'warning',
`Message too long (${trimmed.length}/${MAX_MESSAGE_LENGTH} characters). Please shorten it.`);
return;
}
// Get user info for ban check and user_id
let userId = null;
if (this.gameModeManager) {
try {
const userRow = await this.gameModeManager.getOrCreateUser(socket.id);
userId = userRow?.id || null;
// Check if user is chat banned
if (userId && await this.chatHistory.isUserChatBanned(userId)) {
this.broadcastManager.sendStatusUpdate(socket.id, 'error',
'You have been banned from chat.');
return;
}
} catch (e) {
// Continue without user lookup - allow message
if (this.debugManager?.CONSOLE_LOGGING) {
console.warn('User lookup for chat failed:', e.message);
}
}
}
// Additional rate limit for broadcast messages (more restrictive)
const now = Date.now();
const last = this._chatLastSent.get(socket.id) || 0;
const BROADCAST_COOLDOWN = 2000; // 2 seconds between broadcasts
if (now - last < BROADCAST_COOLDOWN) {
this.broadcastManager.sendStatusUpdate(socket.id, 'warning',
this.broadcastManager.sendStatusUpdate(socket.id, 'warning',
`Please wait ${Math.ceil((BROADCAST_COOLDOWN - (now - last)) / 1000)} seconds before sending another message.`);
return;
}
@@ -237,25 +266,27 @@ class ChatHandler {
this._chatLastSent.set(socket.id, now);
// Complete HTML entity escaping to prevent XSS
// Note: length already validated above, slice is just a safety net
const safe = trimmed
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
.slice(0, 300);
.slice(0, MAX_MESSAGE_LENGTH);
const username = socket.id.substring(0, 6);
// Save message to history (async, don't block broadcast)
// Save message to history with user_id (async, don't block broadcast)
this.chatHistory.saveMessage({
socketId: socket.id,
username: username,
message: safe,
type: 'chat'
type: 'chat',
userId: userId
}).catch(err => {
console.error('Failed to save chat message:', err.message);
});
this.broadcastManager.broadcastChatMessage(username, safe, now, socket.id);
}
+155 -31
View File
@@ -20,7 +20,7 @@ class ChatHistoryManager {
// Cleanup old messages periodically (once per day)
this._cleanupInterval = null;
this._maxMessageAge = 30 * 24 * 60 * 60 * 1000; // 30 days in ms
this._maxMessageAge = 7 * 24 * 60 * 60 * 1000; // 7 days in ms
}
/**
@@ -119,19 +119,23 @@ class ChatHistoryManager {
* Save a chat message
* @param {Object} messageData - Message data
* @param {string} messageData.socketId - Socket ID of sender
* @param {string} messageData.username - Display username
* @param {string} messageData.username - Display username (playerId)
* @param {string} messageData.message - Message content
* @param {string} messageData.type - Message type (chat, system, event)
* @param {number} [messageData.userId] - Optional user ID from database
* @returns {Object} Saved message with id and timestamp
*/
async saveMessage({ socketId, username, message, type = 'chat' }) {
async saveMessage({ socketId, username, message, type = 'chat', userId = null }) {
const timestamp = Date.now();
const playerId = username; // username IS the playerId (6-char display ID)
const messageObj = {
socketId,
username,
playerId,
message,
type,
timestamp
timestamp,
userId
};
// Add to in-memory cache immediately
@@ -139,7 +143,7 @@ class ChatHistoryManager {
if (this._recentMessagesCache.length > this._cacheSize) {
this._recentMessagesCache.shift();
}
// Also add to fallback array
this._inMemoryHistory.push(messageObj);
if (this._inMemoryHistory.length > this._inMemoryFallbackSize) {
@@ -150,11 +154,11 @@ class ChatHistoryManager {
if (this.db) {
try {
const result = await this.db.query(`
INSERT INTO chat_messages (socket_id, username, message, message_type, created_at)
VALUES ($1, $2, $3, $4, NOW())
INSERT INTO chat_messages (socket_id, username, player_id, user_id, message, message_type, created_at)
VALUES ($1, $2, $3, $4, $5, $6, NOW())
RETURNING id
`, [socketId, username, message, type]);
`, [socketId, username, playerId, userId, message, type]);
messageObj.id = result.rows[0]?.id;
} catch (error) {
console.error('Failed to persist chat message:', error.message);
@@ -167,36 +171,46 @@ class ChatHistoryManager {
/**
* Get recent messages for a new user
* @param {number} count - Number of messages to retrieve (default: 50)
* Uses hybrid strategy: last hour of messages, OR last 10 messages if < 10 in last hour
* @param {number} maxCount - Maximum number of messages to retrieve (default: 50)
* @param {number} minCount - Minimum messages to show if recent activity is low (default: 10)
* @returns {Array} Array of recent messages
*/
async getRecentMessages(count = 50) {
const limit = Math.min(count, this._cacheSize);
// If cache is initialized, use it
if (this._cacheInitialized && this._recentMessagesCache.length > 0) {
return this._recentMessagesCache.slice(-limit);
}
// If cache is empty, try in-memory fallback
if (this._inMemoryHistory.length > 0) {
return this._inMemoryHistory.slice(-limit);
}
// If nothing in memory, try database
async getRecentMessages(maxCount = 50, minCount = 10) {
// Try database first for hybrid strategy
if (this.db) {
try {
// Hybrid query: Get messages from last hour, OR last N messages if fewer than minCount
const result = await this.db.query(`
SELECT id, socket_id, username, message, message_type, created_at
FROM chat_messages
ORDER BY created_at DESC
WITH recent_hour AS (
SELECT id, socket_id, username, player_id, message, message_type, created_at
FROM chat_messages
WHERE deleted_at IS NULL
AND created_at > NOW() - INTERVAL '1 hour'
ORDER BY created_at DESC
LIMIT $1
),
fallback AS (
SELECT id, socket_id, username, player_id, message, message_type, created_at
FROM chat_messages
WHERE deleted_at IS NULL
ORDER BY created_at DESC
LIMIT $2
)
SELECT * FROM (
SELECT * FROM recent_hour
UNION
SELECT * FROM fallback
) combined
ORDER BY created_at ASC
LIMIT $1
`, [limit]);
return result.rows.reverse().map(row => ({
`, [maxCount, minCount]);
return result.rows.map(row => ({
id: row.id,
socketId: row.socket_id,
username: row.username,
playerId: row.player_id || row.username,
message: row.message,
type: row.message_type,
timestamp: new Date(row.created_at).getTime()
@@ -205,7 +219,21 @@ class ChatHistoryManager {
console.error('Failed to get recent messages from DB:', error.message);
}
}
// Fallback to cache (filter out deleted messages)
const limit = Math.min(maxCount, this._cacheSize);
if (this._cacheInitialized && this._recentMessagesCache.length > 0) {
const activeMessages = this._recentMessagesCache.filter(m => !m.deletedAt);
return activeMessages.slice(-limit);
}
// Final fallback to in-memory
if (this._inMemoryHistory.length > 0) {
const activeMessages = this._inMemoryHistory.filter(m => !m.deletedAt);
return activeMessages.slice(-limit);
}
return [];
}
@@ -237,6 +265,102 @@ class ChatHistoryManager {
}
}
/**
* Check if a user is banned from chat
* @param {number} userId - User ID to check
* @returns {boolean} True if user is banned
*/
async isUserChatBanned(userId) {
if (!userId || !this.db) return false;
try {
const result = await this.db.query(
'SELECT chat_banned FROM users WHERE id = $1',
[userId]
);
return result.rows[0]?.chat_banned === true;
} catch (error) {
console.error('Failed to check chat ban status:', error.message);
return false;
}
}
/**
* Soft delete a message (admin moderation)
* @param {number} messageId - Message ID to delete
* @param {string} deletedBy - Admin identifier
* @param {string} reason - Reason for deletion
* @returns {boolean} True if deleted successfully
*/
async deleteMessage(messageId, deletedBy = 'admin', reason = null) {
if (!this.db) return false;
try {
const result = await this.db.query(`
UPDATE chat_messages
SET deleted_at = NOW(), deleted_by = $2, delete_reason = $3
WHERE id = $1 AND deleted_at IS NULL
RETURNING id
`, [messageId, deletedBy, reason]);
if (result.rowCount > 0) {
// Also remove from in-memory caches
this._recentMessagesCache = this._recentMessagesCache.filter(m => m.id !== messageId);
this._inMemoryHistory = this._inMemoryHistory.filter(m => m.id !== messageId);
return true;
}
return false;
} catch (error) {
console.error('Failed to delete chat message:', error.message);
return false;
}
}
/**
* Get messages for admin view (includes deleted)
* @param {number} limit - Number of messages
* @param {boolean} includeDeleted - Whether to include deleted messages
* @returns {Array} Messages for admin
*/
async getMessagesForAdmin(limit = 100, includeDeleted = false) {
if (!this.db) return [];
try {
const result = await this.db.query(`
SELECT
c.id, c.socket_id, c.username, c.player_id, c.user_id,
c.message, c.message_type, c.created_at,
c.deleted_at, c.deleted_by, c.delete_reason,
u.payout_address, u.chat_banned
FROM chat_messages c
LEFT JOIN users u ON c.user_id = u.id
${includeDeleted ? '' : 'WHERE c.deleted_at IS NULL'}
ORDER BY c.created_at DESC
LIMIT $1
`, [limit]);
return result.rows.map(row => ({
id: row.id,
socketId: row.socket_id,
username: row.username,
playerId: row.player_id,
userId: row.user_id,
message: row.message,
type: row.message_type,
timestamp: new Date(row.created_at).getTime(),
createdAt: row.created_at,
deletedAt: row.deleted_at,
deletedBy: row.deleted_by,
deleteReason: row.delete_reason,
payoutAddress: row.payout_address,
chatBanned: row.chat_banned
}));
} catch (error) {
console.error('Failed to get admin messages:', error.message);
return [];
}
}
/**
* Get message count for statistics
* @returns {Object} Statistics about chat messages