Commit d5bedc75 authored by Mahmoud Aglan's avatar Mahmoud Aglan

fix(core): a write PostgREST rejects can no longer be reported as saved

Database used to decode the PostgREST error body and hand it back as if it
were the written row, so writes to missing columns (PGRST204), bad types and
broken foreign keys came back truthy and the controller flashed success.
Every failed request is now recorded and logged; insert/update/rpc return
null on failure; Response::success, redirect-with-success and json refuse to
report success over a recorded write failure and show the database's reason.
Best-effort writes (audit trail, page-view counters, last login) go through
Database::soft() so they cannot fail the action they describe.

AuditLog had never written a row: staff ids broke the actor_id foreign key
to profiles, names broke the resource_id uuid, and details was double
encoded. Staff now go in admin_user_id and text ids in resource_ref
(El3ab migrations/2026-10-26-manager-columns.sql); the audit page shows the
admin's name and searches the text reference.

Staff type times in Cairo: the app now runs in Africa/Cairo and core/Time
converts typed times to UTC instants and back, DST included.
Co-Authored-By: 's avatarClaude Opus 5.5 <noreply@anthropic.com>
parent aeeb1740
...@@ -3,6 +3,10 @@ ...@@ -3,6 +3,10 @@
define('APP_NAME', 'El3ab Management'); define('APP_NAME', 'El3ab Management');
define('APP_VERSION', '1.0.0'); define('APP_VERSION', '1.0.0');
define('APP_ENV', getenv('APP_ENV') ?: 'production'); define('APP_ENV', getenv('APP_ENV') ?: 'production');
// Staff are in Egypt. Every date() shown and every strtotime() of a typed time
// is Cairo time; the database keeps instants, so nothing stored changes meaning.
date_default_timezone_set(getenv('APP_TIMEZONE') ?: 'Africa/Cairo');
define('APP_SECRET', getenv('APP_SECRET') ?: 'dev-secret-change-in-production-64chars-minimum-required-here!!'); define('APP_SECRET', getenv('APP_SECRET') ?: 'dev-secret-change-in-production-64chars-minimum-required-here!!');
define('SUPABASE_URL', getenv('SUPABASE_URL') ?: 'https://safe-supabase-kong.caprover.al-arcade.com'); define('SUPABASE_URL', getenv('SUPABASE_URL') ?: 'https://safe-supabase-kong.caprover.al-arcade.com');
......
<?php <?php
/**
* The manager's audit trail (public.audit_log).
*
* Not one row had ever been written. Three things were rejected by the database:
* - actor_id references profiles, and staff are admin_users, so every logged-in
* admin's id broke the foreign key;
* - resource_id is a uuid, and callers often pass a name or a config key;
* - details was json_encode()d before PostgREST encoded it again.
* Staff now go in admin_user_id, anything that is not a uuid goes in
* resource_ref, and details is sent as an object
* (migration El3ab/migrations/2026-10-26-manager-columns.sql).
*
* Logging is best effort: a failed audit write is logged to the PHP error log
* but never turns the action it describes into a failure.
*/
class AuditLog class AuditLog
{ {
private const UUID = '/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i';
public static function log(string $action, string $resourceType, ?string $resourceId = null, ?array $oldValue = null, ?array $newValue = null): void public static function log(string $action, string $resourceType, ?string $resourceId = null, ?array $oldValue = null, ?array $newValue = null): void
{ {
$db = Database::getInstance();
$details = []; $details = [];
if ($oldValue !== null) $details['old'] = $oldValue; if ($oldValue !== null) $details['old'] = $oldValue;
if ($newValue !== null) $details['new'] = $newValue; if ($newValue !== null) $details['new'] = $newValue;
$db->insert('audit_log', [ $user = Auth::user() ?? [];
'actor_id' => Auth::user()['id'] ?? null, $adminId = $user['id'] ?? null;
if ($adminId !== null && !preg_match(self::UUID, (string)$adminId)) $adminId = null;
if (!empty($user['username'])) $details['admin'] = $user['username'];
$isUuid = $resourceId !== null && preg_match(self::UUID, $resourceId);
$ip = $_SERVER['REMOTE_ADDR'] ?? null;
if ($ip !== null && !filter_var($ip, FILTER_VALIDATE_IP)) $ip = null;
$row = [
'actor_id' => null,
'admin_user_id' => $adminId,
'action' => $action, 'action' => $action,
'resource_type' => $resourceType, 'resource_type' => $resourceType,
'resource_id' => $resourceId, 'resource_id' => $isUuid ? $resourceId : null,
'details' => !empty($details) ? json_encode($details) : '{}', 'resource_ref' => $isUuid ? null : $resourceId,
'ip_address' => $_SERVER['REMOTE_ADDR'] ?? null, 'details' => $details ?: new stdClass(),
]); 'ip_address' => $ip,
];
Database::getInstance()->soft(fn(Database $db) => $db->insert('audit_log', $row));
} }
} }
...@@ -271,10 +271,9 @@ class Auth ...@@ -271,10 +271,9 @@ class Auth
private static function updateLastLogin(string $userId): void private static function updateLastLogin(string $userId): void
{ {
try { try {
$db = Database::getInstance(); Database::getInstance()->soft(fn(Database $db) => $db->update('admin_users', ['id' => 'eq.' . $userId], [
$db->update('admin_users', ['id' => 'eq.' . $userId], [
'last_login_at' => date('c'), 'last_login_at' => date('c'),
]); ]));
} catch (\Throwable $e) { } catch (\Throwable $e) {
// Non-critical — don't break login if this fails // Non-critical — don't break login if this fails
} }
......
<?php <?php
/**
* PostgREST client.
*
* Every write that PostgREST rejects is recorded here. That is the whole point of
* the bookkeeping below: this class used to decode the error body and hand it
* back as if it were the inserted row, so a write to a column that does not
* exist (PGRST204), a bad type (22P02) or a broken foreign key (23503) came back
* truthy and the controller went on to flash "saved". Response::success() and
* Response::json() now consult writeErrors() and refuse to report success over
* a failed write.
*
* Writes whose failure must not fail the request (the audit trail, page-view
* counters, last-login stamps) go through soft(), which still logs them.
*/
class Database class Database
{ {
private static ?Database $instance = null; private static ?Database $instance = null;
private string $baseUrl; private string $baseUrl;
private string $serviceKey; private string $serviceKey;
/** Failed non-GET requests this request, outside soft(). */
private array $writeErrors = [];
/** The most recent failure of any kind, reads included. */
private ?array $lastError = null;
private int $softDepth = 0;
private function __construct() private function __construct()
{ {
$this->baseUrl = SUPABASE_URL . '/rest/v1'; $this->baseUrl = SUPABASE_URL . '/rest/v1';
...@@ -24,7 +44,7 @@ class Database ...@@ -24,7 +44,7 @@ class Database
{ {
$query = http_build_query($params); $query = http_build_query($params);
$url = "{$this->baseUrl}/{$table}" . ($query ? "?{$query}" : ''); $url = "{$this->baseUrl}/{$table}" . ($query ? "?{$query}" : '');
$response = $this->request('GET', $url, null, $headers); $response = $this->request('GET', $url, null, $headers, $table);
$result = json_decode($response['body'], true) ?? []; $result = json_decode($response['body'], true) ?? [];
if (!is_array($result) || (isset($result['message']) && !isset($result[0]))) { if (!is_array($result) || (isset($result['message']) && !isset($result[0]))) {
return []; return [];
...@@ -45,47 +65,162 @@ class Database ...@@ -45,47 +65,162 @@ class Database
$params['select'] = 'count'; $params['select'] = 'count';
$headers = ['Prefer' => 'count=exact']; $headers = ['Prefer' => 'count=exact'];
$url = "{$this->baseUrl}/{$table}?" . http_build_query($params); $url = "{$this->baseUrl}/{$table}?" . http_build_query($params);
$response = $this->request('GET', $url, null, $headers); $response = $this->request('GET', $url, null, $headers, $table);
$range = $response['headers']['content-range'] ?? '*/0'; $range = $response['headers']['content-range'] ?? '*/0';
$parts = explode('/', $range); $parts = explode('/', $range);
return (int)($parts[1] ?? 0); return (int)($parts[1] ?? 0);
} }
/**
* Insert one row (or a list of rows). Returns the first written row, or null
* when PostgREST rejected the write — never the error body.
*/
public function insert(string $table, array $data): ?array public function insert(string $table, array $data): ?array
{ {
$url = "{$this->baseUrl}/{$table}"; $url = "{$this->baseUrl}/{$table}";
$headers = ['Prefer' => 'return=representation']; $headers = ['Prefer' => 'return=representation'];
$response = $this->request('POST', $url, $data, $headers); $response = $this->request('POST', $url, $data, $headers, $table);
if (!$this->ok($response)) return null;
$result = json_decode($response['body'], true); $result = json_decode($response['body'], true);
return $result[0] ?? $result ?? null; if (!is_array($result)) return null;
return $result[0] ?? $result;
} }
/**
* Patch the rows matching $match. Returns the first updated row, [] when the
* filter matched nothing, or null when PostgREST rejected the write.
*/
public function update(string $table, array $match, array $data): ?array public function update(string $table, array $match, array $data): ?array
{ {
$query = http_build_query($match); $query = http_build_query($match);
$url = "{$this->baseUrl}/{$table}?{$query}"; $url = "{$this->baseUrl}/{$table}?{$query}";
$headers = ['Prefer' => 'return=representation']; $headers = ['Prefer' => 'return=representation'];
$response = $this->request('PATCH', $url, $data, $headers); $response = $this->request('PATCH', $url, $data, $headers, $table);
if (!$this->ok($response)) return null;
$result = json_decode($response['body'], true);
if (!is_array($result)) return [];
return $result[0] ?? $result;
}
/** Upsert on a conflict target. Same return contract as insert(). */
public function upsert(string $table, array $data, string $onConflict = ''): ?array
{
$url = "{$this->baseUrl}/{$table}" . ($onConflict !== '' ? '?on_conflict=' . urlencode($onConflict) : '');
$headers = ['Prefer' => 'return=representation,resolution=merge-duplicates'];
$response = $this->request('POST', $url, $data, $headers, $table);
if (!$this->ok($response)) return null;
$result = json_decode($response['body'], true); $result = json_decode($response['body'], true);
return $result[0] ?? $result ?? null; if (!is_array($result)) return null;
return $result[0] ?? $result;
} }
public function delete(string $table, array $match): bool public function delete(string $table, array $match): bool
{ {
$query = http_build_query($match); $query = http_build_query($match);
$url = "{$this->baseUrl}/{$table}?{$query}"; $url = "{$this->baseUrl}/{$table}?{$query}";
$response = $this->request('DELETE', $url); $response = $this->request('DELETE', $url, null, [], $table);
return $response['status'] >= 200 && $response['status'] < 300; return $this->ok($response);
} }
/**
* Call a Postgres function. Returns its decoded result, or null on failure
* (the failure is recorded like any other write).
*/
public function rpc(string $function, array $params = []): mixed public function rpc(string $function, array $params = []): mixed
{ {
$url = SUPABASE_URL . "/rest/v1/rpc/{$function}"; $url = SUPABASE_URL . "/rest/v1/rpc/{$function}";
$response = $this->request('POST', $url, $params); $response = $this->request('POST', $url, $params, [], 'rpc/' . $function);
if (!$this->ok($response)) return null;
return json_decode($response['body'], true); return json_decode($response['body'], true);
} }
private function request(string $method, string $url, ?array $body = null, array $extraHeaders = []): array // ─── Error bookkeeping ───────────────────────────────────────────────
/**
* Run best-effort writes: a failure inside is logged but does not count
* against the request, so it cannot turn a real success into an error.
*/
public function soft(callable $fn): mixed
{
$this->softDepth++;
try {
return $fn($this);
} finally {
$this->softDepth--;
}
}
/** Writes PostgREST rejected during this request (outside soft()). */
public function writeErrors(): array
{
return $this->writeErrors;
}
public function hasWriteErrors(): bool
{
return !empty($this->writeErrors);
}
/** The most recent failure, reads included: ['table','method','status','code','message']. */
public function lastError(): ?array
{
return $this->lastError;
}
/** One line for staff, naming what was rejected and why. */
public function lastErrorMessage(): string
{
$e = end($this->writeErrors) ?: $this->lastError;
if (!$e) return '';
return self::describe($e);
}
/** Forget recorded write failures, for a controller that has handled one itself. */
public function clearWriteErrors(): void
{
$this->writeErrors = [];
}
public static function describe(array $e): string
{
$what = $e['table'] ? "({$e['table']}) " : '';
$code = $e['code'] ? "[{$e['code']}] " : '';
return $what . $code . ($e['message'] ?: ('HTTP ' . $e['status']));
}
private function ok(array $response): bool
{
return $response['status'] >= 200 && $response['status'] < 300;
}
private function recordFailure(string $method, string $table, array $response): void
{
$decoded = json_decode($response['body'] ?? '', true);
$message = '';
$code = '';
if (is_array($decoded)) {
$message = (string)($decoded['message'] ?? $decoded['error'] ?? '');
if (!empty($decoded['details'])) $message .= ' — ' . $decoded['details'];
$code = (string)($decoded['code'] ?? '');
}
if ($response['status'] === 0) {
$message = 'تعذر الاتصال بقاعدة البيانات';
}
$error = [
'method' => $method,
'table' => $table,
'status' => $response['status'],
'code' => $code,
'message' => $message,
];
$this->lastError = $error;
error_log('[Database] ' . $method . ' ' . self::describe($error));
if ($method !== 'GET' && $this->softDepth === 0) {
$this->writeErrors[] = $error;
}
}
private function request(string $method, string $url, ?array $body = null, array $extraHeaders = [], string $table = ''): array
{ {
$ch = curl_init(); $ch = curl_init();
$headers = [ $headers = [
...@@ -126,7 +261,9 @@ class Database ...@@ -126,7 +261,9 @@ class Database
$response = curl_exec($ch); $response = curl_exec($ch);
if ($response === false) { if ($response === false) {
return ['status' => 0, 'body' => '', 'headers' => []]; $failed = ['status' => 0, 'body' => '', 'headers' => []];
$this->recordFailure($method, $table, $failed);
return $failed;
} }
$headerSize = curl_getinfo($ch, CURLINFO_HEADER_SIZE); $headerSize = curl_getinfo($ch, CURLINFO_HEADER_SIZE);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE); $status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
...@@ -141,10 +278,14 @@ class Database ...@@ -141,10 +278,14 @@ class Database
} }
} }
return [ $result = [
'status' => $status, 'status' => $status,
'body' => $responseBody, 'body' => $responseBody,
'headers' => $responseHeaders, 'headers' => $responseHeaders,
]; ];
if ($status < 200 || $status >= 300) {
$this->recordFailure($method, $table, $result);
}
return $result;
} }
} }
...@@ -2,8 +2,31 @@ ...@@ -2,8 +2,31 @@
class Response class Response
{ {
/**
* The write PostgREST rejected during this request, if any. A success reply
* must never be sent over one: the manager used to flash "saved" for writes
* that never landed, which is how 27 broken writes went unnoticed.
*/
private static function failedWrite(): ?string
{
if (!class_exists('Database', false)) return null;
$db = Database::getInstance();
return $db->hasWriteErrors() ? $db->lastErrorMessage() : null;
}
private static function failureText(string $detail): string
{
return 'لم يتم الحفظ: رفضت قاعدة البيانات العملية. ' . $detail;
}
public static function json(array $data, int $status = 200): void public static function json(array $data, int $status = 200): void
{ {
if ($status < 400 && !isset($data['error']) && ($detail = self::failedWrite()) !== null) {
$status = 500;
$data['success'] = false;
$data['ok'] = false;
$data['error'] = self::failureText($detail);
}
http_response_code($status); http_response_code($status);
header('Content-Type: application/json; charset=utf-8'); header('Content-Type: application/json; charset=utf-8');
echo json_encode($data, JSON_UNESCAPED_UNICODE); echo json_encode($data, JSON_UNESCAPED_UNICODE);
...@@ -12,6 +35,9 @@ class Response ...@@ -12,6 +35,9 @@ class Response
public static function redirect(string $url, array $flash = []): void public static function redirect(string $url, array $flash = []): void
{ {
if (($flash['type'] ?? '') === 'success' && ($detail = self::failedWrite()) !== null) {
$flash = ['type' => 'error', 'message' => self::failureText($detail)];
}
if (!empty($flash)) { if (!empty($flash)) {
$_SESSION['flash'] = $flash; $_SESSION['flash'] = $flash;
} }
...@@ -39,7 +65,11 @@ class Response ...@@ -39,7 +65,11 @@ class Response
public static function success(string $message, ?string $redirect = null): void public static function success(string $message, ?string $redirect = null): void
{ {
self::flash('success', $message); if (($detail = self::failedWrite()) !== null) {
self::flash('error', self::failureText($detail));
} else {
self::flash('success', $message);
}
if ($redirect) { if ($redirect) {
header("Location: {$redirect}"); header("Location: {$redirect}");
exit; exit;
......
<?php
/**
* Staff work in Cairo time; the database stores instants (timestamptz).
*
* The manager container runs PHP in UTC, so a datetime-local value such as
* "2026-11-01T18:00", typed by an organiser in Cairo, was saved as 18:00 UTC —
* two or three hours late depending on the season. Everything that turns a
* typed time into a stored one, or a stored one back into something shown,
* goes through here. Africa/Cairo carries Egypt's daylight-saving rules
* (UTC+3 from the last Friday of April to the last Thursday of October, UTC+2
* otherwise), so the offset is right on either side of the change.
*/
class Time
{
public const ZONE = 'Africa/Cairo';
public static function zone(): DateTimeZone
{
static $tz = null;
return $tz ??= new DateTimeZone(self::ZONE);
}
/**
* A time typed in Cairo (datetime-local "Y-m-d\TH:i", or "Y-m-d H:i") as an
* ISO 8601 UTC instant for the database. Null for empty or unparsable input.
*/
public static function toUtc(?string $local): ?string
{
$local = trim((string)$local);
if ($local === '') return null;
try {
$dt = new DateTimeImmutable($local, self::zone());
} catch (Exception $e) {
return null;
}
return $dt->setTimezone(new DateTimeZone('UTC'))->format('Y-m-d\TH:i:s\Z');
}
/** A stored instant as a datetime-local value in Cairo, for a form field. */
public static function toInput(?string $iso): string
{
$dt = self::parse($iso);
return $dt ? $dt->format('Y-m-d\TH:i') : '';
}
/** A stored instant shown in Cairo time. */
public static function show(?string $iso, string $format = 'Y/m/d H:i', string $empty = '-'): string
{
$dt = self::parse($iso);
return $dt ? $dt->format($format) : $empty;
}
/** Now, as a UTC instant for the database. */
public static function nowUtc(): string
{
return gmdate('Y-m-d\TH:i:s\Z');
}
private static function parse(?string $iso): ?DateTimeImmutable
{
if ($iso === null || trim($iso) === '') return null;
try {
return (new DateTimeImmutable($iso))->setTimezone(self::zone());
} catch (Exception $e) {
return null;
}
}
}
...@@ -3,6 +3,7 @@ ...@@ -3,6 +3,7 @@
session_start(); session_start();
require_once __DIR__ . '/config/app.php'; require_once __DIR__ . '/config/app.php';
require_once __DIR__ . '/core/Time.php';
require_once __DIR__ . '/core/Database.php'; require_once __DIR__ . '/core/Database.php';
require_once __DIR__ . '/core/Auth.php'; require_once __DIR__ . '/core/Auth.php';
require_once __DIR__ . '/core/Router.php'; require_once __DIR__ . '/core/Router.php';
......
...@@ -11,10 +11,12 @@ class AuditLogController ...@@ -11,10 +11,12 @@ class AuditLogController
public function index(array $params, string $method): void public function index(array $params, string $method): void
{ {
$queryParams = ['select' => '*', 'order' => 'created_at.desc']; // Staff actions name the admin account (admin_user_id); actor_id is only
// ever a player profile.
$queryParams = ['select' => '*,admin:admin_users(username,display_name)', 'order' => 'created_at.desc'];
if (!empty($_GET['actor'])) { if (!empty($_GET['actor'])) {
$queryParams['actor_id'] = "eq." . $_GET['actor']; $queryParams['admin_user_id'] = "eq." . $_GET['actor'];
} }
if (!empty($_GET['action'])) { if (!empty($_GET['action'])) {
$queryParams['action'] = "eq." . $_GET['action']; $queryParams['action'] = "eq." . $_GET['action'];
...@@ -23,7 +25,14 @@ class AuditLogController ...@@ -23,7 +25,14 @@ class AuditLogController
$queryParams['resource_type'] = "eq." . $_GET['resource_type']; $queryParams['resource_type'] = "eq." . $_GET['resource_type'];
} }
if (!empty($_GET['search'])) { if (!empty($_GET['search'])) {
$queryParams['resource_id'] = "eq." . $_GET['search']; // resource_id is a uuid: comparing it to free text fails the cast, so
// only use it for a uuid and search the text reference otherwise.
$search = trim($_GET['search']);
if (preg_match('/^[0-9a-f-]{36}$/i', $search)) {
$queryParams['resource_id'] = "eq." . $search;
} else {
$queryParams['resource_ref'] = 'ilike.*' . str_replace(['*', ','], '', $search) . '*';
}
} }
$countParams = $queryParams; $countParams = $queryParams;
...@@ -46,7 +55,7 @@ class AuditLogController ...@@ -46,7 +55,7 @@ class AuditLogController
Auth::requireRole('admin'); Auth::requireRole('admin');
$logs = $this->db->select('audit_log', [ $logs = $this->db->select('audit_log', [
'select' => '*', 'select' => '*,admin:admin_users(username)',
'order' => 'created_at.desc', 'order' => 'created_at.desc',
'limit' => 10000, 'limit' => 10000,
]); ]);
...@@ -61,10 +70,10 @@ class AuditLogController ...@@ -61,10 +70,10 @@ class AuditLogController
foreach ($logs as $log) { foreach ($logs as $log) {
fputcsv($output, [ fputcsv($output, [
$log['created_at'], $log['created_at'],
$log['actor_id'] ?? '', $log['admin']['username'] ?? ($log['details']['admin'] ?? ($log['actor_id'] ?? '')),
$log['action'], $log['action'],
$log['resource_type'], $log['resource_type'],
$log['resource_id'] ?? '', $log['resource_id'] ?? ($log['resource_ref'] ?? ''),
$log['ip_address'] ?? '', $log['ip_address'] ?? '',
]); ]);
} }
......
...@@ -10,7 +10,7 @@ ...@@ -10,7 +10,7 @@
<div class="table-toolbar"> <div class="table-toolbar">
<div class="table-search"> <div class="table-search">
<svg class="table-search-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg> <svg class="table-search-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
<input type="text" placeholder="بحث بالمعرف..." value="<?= View::e($_GET['search'] ?? '') ?>" onchange="location.href='/audit-log?search='+this.value"> <input type="text" placeholder="بحث بالمعرف أو الاسم..." value="<?= View::e($_GET['search'] ?? '') ?>" onchange="location.href='/audit-log?search='+this.value">
</div> </div>
<div class="flex gap-2"> <div class="flex gap-2">
<select class="form-select" style="width: auto; font-size: 12px;" onchange="location.href='/audit-log?resource_type='+this.value"> <select class="form-select" style="width: auto; font-size: 12px;" onchange="location.href='/audit-log?resource_type='+this.value">
...@@ -44,7 +44,7 @@ ...@@ -44,7 +44,7 @@
<?php foreach ($logs as $log): ?> <?php foreach ($logs as $log): ?>
<tr> <tr>
<td class="text-xs tabular-nums text-muted"><?= date('m/d H:i:s', strtotime($log['created_at'])) ?></td> <td class="text-xs tabular-nums text-muted"><?= date('m/d H:i:s', strtotime($log['created_at'])) ?></td>
<td class="font-medium"><?= View::e($log['actor_id'] ?? '-') ?></td> <td class="font-medium"><?= View::e($log['admin']['display_name'] ?? $log['admin']['username'] ?? ($log['details']['admin'] ?? ($log['actor_id'] ?? '-'))) ?></td>
<td> <td>
<?php <?php
$actionColors = ['create' => 'success', 'update' => 'info', 'delete' => 'danger', 'ban' => 'danger', 'unban' => 'success', 'toggle' => 'warning', 'grant' => 'success', 'revoke' => 'danger']; $actionColors = ['create' => 'success', 'update' => 'info', 'delete' => 'danger', 'ban' => 'danger', 'unban' => 'success', 'toggle' => 'warning', 'grant' => 'success', 'revoke' => 'danger'];
...@@ -52,7 +52,7 @@ ...@@ -52,7 +52,7 @@
<span class="badge badge-<?= $actionColors[$log['action']] ?? 'default' ?>"><?= View::e($log['action']) ?></span> <span class="badge badge-<?= $actionColors[$log['action']] ?? 'default' ?>"><?= View::e($log['action']) ?></span>
</td> </td>
<td class="text-sm"><?= View::e($log['resource_type'] ?? '') ?></td> <td class="text-sm"><?= View::e($log['resource_type'] ?? '') ?></td>
<td class="text-xs text-muted truncate" style="max-width: 100px;"><?= View::e($log['resource_id'] ?? '-') ?></td> <td class="text-xs text-muted truncate" style="max-width: 100px;"><?= View::e($log['resource_id'] ?? $log['resource_ref'] ?? '-') ?></td>
<td> <td>
<?php if (!empty($log['details']) && $log['details'] !== '{}'): ?> <?php if (!empty($log['details']) && $log['details'] !== '{}'): ?>
<button class="btn btn-ghost btn-sm" onclick='showLogDetail(<?= htmlspecialchars(json_encode($log), ENT_QUOTES) ?>)'>عرض</button> <button class="btn btn-ghost btn-sm" onclick='showLogDetail(<?= htmlspecialchars(json_encode($log), ENT_QUOTES) ?>)'>عرض</button>
......
...@@ -12,7 +12,16 @@ class AnalyticsService ...@@ -12,7 +12,16 @@ class AnalyticsService
return self::$db; return self::$db;
} }
/**
* Page-view counting is best effort: a failed counter write must never turn
* the public page or its beacon into an error.
*/
public static function trackView(string $tournamentId, string $section = 'overview'): void public static function trackView(string $tournamentId, string $section = 'overview'): void
{
self::db()->soft(fn() => self::trackViewNow($tournamentId, $section));
}
private static function trackViewNow(string $tournamentId, string $section): void
{ {
$ip = $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0'; $ip = $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
$visitorHash = hash('sha256', $ip . ($_SERVER['HTTP_USER_AGENT'] ?? '') . date('Y-m-d')); $visitorHash = hash('sha256', $ip . ($_SERVER['HTTP_USER_AGENT'] ?? '') . date('Y-m-d'));
...@@ -47,6 +56,11 @@ class AnalyticsService ...@@ -47,6 +56,11 @@ class AnalyticsService
} }
public static function incrementViewCount(string $tournamentId): void public static function incrementViewCount(string $tournamentId): void
{
self::db()->soft(fn() => self::incrementViewCountNow($tournamentId));
}
private static function incrementViewCountNow(string $tournamentId): void
{ {
$current = self::db()->selectOne('el3ab_tournaments', [ $current = self::db()->selectOne('el3ab_tournaments', [
'id' => "eq.{$tournamentId}", 'id' => "eq.{$tournamentId}",
...@@ -63,13 +77,13 @@ class AnalyticsService ...@@ -63,13 +77,13 @@ class AnalyticsService
$ip = $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0'; $ip = $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
$visitorHash = hash('sha256', $ip . ($_SERVER['HTTP_USER_AGENT'] ?? '') . date('Y-m-d')); $visitorHash = hash('sha256', $ip . ($_SERVER['HTTP_USER_AGENT'] ?? '') . date('Y-m-d'));
self::db()->insert('tournament_page_views', [ self::db()->soft(fn(Database $db) => $db->insert('tournament_page_views', [
'tournament_id' => $tournamentId, 'tournament_id' => $tournamentId,
'visitor_hash' => $visitorHash, 'visitor_hash' => $visitorHash,
'ip_country' => self::getCountryFromIp($ip), 'ip_country' => self::getCountryFromIp($ip),
'referrer' => $_SERVER['HTTP_REFERER'] ?? null, 'referrer' => $_SERVER['HTTP_REFERER'] ?? null,
'page_section' => $section, 'page_section' => $section,
]); ]));
} }
public static function getStats(string $tournamentId): array public static function getStats(string $tournamentId): array
......
...@@ -329,7 +329,10 @@ class UsersController ...@@ -329,7 +329,10 @@ class UsersController
$tempPassword = bin2hex(random_bytes(4)); // 8 character hex string $tempPassword = bin2hex(random_bytes(4)); // 8 character hex string
$hash = password_hash($tempPassword, PASSWORD_BCRYPT); $hash = password_hash($tempPassword, PASSWORD_BCRYPT);
$this->db->update('admin_users', ['id' => "eq.{$id}"], ['password_hash' => $hash]); if ($this->db->update('admin_users', ['id' => "eq.{$id}"], ['password_hash' => $hash]) === null) {
Response::error('تعذر إعادة تعيين كلمة المرور: ' . $this->db->lastErrorMessage(), "/users/{$id}");
return;
}
AuditLog::log('reset_password', 'admin_user', $id); AuditLog::log('reset_password', 'admin_user', $id);
......
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