fix admin panel and credits package

This commit is contained in:
jwinterm
2026-02-17 20:02:22 -05:00
parent f8ae69304a
commit 320d1a3b1f
5 changed files with 226 additions and 134 deletions
+10 -10
View File
@@ -653,20 +653,20 @@
return Math.floor(sec / 3600) + 'h ' + Math.floor((sec % 3600) / 60) + 'm';
};
return \`
<tr style="\${rowStyle}">
<td>\${entry.position}</td>
<td title="\${entry.serverId}">\${entry.serverId.substring(0, 12)}...</td>
<td style="\${isStuck ? 'color:#f80; font-weight:bold;' : ''}">\${formatWait(entry.waitingSeconds)}</td>
<td>\${entry.paymentId || '-'}</td>
<td>\${statusText}</td>
return `
<tr style="${rowStyle}">
<td>${entry.position}</td>
<td title="${entry.serverId}">${entry.serverId.substring(0, 12)}...</td>
<td style="${isStuck ? 'color:#f80; font-weight:bold;' : ''}">${formatWait(entry.waitingSeconds)}</td>
<td>${entry.paymentId || '-'}</td>
<td>${statusText}</td>
<td>
<button class="btn btn-danger" onclick="removeQueueEntry('\${entry.serverId}')" style="background:#500; color:#f88; border:1px solid #f55; padding:3px 8px; cursor:pointer;">
<button class="btn btn-danger" onclick="removeQueueEntry('${entry.serverId}')" style="background:#500; color:#f88; border:1px solid #f55; padding:3px 8px; cursor:pointer;">
Remove
</button>
</td>
</tr>
\`;
`;
}).join('');
} catch (e) {
console.error('Failed to load queue:', e);
@@ -679,7 +679,7 @@
}
async function removeQueueEntry(serverId) {
if (!confirm(\`Remove queue entry for \${serverId.substring(0, 12)}...? This will cancel their queue position.\`)) return;
if (!confirm(`Remove queue entry for ${serverId.substring(0, 12)}...? This will cancel their queue position.`)) return;
const reason = prompt('Reason for removal (optional):') || 'Admin removal';
+22 -5
View File
@@ -1331,7 +1331,7 @@ const SocketHandlers = {
animation: earlyEntryPulse 2s infinite;
">⚡ ENTER NOW (RISKY!) ⚡</button>
`);
// Add CSS animation
if (!$('#earlyEntryStyles').length) {
$('head').append(`
@@ -1365,22 +1365,39 @@ const SocketHandlers = {
// Show/hide based on whether we're queued and early entry is allowed
if (this._isQueued && this.isEarlyEntryAllowed()) {
// Update button text to show credit cost when in paid mode
var mode = this._gameMode;
if (mode === 'PAID_CREDITS' || mode === 'MIXED') {
var cost = this._creditsPerGame || 1;
$btn.text('⚡ ENTER NOW (' + cost + ' credit, RISKY!) ⚡');
} else {
$btn.text('⚡ ENTER NOW (RISKY!) ⚡');
}
$btn.show();
} else {
$btn.hide();
}
},
/**
* Request early entry from the server
*/
requestEarlyEntry: function() {
if (!window.socket) return;
// Confirm before spending credits
var mode = this._gameMode;
var msg = 'Enter the dungeon NOW?\n\nYou will die when the next block is found!';
if (mode === 'PAID_CREDITS' || mode === 'MIXED') {
var cost = this._creditsPerGame || 1;
msg = 'Use ' + cost + ' credit to enter the dungeon NOW?\n\nYou will die when the next block is found!';
}
if (!confirm(msg)) return;
// Disable button to prevent double-clicks
const $btn = $('#earlyEntryButton');
var $btn = $('#earlyEntryButton');
$btn.prop('disabled', true).text('⏳ Entering...');
socket.emit('early_entry');
},
+123 -108
View File
@@ -262,85 +262,91 @@ class GameModeManager {
*/
async processCreditsPackageConfirmation(socketId, paymentId, packageInfo = null) {
try {
const user = await this.getOrCreateUser(socketId);
// Look up user from payment record's user_id (stable across socket reconnects)
// This avoids the bug where socket ID changes if user refreshes during confirmation
const paymentLookup = await this.db.query(`
SELECT user_id, description, status FROM payments WHERE id = $1
`, [paymentId]);
// Determine credits to add from package or fallback
if (paymentLookup.rows.length === 0) {
console.error(`Payment ${paymentId} not found`);
return { success: false, reason: 'Payment not found' };
}
// Fast-fail if already confirmed (avoids unnecessary transaction)
if (paymentLookup.rows[0].status === 'confirmed') {
console.log(`Payment ${paymentId} already confirmed, skipping duplicate processing`);
return { success: false, alreadyProcessed: true, reason: 'Payment already processed' };
}
const userId = paymentLookup.rows[0].user_id;
// Determine credits to add from package info or payment description
let creditsToAdd = 10; // Default fallback
if (packageInfo && packageInfo.credits) {
creditsToAdd = Number(packageInfo.credits) + (Number(packageInfo.bonus) || 0);
} else {
// Try to get from payment record description
const paymentResult = await this.db.query(`
SELECT description, status FROM payments WHERE id = $1
`, [paymentId]);
if (paymentResult.rows.length > 0) {
// SECURITY: Check if already confirmed (prevents double-crediting)
if (paymentResult.rows[0].status === 'confirmed') {
console.log(`⚠️ Payment ${paymentId} already confirmed, skipping duplicate processing`);
return {
success: false,
alreadyProcessed: true,
reason: 'Payment already processed'
};
}
const desc = paymentResult.rows[0].description || '';
const match = desc.match(/(\d+)\s*credits?/i);
if (match) {
creditsToAdd = parseInt(match[1], 10) || 10;
}
const desc = paymentLookup.rows[0].description || '';
const match = desc.match(/(\d+)\s*credits?/i);
if (match) {
creditsToAdd = parseInt(match[1], 10) || 10;
}
}
// SECURITY: Atomically update payment status from 'pending' to 'confirmed'
// Only proceeds if the payment was actually pending (prevents double-crediting)
const paymentUpdateResult = await this.db.query(`
UPDATE payments
SET status = 'confirmed',
credits_purchased = $1,
confirmed_at = NOW()
WHERE id = $2 AND status = 'pending'
RETURNING id
`, [creditsToAdd, paymentId]);
// CRITICAL: Wrap all three operations in a transaction.
// If any step fails, the payment stays 'pending' and can be recovered.
const result = await this.db.withTransaction(async (client) => {
// Step 1: Atomically mark payment as confirmed (prevents double-crediting)
const paymentUpdateResult = await client.query(`
UPDATE payments
SET status = 'confirmed',
credits_purchased = $1,
confirmed_at = NOW()
WHERE id = $2 AND status = 'pending'
RETURNING id
`, [creditsToAdd, paymentId]);
// If no rows were updated, payment was already processed
if (paymentUpdateResult.rows.length === 0) {
console.log(`⚠️ Payment ${paymentId} already confirmed (atomic check), skipping duplicate`);
return {
success: false,
alreadyProcessed: true,
reason: 'Payment already processed'
};
if (paymentUpdateResult.rows.length === 0) {
// Already processed by another instance - not an error
return { alreadyProcessed: true };
}
// Step 2: Add credits to user (using payment's user_id, not socket lookup)
const updateResult = await client.query(`
UPDATE users
SET credits = credits + $1,
total_credits_purchased = COALESCE(total_credits_purchased, 0) + $1,
updated_at = NOW()
WHERE id = $2
RETURNING credits
`, [creditsToAdd, userId]);
const newBalance = updateResult.rows[0]?.credits ?? creditsToAdd;
// Step 3: Record credit transaction for audit trail
await client.query(`
INSERT INTO credit_transactions (user_id, amount, reason, balance_after, transaction_type)
VALUES ($1, $2, 'package_purchase', $3, 'purchase')
`, [userId, creditsToAdd, newBalance]);
return { newBalance };
});
if (result.alreadyProcessed) {
console.log(`Payment ${paymentId} already confirmed (atomic check), skipping duplicate`);
return { success: false, alreadyProcessed: true, reason: 'Payment already processed' };
}
// Payment successfully marked as confirmed - now add credits
// Use atomic increment to prevent race conditions
const updateResult = await this.db.query(`
UPDATE users
SET credits = credits + $1,
total_credits_purchased = COALESCE(total_credits_purchased, 0) + $1,
updated_at = NOW()
WHERE id = $2
RETURNING credits
`, [creditsToAdd, user.id]);
const newBalance = updateResult.rows[0]?.credits ?? (user.credits + creditsToAdd);
// Record credit transaction
await this.db.query(`
INSERT INTO credit_transactions (user_id, amount, reason, balance_after, transaction_type)
VALUES ($1, $2, 'package_purchase', $3, 'purchase')
`, [user.id, creditsToAdd, newBalance]);
console.log(`💰 Credits package confirmed: +${creditsToAdd} credits for user ${user.id}, new balance: ${newBalance}`);
console.log(`Credits package confirmed: +${creditsToAdd} credits for user ${userId}, new balance: ${result.newBalance}`);
return {
success: true,
creditsAdded: creditsToAdd,
newBalance: newBalance
newBalance: result.newBalance
};
} catch (error) {
const normalized = normalizeError(error, 'Failed to process credits package confirmation');
console.error('❌ Error processing credits package:', normalized.message);
console.error('Error processing credits package:', normalized.message);
return {
success: false,
reason: normalized.safeMessage
@@ -722,65 +728,74 @@ class GameModeManager {
async _processGameStartWithCredits(user, socketId, gameId) {
const creditsToSpend = this.creditsPerGameCost;
// Use a conditional update to prevent credits going negative
const updateRes = await this.db.query(`
UPDATE users
SET credits = credits - $1,
total_games_played = total_games_played + 1,
updated_at = NOW()
WHERE id = $2 AND credits >= $1
RETURNING credits
`, [creditsToSpend, user.id]);
// If no rows were updated, user doesn't have enough credits
if (updateRes.rows.length === 0) {
return {
success: false,
const result = await this.db.withTransaction(async (client) => {
// Conditional update prevents credits going negative
const updateRes = await client.query(`
UPDATE users
SET credits = credits - $1,
total_games_played = total_games_played + 1,
updated_at = NOW()
WHERE id = $2 AND credits >= $1
RETURNING credits
`, [creditsToSpend, user.id]);
if (updateRes.rows.length === 0) {
return { insufficient: true };
}
const remainingCredits = updateRes.rows[0].credits;
await client.query(`
UPDATE games SET payment_mode = 'credits' WHERE id = $1
`, [gameId]);
await client.query(`
INSERT INTO credit_transactions (user_id, amount, reason, balance_after, transaction_type)
VALUES ($1, $2, 'game_entry', $3, 'spend')
`, [user.id, -creditsToSpend, remainingCredits]);
return { remainingCredits };
});
if (result.insufficient) {
return {
success: false,
reason: 'Insufficient credits',
creditsRequired: creditsToSpend,
creditsAvailable: user.credits
};
}
const remainingCredits = updateRes.rows[0] ? updateRes.rows[0].credits : (user.credits - creditsToSpend);
// Record the game mode used
await this.db.query(`
UPDATE games SET payment_mode = 'credits' WHERE id = $1
`, [gameId]);
// Record credit transaction
await this.db.query(`
INSERT INTO credit_transactions (user_id, amount, reason, balance_after, transaction_type)
VALUES ($1, $2, 'game_entry', $3, 'spend')
`, [user.id, -creditsToSpend, remainingCredits]);
console.log(`🎫 Deducted ${creditsToSpend} credit(s) from user ${user.id}, ${remainingCredits} remaining`);
return {
success: true,
creditsRemaining: remainingCredits,
console.log(`Deducted ${creditsToSpend} credit(s) from user ${user.id}, ${result.remainingCredits} remaining`);
return {
success: true,
creditsRemaining: result.remainingCredits,
creditsSpent: creditsToSpend,
effectiveMode: 'PAID_CREDITS'
};
}
async _processGameStartWithPayment(user, payment, gameId) {
await this.db.query(`
UPDATE games
SET payment_id = $1
WHERE id = $2
`, [payment.id, gameId]);
await this.db.query(`
UPDATE users
SET total_games_played = total_games_played + 1,
updated_at = NOW()
WHERE id = $1
`, [user.id]);
console.log(`💳 Linked game ${gameId} to payment ${payment.id}`);
return {
success: true,
await this.db.withTransaction(async (client) => {
await client.query(`
UPDATE games
SET payment_id = $1
WHERE id = $2
`, [payment.id, gameId]);
await client.query(`
UPDATE users
SET total_games_played = total_games_played + 1,
updated_at = NOW()
WHERE id = $1
`, [user.id]);
});
console.log(`Linked game ${gameId} to payment ${payment.id}`);
return {
success: true,
paymentId: payment.id,
effectiveMode: 'PAID_SINGLE'
};
+5
View File
@@ -61,6 +61,11 @@ app.get('/', function(req, res) {
res.sendFile('index.html', { root: htmlPath });
});
// Serve admin panel
app.get('/admin', function(req, res) {
res.sendFile('admin.html', { root: htmlPath });
});
// Debug endpoint to receive client-side debug info
app.post('/debug', (req, res) => {
if (debugManager.CONSOLE_LOGGING) {
+66 -11
View File
@@ -162,28 +162,65 @@ class SessionManager {
AND p.status = 'confirmed'
AND p.payment_type = 'credits_package'
AND (p.credits_purchased IS NULL OR p.credits_purchased = 0)
AND p.confirmed_at > NOW() - INTERVAL '24 hours'
AND p.confirmed_at > NOW() - INTERVAL '7 days'
ORDER BY p.confirmed_at ASC
`, [userId]);
// Also find payments where credits_purchased was set but credits were never
// actually added to the user (e.g., crash/disconnect between payment update
// and user credit update before transaction wrapping was added)
const orphanedPayments = await this.db.query(`
SELECT p.id, p.credits_purchased, p.confirmed_at
FROM payments p
WHERE p.user_id = $1
AND p.status = 'confirmed'
AND p.payment_type = 'credits_package'
AND p.credits_purchased > 0
AND p.confirmed_at > NOW() - INTERVAL '7 days'
AND NOT EXISTS (
SELECT 1 FROM credit_transactions ct
WHERE ct.user_id = p.user_id
AND ct.amount = p.credits_purchased
AND ct.reason IN ('package_purchase', 'package_purchase_recovered')
AND ct.created_at >= p.confirmed_at - INTERVAL '1 minute'
)
ORDER BY p.confirmed_at ASC
`, [userId]);
// Merge orphaned payments into unprocessed list (they need recovery too)
for (const orphan of orphanedPayments.rows) {
unprocessedPayments.rows.push({
id: orphan.id,
payment_type: 'credits_package',
description: null,
expected_amount: null,
confirmed_at: orphan.confirmed_at,
_creditsFromRecord: orphan.credits_purchased // use stored value
});
}
if (unprocessedPayments.rows.length === 0) {
return result;
}
for (const payment of unprocessedPayments.rows) {
try {
// Parse credits from description (e.g., "Wowngeon 10 credits package (WOW)")
let creditsToAdd = 10; // Default fallback
const desc = payment.description || '';
const match = desc.match(/(\d+)\s*credits?/i);
if (match) {
creditsToAdd = parseInt(match[1], 10) || 10;
// Determine credits: use stored value from orphaned record, or parse from description
let creditsToAdd = payment._creditsFromRecord || 0;
if (!creditsToAdd) {
creditsToAdd = 10; // Default fallback
const desc = payment.description || '';
const match = desc.match(/(\d+)\s*credits?/i);
if (match) {
creditsToAdd = parseInt(match[1], 10) || 10;
}
}
const isOrphaned = !!payment._creditsFromRecord;
// CRITICAL: Use transaction with row lock to prevent double-credit race condition
// This ensures only one instance can process a payment at a time
const recovered = await this.db.withTransaction(async (client) => {
// Lock the payment row and re-check if it's still unprocessed
// Lock the payment row and re-check status
const lockResult = await client.query(`
SELECT id, credits_purchased
FROM payments
@@ -191,9 +228,27 @@ class SessionManager {
FOR UPDATE
`, [payment.id]);
// If already processed (by another instance), skip
if (!lockResult.rows[0] || (lockResult.rows[0].credits_purchased && lockResult.rows[0].credits_purchased > 0)) {
return null; // Already processed
if (!lockResult.rows[0]) return null;
// For normal recovery: skip if credits_purchased already set
// For orphaned recovery: skip if a credit_transaction already exists
if (!isOrphaned) {
if (lockResult.rows[0].credits_purchased && lockResult.rows[0].credits_purchased > 0) {
return null; // Already processed
}
} else {
// Double-check: does a credit_transaction already exist for this?
const txCheck = await client.query(`
SELECT 1 FROM credit_transactions
WHERE user_id = $1
AND amount = $2
AND reason IN ('package_purchase', 'package_purchase_recovered')
AND created_at >= $3::timestamp - INTERVAL '1 minute'
LIMIT 1
`, [userId, creditsToAdd, payment.confirmed_at]);
if (txCheck.rows.length > 0) {
return null; // Already recovered
}
}
// Add credits to user