Commit ef9854c7 authored by Mahmoud Aglan's avatar Mahmoud Aglan

fix(players, economy): grants are atomic and tell the player; create and edit players work

Grant and revoke (player page and economy module) used to read the balance,
add in PHP, write it back and insert the ledger row separately, racing any
game payout in between. They now call admin_adjust_currency() (one statement,
coins or gems, never below zero, booked as admin_grant / debit; see El3ab
migrations/2026-10-26-manager-columns.sql) and put a notice in the player's
bell (title_ar/body_ar, as the game's notifications.php reads them).
award_coins() was not used: coins only, no floor, and it books staff grants
as game income.

Edit no longer sends profiles.email (no such column; PGRST204 rejected the
whole update); the login email is shown read-only from the auth account.
Create now makes a real login account through GoTrue's admin API, whose
trigger creates the profile; the old insert had no id and always failed.
The match history query used a players column matches does not have, and
the transactions tab read a variable that was never set.
Co-Authored-By: 's avatarClaude Opus 5.5 <noreply@anthropic.com>
parent 1f8152ac
<?php
/**
* Staff grants and revokes of coins and gems.
*
* Grants used to read the balance, add to it in PHP and write it back, then
* insert the ledger row separately: a game paying out between the read and the
* write lost one of the two changes, and a failed second write left a balance
* with no ledger entry. The change now happens in one database statement,
* admin_adjust_currency() (El3ab migrations/2026-10-26-manager-columns.sql),
* which also refuses to take a balance below zero and books the row as
* admin_grant / debit. award_coins() was not used: it only handles coins, never
* refuses a negative balance, and books everything as game income.
*
* The player is told in the in-game bell (notifications.title_ar/body_ar, as
* the game's api/notifications.php reads them). Their HUD balance itself only
* refreshes when the app next loads it.
*/
class EconomyService
{
public const MAX_AMOUNT = 1000000;
/** "500 من العملات" reads correctly for any count, unlike a bare noun. */
private const CURRENCY_AR = ['coins' => 'من العملات', 'gems' => 'من الجواهر'];
/**
* @return array{ok:bool, balance:?int, message:string, error:?string}
*/
public static function adjust(string $playerId, string $currency, int $amount, string $reason = '', bool $notify = true): array
{
if (!isset(self::CURRENCY_AR[$currency])) {
return ['ok' => false, 'balance' => null, 'message' => '', 'error' => 'عملة غير معروفة'];
}
if ($amount === 0) {
return ['ok' => false, 'balance' => null, 'message' => '', 'error' => 'يجب إدخال مبلغ صحيح'];
}
if (abs($amount) > self::MAX_AMOUNT) {
return ['ok' => false, 'balance' => null, 'message' => '', 'error' => 'مبلغ كبير جداً - الحد الأقصى 1,000,000'];
}
$db = Database::getInstance();
$admin = Auth::user()['username'] ?? 'staff';
$reason = $reason !== '' ? $reason : ($amount > 0 ? 'منح بواسطة الإدارة' : 'سحب بواسطة الإدارة');
$balance = $db->rpc('admin_adjust_currency', [
'p_player_id' => $playerId,
'p_currency' => $currency,
'p_amount' => $amount,
'p_reason' => $reason,
'p_admin' => 'admin:' . $admin,
]);
if ($balance === null) {
$err = $db->lastError();
$db->clearWriteErrors();
$message = $err['message'] ?? '';
if (str_contains($message, 'insufficient balance')) return ['ok' => false, 'balance' => null, 'message' => '', 'error' => 'الرصيد غير كاف'];
if (str_contains($message, 'player not found')) return ['ok' => false, 'balance' => null, 'message' => '', 'error' => 'اللاعب غير موجود'];
return ['ok' => false, 'balance' => null, 'message' => '', 'error' => 'لم تتم العملية: ' . ($err ? Database::describe($err) : 'خطأ غير معروف')];
}
$unit = self::CURRENCY_AR[$currency];
$abs = number_format(abs($amount));
$message = ($amount > 0 ? "تم منح {$abs} {$unit}" : "تم سحب {$abs} {$unit}") . ' — الرصيد الآن ' . number_format((int)$balance);
if ($notify) {
$note = $db->soft(fn(Database $d) => $d->insert('notifications', [
'user_id' => $playerId,
'type' => 'reward',
'title' => $amount > 0 ? 'A gift from the EL3AB team' : 'Balance adjusted',
'title_ar' => $amount > 0 ? 'هدية من فريق الإدارة' : 'تعديل في رصيدك',
'body' => ($amount > 0 ? "{$abs} {$currency} were added to your balance." : "{$abs} {$currency} were deducted from your balance.") . ' ' . $reason,
'body_ar' => ($amount > 0 ? "أُضيف إلى رصيدك {$abs} {$unit}." : "خُصم من رصيدك {$abs} {$unit}.") . ' ' . $reason,
'data' => ['currency' => $currency, 'amount' => $amount, 'balance' => (int)$balance],
]));
if ($note === null) $message .= ' (تعذر إرسال الإشعار للاعب)';
}
return ['ok' => true, 'balance' => (int)$balance, 'message' => $message, 'error' => null];
}
}
......@@ -16,6 +16,7 @@ require_once __DIR__ . '/core/Response.php';
require_once __DIR__ . '/core/ThemeService.php';
require_once __DIR__ . '/core/SupabaseStorage.php';
require_once __DIR__ . '/core/OrgPermissions.php';
require_once __DIR__ . '/core/EconomyService.php';
$route = trim($_GET['route'] ?? '', '/');
$method = $_SERVER['REQUEST_METHOD'];
......
......@@ -21,7 +21,7 @@ class EconomyController
}
$todayEarned = $this->db->count('economy_transactions', [
'type' => 'eq.credit',
'type' => 'in.(credit,admin_grant,game_reward,daily_reward,level_up_reward,refund)',
'created_at' => 'gte.' . date('Y-m-d') . 'T00:00:00',
]);
......@@ -71,80 +71,43 @@ class EconomyController
public function grant(array $params, string $method): void
{
Auth::requireCsrf();
$userId = $_POST['user_id'] ?? '';
$currency = $_POST['currency'] ?? 'coins';
$amount = (int)($_POST['amount'] ?? 0);
if (empty($userId) || $amount <= 0) {
$userId = trim($_POST['user_id'] ?? '');
if ($userId === '') {
Response::error('بيانات غير صحيحة', '/economy');
return;
}
if ($amount > 1000000) {
Response::error('مبلغ كبير جداً - الحد الأقصى 1,000,000', '/economy');
$amount = (int)($_POST['amount'] ?? 0);
if ($amount <= 0) {
Response::error('يجب إدخال مبلغ صحيح', '/economy');
return;
}
$player = $this->db->selectOne('profiles', ['id' => "eq.{$userId}"]);
if (!$player) {
Response::error('اللاعب غير موجود', '/economy');
$currency = (string)($_POST['currency'] ?? 'coins');
$res = EconomyService::adjust($userId, $currency, $amount, trim($_POST['reason'] ?? ''));
if (!$res['ok']) {
Response::error($res['error'], '/economy');
return;
}
$newBalance = ($player[$currency] ?? 0) + $amount;
$this->db->update('profiles', ['id' => "eq.{$userId}"], [$currency => $newBalance]);
$this->db->insert('economy_transactions', [
'player_id' => $userId,
'type' => 'credit',
'currency' => $currency,
'amount' => $amount,
'balance_after' => $newBalance,
'reason' => trim($_POST['reason'] ?? 'منح بواسطة الإدارة'),
'source_id' => 'admin:' . Auth::user()['username'],
]);
AuditLog::log('grant', 'economy', $userId, null, ['currency' => $currency, 'amount' => $amount]);
Response::success("تم منح {$amount} {$currency} بنجاح", '/economy');
AuditLog::log('grant', 'economy', $userId, null, ['currency' => $currency, 'amount' => $amount, 'balance' => $res['balance']]);
Response::success($res['message'], '/economy');
}
public function revoke(array $params, string $method): void
{
Auth::requireCsrf();
$userId = $_POST['user_id'] ?? '';
$currency = $_POST['currency'] ?? 'coins';
$userId = trim($_POST['user_id'] ?? '');
$amount = (int)($_POST['amount'] ?? 0);
if (empty($userId) || $amount <= 0) {
if ($userId === '' || $amount <= 0) {
Response::error('بيانات غير صحيحة', '/economy');
return;
}
$player = $this->db->selectOne('profiles', ['id' => "eq.{$userId}"]);
if (!$player) {
Response::error('اللاعب غير موجود', '/economy');
$currency = (string)($_POST['currency'] ?? 'coins');
$res = EconomyService::adjust($userId, $currency, -$amount, trim($_POST['reason'] ?? ''));
if (!$res['ok']) {
Response::error($res['error'], '/economy');
return;
}
$currentBalance = $player[$currency] ?? 0;
if ($amount > $currentBalance) {
Response::error('الرصيد غير كاف', '/economy');
return;
}
$newBalance = $currentBalance - $amount;
$this->db->update('profiles', ['id' => "eq.{$userId}"], [$currency => $newBalance]);
$this->db->insert('economy_transactions', [
'player_id' => $userId,
'type' => 'debit',
'currency' => $currency,
'amount' => -$amount,
'balance_after' => $newBalance,
'reason' => trim($_POST['reason'] ?? 'سحب بواسطة الإدارة'),
'source_id' => 'admin:' . Auth::user()['username'],
]);
AuditLog::log('revoke', 'economy', $userId, null, ['currency' => $currency, 'amount' => $amount]);
Response::success("تم سحب {$amount} {$currency}", '/economy');
AuditLog::log('revoke', 'economy', $userId, null, ['currency' => $currency, 'amount' => -$amount, 'balance' => $res['balance']]);
Response::success($res['message'], '/economy');
}
public function bulkGrant(array $params, string $method): void
......@@ -152,8 +115,8 @@ class EconomyController
Auth::requireCsrf();
Auth::requireRole('admin');
$userIds = array_filter(explode(',', $_POST['user_ids'] ?? ''));
$currency = $_POST['currency'] ?? 'coins';
$userIds = array_values(array_unique(array_filter(array_map('trim', explode(',', $_POST['user_ids'] ?? '')))));
$currency = (string)($_POST['currency'] ?? 'coins');
$amount = (int)($_POST['amount'] ?? 0);
if (empty($userIds) || $amount <= 0) {
......@@ -162,25 +125,19 @@ class EconomyController
}
$count = 0;
$failed = [];
foreach ($userIds as $userId) {
$player = $this->db->selectOne('profiles', ['id' => "eq.{$userId}"]);
if (!$player) continue;
$newBalance = ($player[$currency] ?? 0) + $amount;
$this->db->update('profiles', ['id' => "eq.{$userId}"], [$currency => $newBalance]);
$this->db->insert('economy_transactions', [
'player_id' => $userId,
'type' => 'credit',
'currency' => $currency,
'amount' => $amount,
'balance_after' => $newBalance,
'reason' => 'منح جماعي',
'source_id' => 'admin:' . Auth::user()['username'],
]);
$count++;
$res = EconomyService::adjust($userId, $currency, $amount, 'منح جماعي');
if ($res['ok']) { $count++; } else { $failed[] = $userId; }
}
AuditLog::log('bulk_grant', 'economy', null, null, ['count' => $count, 'amount' => $amount, 'currency' => $currency]);
Response::success("تم منح {$amount} {$currency} لـ {$count} لاعب", '/economy');
AuditLog::log('bulk_grant', 'economy', null, null, ['count' => $count, 'failed' => count($failed), 'amount' => $amount, 'currency' => $currency]);
if ($count === 0) {
Response::error('لم يُمنح أي لاعب', '/economy');
return;
}
$msg = "تم منح {$amount} لـ {$count} لاعب";
if ($failed) $msg .= ' — تعذر ' . count($failed);
Response::success($msg, '/economy');
}
}
......@@ -70,12 +70,12 @@
<tbody>
<?php foreach ($recentTransactions as $tx): ?>
<tr>
<td class="text-xs"><?= View::e(substr($tx['user_id'], 0, 8)) ?>...</td>
<td class="text-xs"><?= View::e(substr((string)($tx['player_id'] ?? ''), 0, 8)) ?>...</td>
<td><span class="badge badge-<?= in_array($tx['type'], ['earn', 'admin_grant', 'tournament_prize']) ? 'success' : 'danger' ?>"><?= View::e($tx['type']) ?></span></td>
<td><?= $tx['currency'] === 'coins' ? 'عملات' : 'جواهر' ?></td>
<td class="tabular-nums <?= $tx['amount'] > 0 ? 'text-success' : 'text-danger' ?>"><?= $tx['amount'] > 0 ? '+' : '' ?><?= number_format($tx['amount']) ?></td>
<td class="tabular-nums"><?= number_format($tx['balance_after']) ?></td>
<td class="text-xs text-muted truncate" style="max-width: 150px;"><?= View::e($tx['description'] ?? '-') ?></td>
<td class="text-xs text-muted truncate" style="max-width: 150px;"><?= View::e($tx['reason'] ?? '-') ?></td>
<td class="text-xs tabular-nums"><?= date('m/d H:i', strtotime($tx['created_at'])) ?></td>
</tr>
<?php endforeach; ?>
......
......@@ -32,12 +32,12 @@
<tbody>
<?php foreach ($transactions as $tx): ?>
<tr>
<td><a href="/players/<?= $tx['user_id'] ?>" class="text-blue text-xs"><?= substr($tx['user_id'], 0, 8) ?>...</a></td>
<td><a href="/players/<?= View::e($tx['player_id'] ?? '') ?>" class="text-blue text-xs"><?= View::e(substr((string)($tx['player_id'] ?? ''), 0, 8)) ?>...</a></td>
<td><span class="badge badge-<?= in_array($tx['type'], ['earn', 'admin_grant', 'tournament_prize']) ? 'success' : 'danger' ?>"><?= View::e($tx['type']) ?></span></td>
<td><?= $tx['currency'] === 'coins' ? 'عملات' : 'جواهر' ?></td>
<td class="tabular-nums <?= $tx['amount'] > 0 ? 'text-success' : 'text-danger' ?>"><?= $tx['amount'] > 0 ? '+' : '' ?><?= number_format($tx['amount']) ?></td>
<td class="tabular-nums"><?= number_format($tx['balance_after']) ?></td>
<td class="text-xs text-muted truncate" style="max-width: 150px;"><?= View::e($tx['description'] ?? '-') ?></td>
<td class="text-xs text-muted truncate" style="max-width: 150px;"><?= View::e($tx['reason'] ?? '-') ?></td>
<td class="text-xs tabular-nums"><?= date('m/d H:i', strtotime($tx['created_at'])) ?></td>
</tr>
<?php endforeach; ?>
......
This diff is collapsed.
......@@ -32,11 +32,27 @@
</div>
</div>
<?php if (empty($player['id'])): ?>
<div class="grid grid-2 gap-4">
<div class="form-group">
<label class="form-label">البريد الإلكتروني *</label>
<input type="email" name="email" class="form-input" required dir="ltr">
<span class="form-hint">يُنشأ به حساب دخول للاعب</span>
<span class="form-error"></span>
</div>
<div class="form-group">
<label class="form-label">كلمة المرور</label>
<input type="text" name="password" class="form-input" minlength="8" dir="ltr" autocomplete="new-password">
<span class="form-hint">اتركها فارغة لتوليد كلمة مؤقتة تظهر بعد الحفظ</span>
</div>
</div>
<?php else: ?>
<div class="form-group">
<label class="form-label">البريد الإلكتروني</label>
<input type="email" name="email" class="form-input" value="<?= View::e($player['email'] ?? '') ?>" dir="ltr">
<span class="form-error"></span>
<input type="email" class="form-input" value="<?= View::e($player['email'] ?? '') ?>" dir="ltr" disabled>
<span class="form-hint">بريد الدخول جزء من حساب اللاعب ولا يُعدّل من هنا</span>
</div>
<?php endif; ?>
<div class="grid grid-2 gap-4">
<div class="form-group">
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment