Commit 7d9a4f22 authored by Mahmoud Aglan's avatar Mahmoud Aglan

fix(feature-flags): the list shows the real flags and edits land

feature_flags has id, name, description, is_enabled, rollout_percentage and
conditions. The list ordered by a category column that does not exist, so
the query failed and the page always said there were no flags (13 exist);
create and edit wrote label, label_ar, target, category and target_value
and were all rejected. Flags now get Arabic names and groups for the
platform's own ids, a partial rollout is rollout_percentage, and the
maintenance_mode message, English message and end time go in conditions
({message_ar, message, until}) as the game's api/config.php reads them.
Turning maintenance on asks for confirmation; built-in flags cannot be
deleted, only switched off.
Co-Authored-By: 's avatarClaude Opus 5.5 <noreply@anthropic.com>
parent 952e2ce6
<?php
/**
* Feature flags (public.feature_flags).
*
* The table has id, name, description, is_enabled, rollout_percentage and
* conditions (jsonb). This module used to order the list by a category column
* and write label, label_ar, target, category and target_value, none of which
* exist: the list query failed, so the page always said there were no flags,
* and every create or edit was rejected.
*
* The game reads the flags at boot from its api/config.php: id => is_enabled,
* rollout_percentage for a partial rollout, and for maintenance_mode the
* message in conditions {"message_ar", "message", "until"}.
*/
class FeatureFlagsController
{
private Database $db;
/** Arabic names and groups for the flags the platform ships with. */
public const KNOWN = [
'maintenance_mode' => ['وضع الصيانة', 'المنصة'],
'matchmaking_enabled' => ['البحث عن مباريات', 'اللعب'],
'ranked_games_enabled' => ['المباريات المصنفة', 'اللعب'],
'bot_games_enabled' => ['اللعب ضد البوتات', 'اللعب'],
'spectator_enabled' => ['وضع المشاهدة', 'اللعب'],
'domino_enabled' => ['لعبة الدومينو', 'اللعب'],
'tournaments_enabled' => ['البطولات', 'البطولات'],
'chat_enabled' => ['الدردشة', 'التواصل'],
'clubs_enabled' => ['الأندية', 'التواصل'],
'cosmetics_shop_enabled' => ['المتجر', 'الاقتصاد'],
'ads_enabled' => ['الإعلانات', 'الاقتصاد'],
'charity_enabled' => ['التبرعات الخيرية', 'الاقتصاد'],
'anti_cheat_enabled' => ['مكافحة الغش', 'المنصة'],
];
public function __construct()
{
$this->db = Database::getInstance();
}
public static function labelOf(array $flag): string
{
return self::KNOWN[$flag['id']][0] ?? ($flag['name'] ?: $flag['id']);
}
public static function groupOf(array $flag): string
{
return self::KNOWN[$flag['id']][1] ?? 'أخرى';
}
public function list(array $params, string $method): void
{
$category = $_GET['category'] ?? '';
$queryParams = ['select' => '*', 'order' => 'category.asc,id.asc'];
if ($category) $queryParams['category'] = "eq.{$category}";
$flags = $this->db->select('feature_flags', $queryParams);
$flags = $this->db->select('feature_flags', ['select' => '*', 'order' => 'id.asc']);
$categories = array_unique(array_column($flags, 'category'));
$categories = array_values(array_unique(array_map([self::class, 'groupOf'], $flags)));
if ($category !== '') {
$flags = array_values(array_filter($flags, fn($f) => self::groupOf($f) === $category));
}
$pageTitle = 'الميزات';
$moduleCSS = 'feature-flags';
......@@ -39,30 +79,36 @@ class FeatureFlagsController
$validator = Validator::make($_POST)
->required('id', 'المعرف')
->required('label', 'التسمية')
->required('label_ar', 'التسمية بالعربية');
->required('name', 'الاسم');
if ($validator->fails()) {
Response::error($validator->firstError(), '/feature-flags/create');
return;
}
$id = strtolower(str_replace([' ', '-'], '_', trim($_POST['id'])));
if (!preg_match('/^[a-z][a-z0-9_]{2,60}$/', $id)) {
Response::error('المعرف: حروف إنجليزية صغيرة وأرقام و _ فقط', '/feature-flags/create');
return;
}
if ($this->db->selectOne('feature_flags', ['id' => "eq.{$id}", 'select' => 'id'])) {
Response::error('يوجد ميزة بهذا المعرف', '/feature-flags/create');
return;
}
$data = [
'id' => strtolower(str_replace(' ', '_', trim($_POST['id']))),
'label' => trim($_POST['label']),
'label_ar' => trim($_POST['label_ar']),
'id' => $id,
'name' => trim($_POST['name']),
'description' => trim($_POST['description'] ?? ''),
'is_enabled' => isset($_POST['is_enabled']),
'target' => $_POST['target'] ?? 'all',
'category' => trim($_POST['category'] ?? 'general'),
'rollout_percentage' => $this->percentage(),
'conditions' => $this->conditions($id, []),
];
if ($_POST['target'] === 'percentage') {
$data['target_value'] = json_encode(['percentage' => (int)($_POST['percentage'] ?? 0)]);
if ($this->db->insert('feature_flags', $data) === null) {
Response::error('لم تُضف الميزة: ' . $this->db->lastErrorMessage(), '/feature-flags/create');
return;
}
$this->db->insert('feature_flags', $data);
AuditLog::log('create', 'feature_flag', $data['id'], null, $data);
AuditLog::log('create', 'feature_flag', $id, null, $data);
Response::success('تم إضافة الميزة', '/feature-flags');
}
......@@ -81,21 +127,25 @@ class FeatureFlagsController
Auth::requireCsrf();
$id = $params['id'];
$old = $this->db->selectOne('feature_flags', ['id' => "eq.{$id}"]);
if (!$old) { Response::error('الميزة غير موجودة', '/feature-flags'); return; }
$existing = $old['conditions'] ?? [];
if (is_string($existing)) $existing = json_decode($existing, true) ?: [];
$data = [
'label' => trim($_POST['label']),
'label_ar' => trim($_POST['label_ar']),
'name' => trim($_POST['name'] ?? '') ?: $old['name'],
'description' => trim($_POST['description'] ?? ''),
'target' => $_POST['target'] ?? 'all',
'category' => trim($_POST['category'] ?? 'general'),
'rollout_percentage' => $this->percentage(),
'conditions' => $this->conditions($id, is_array($existing) ? $existing : []),
'updated_at' => date('c'),
];
if ($_POST['target'] === 'percentage') {
$data['target_value'] = json_encode(['percentage' => (int)($_POST['percentage'] ?? 0)]);
if ($this->db->update('feature_flags', ['id' => "eq.{$id}"], $data) === null) {
Response::error('لم تُحفظ الميزة: ' . $this->db->lastErrorMessage(), "/feature-flags/{$id}/edit");
return;
}
$this->db->update('feature_flags', ['id' => "eq.{$id}"], $data);
AuditLog::log('update', 'feature_flag', $id);
AuditLog::log('update', 'feature_flag', $id, $old, $data);
Response::success('تم تحديث الميزة', '/feature-flags');
}
......@@ -109,15 +159,49 @@ class FeatureFlagsController
$newState = !$flag['is_enabled'];
$this->db->update('feature_flags', ['id' => "eq.{$id}"], ['is_enabled' => $newState, 'updated_at' => date('c')]);
AuditLog::log('toggle', 'feature_flag', $id, null, ['is_enabled' => $newState]);
AuditLog::log('toggle', 'feature_flag', $id, ['is_enabled' => !$newState], ['is_enabled' => $newState]);
if ($id === 'maintenance_mode') {
Response::success($newState ? 'تم تفعيل وضع الصيانة: يراه اللاعبون عند فتح التطبيق' : 'تم إيقاف وضع الصيانة', '/feature-flags');
return;
}
Response::success($newState ? 'تم تفعيل الميزة' : 'تم تعطيل الميزة', '/feature-flags');
}
public function delete(array $params, string $method): void
{
Auth::requireCsrf();
$this->db->delete('feature_flags', ['id' => "eq.{$params['id']}"]);
AuditLog::log('delete', 'feature_flag', $params['id']);
$id = $params['id'];
if (isset(self::KNOWN[$id])) {
Response::error('هذه ميزة أساسية في المنصة ولا تُحذف؛ يمكن تعطيلها', '/feature-flags');
return;
}
$this->db->delete('feature_flags', ['id' => "eq.{$id}"]);
AuditLog::log('delete', 'feature_flag', $id);
Response::success('تم حذف الميزة', '/feature-flags');
}
/** 100 = everyone; lower rolls the flag out to that share of players. */
private function percentage(): int
{
if (($_POST['target'] ?? 'all') !== 'percentage') return 100;
return max(0, min(100, (int)($_POST['percentage'] ?? 100)));
}
/**
* conditions jsonb, keeping whatever keys are already there. For
* maintenance_mode it carries the message the app shows and when it ends.
*/
private function conditions(string $id, array $existing): array|stdClass
{
if ($id === 'maintenance_mode') {
foreach (['message_ar', 'message'] as $k) {
$v = trim((string)($_POST[$k] ?? ''));
if ($v === '') unset($existing[$k]); else $existing[$k] = $v;
}
$until = Time::toUtc($_POST['until'] ?? '');
if ($until) $existing['until'] = $until; else unset($existing['until']);
}
// An empty jsonb object, as the column default, not an empty list.
return $existing ?: new stdClass();
}
}
......@@ -11,25 +11,48 @@
<?php if (!$isEdit): ?>
<div class="form-group"><label class="form-label">المعرف * (حروف صغيرة، _ للفصل)</label><input type="text" name="id" class="form-input" required dir="ltr" placeholder="enable_new_matchmaking"><span class="form-error"></span></div>
<?php endif; ?>
<div class="grid grid-2 gap-4">
<div class="form-group"><label class="form-label">التسمية (English) *</label><input type="text" name="label" class="form-input" value="<?= View::e($flag['label'] ?? '') ?>" required dir="ltr"><span class="form-error"></span></div>
<div class="form-group"><label class="form-label">التسمية (عربي) *</label><input type="text" name="label_ar" class="form-input" value="<?= View::e($flag['label_ar'] ?? '') ?>" required></div>
<?php
$pct = (int)($flag['rollout_percentage'] ?? 100);
$cond = $flag['conditions'] ?? [];
if (is_string($cond)) $cond = json_decode($cond, true) ?: [];
$isMaintenance = ($flag['id'] ?? '') === 'maintenance_mode';
?>
<div class="form-group">
<label class="form-label">الاسم *</label>
<input type="text" name="name" class="form-input" value="<?= View::e($flag['name'] ?? '') ?>" required>
<span class="form-error"></span>
</div>
<div class="form-group"><label class="form-label">الوصف</label><textarea name="description" class="form-input"><?= View::e($flag['description'] ?? '') ?></textarea></div>
<div class="form-group"><label class="form-label">الفئة</label><input type="text" name="category" class="form-input" value="<?= View::e($flag['category'] ?? 'general') ?>" placeholder="general"></div>
<div class="form-group">
<label class="form-label">الاستهداف</label>
<select name="target" class="form-select" id="targetSelect" onchange="togglePercentage()">
<option value="all" <?= ($flag['target'] ?? 'all') === 'all' ? 'selected' : '' ?>>الجميع</option>
<option value="percentage" <?= ($flag['target'] ?? '') === 'percentage' ? 'selected' : '' ?>>نسبة مئوية</option>
<option value="user_list" <?= ($flag['target'] ?? '') === 'user_list' ? 'selected' : '' ?>>مستخدمون محددون</option>
<option value="org_list" <?= ($flag['target'] ?? '') === 'org_list' ? 'selected' : '' ?>>منظمات محددة</option>
<option value="all" <?= $pct >= 100 ? 'selected' : '' ?>>كل اللاعبين</option>
<option value="percentage" <?= $pct < 100 ? 'selected' : '' ?>>نسبة من اللاعبين</option>
</select>
</div>
<div class="form-group hidden" id="percentageGroup">
<label class="form-label">النسبة %</label>
<input type="number" name="percentage" class="form-input" min="0" max="100" value="<?php $tv = json_decode($flag['target_value'] ?? '{}', true); echo $tv['percentage'] ?? 50; ?>">
<input type="number" name="percentage" class="form-input" min="0" max="100" value="<?= $pct < 100 ? $pct : 50 ?>">
<span class="form-hint">يختار التطبيق اللاعبين بثبات حسب معرف كل لاعب، فيبقى اللاعب نفسه داخل النسبة أو خارجها.</span>
</div>
<?php if ($isMaintenance): ?>
<div class="card card-inner mb-4 p-4">
<h3 class="text-sm font-medium mb-3">رسالة الصيانة</h3>
<div class="form-group">
<label class="form-label">الرسالة بالعربية</label>
<textarea name="message_ar" class="form-input" rows="2" placeholder="نعمل على تحسينات، نعود خلال ساعة"><?= View::e($cond['message_ar'] ?? '') ?></textarea>
</div>
<div class="form-group">
<label class="form-label">الرسالة بالإنجليزية</label>
<textarea name="message" class="form-input" rows="2" dir="ltr"><?= View::e($cond['message'] ?? '') ?></textarea>
</div>
<div class="form-group">
<label class="form-label">تنتهي الصيانة (بتوقيت القاهرة)</label>
<input type="datetime-local" name="until" class="form-input" value="<?= Time::toInput($cond['until'] ?? null) ?>" dir="ltr">
<span class="form-hint">يُعرض للاعبين فقط؛ إيقاف الصيانة يتم بزر التفعيل في القائمة.</span>
</div>
</div>
<?php endif; ?>
<?php if (!$isEdit): ?>
<label class="toggle mb-5"><input type="checkbox" name="is_enabled"><span class="toggle-track"></span><span>مفعّلة عند الإنشاء</span></label>
<?php endif; ?>
......
......@@ -6,6 +6,8 @@
</a>
</div>
<p class="text-sm text-muted mb-4">يقرأ التطبيق هذه المفاتيح عند فتحه (api/config.php)، فيظهر التغيير للاعب في المرة التالية التي يفتح فيها التطبيق.</p>
<?php if (!empty($categories)): ?>
<div class="filter-pills mb-5">
<a href="/feature-flags" class="filter-pill <?= empty($category) ? 'active' : '' ?>">الكل</a>
......@@ -20,41 +22,50 @@
<?php else: ?>
<div class="grid grid-2 stagger">
<?php foreach ($flags as $flag): ?>
<?php
$label = FeatureFlagsController::labelOf($flag);
$pct = (int)($flag['rollout_percentage'] ?? 100);
$cond = $flag['conditions'] ?? [];
if (is_string($cond)) $cond = json_decode($cond, true) ?: [];
$isMaintenance = $flag['id'] === 'maintenance_mode';
?>
<div class="card">
<div class="flex items-start justify-between">
<div class="flex-1">
<div class="flex items-center gap-3 mb-2">
<h3 class="font-semibold"><?= View::e($flag['label_ar']) ?></h3>
<span class="badge badge-default"><?= View::e($flag['category']) ?></span>
<h3 class="font-semibold"><?= View::e($label) ?></h3>
<span class="badge badge-default"><?= View::e(FeatureFlagsController::groupOf($flag)) ?></span>
<?php if ($isMaintenance && !empty($flag['is_enabled'])): ?>
<span class="badge badge-danger">المنصة في الصيانة</span>
<?php endif; ?>
</div>
<p class="text-xs text-muted mb-1" dir="ltr"><?= View::e($flag['id']) ?></p>
<?php if ($flag['description']): ?>
<p class="text-xs text-muted mb-1" dir="ltr"><?= View::e($flag['id']) ?><?= ($flag['name'] ?? '') !== '' && $flag['name'] !== $label ? ' — ' . View::e($flag['name']) : '' ?></p>
<?php if (!empty($flag['description'])): ?>
<p class="text-sm text-secondary mt-2"><?= View::e($flag['description']) ?></p>
<?php endif; ?>
<div class="flex items-center gap-3 mt-3">
<span class="text-xs text-muted">
<?php
$targets = ['all' => 'الجميع', 'percentage' => 'نسبة مئوية', 'user_list' => 'مستخدمون محددون', 'org_list' => 'منظمات محددة'];
echo $targets[$flag['target']] ?? $flag['target'];
?>
</span>
<?php if ($flag['target'] === 'percentage'): ?>
<?php $tv = json_decode($flag['target_value'] ?? '{}', true); ?>
<span class="badge badge-info"><?= $tv['percentage'] ?? 0 ?>%</span>
<span class="text-xs text-muted"><?= $pct >= 100 ? 'لكل اللاعبين' : 'نسبة من اللاعبين' ?></span>
<?php if ($pct < 100): ?>
<span class="badge badge-info"><?= $pct ?>%</span>
<?php endif; ?>
<?php if ($isMaintenance && !empty($cond['message_ar'])): ?>
<span class="text-xs text-muted truncate" style="max-width: 220px;">الرسالة: <?= View::e($cond['message_ar']) ?></span>
<?php endif; ?>
</div>
</div>
<form method="POST" action="/feature-flags/<?= View::e($flag['id']) ?>/toggle">
<input type="hidden" name="_csrf" value="<?= Auth::csrfToken() ?>">
<label class="toggle">
<input type="checkbox" <?= $flag['is_enabled'] ? 'checked' : '' ?> onchange="this.closest('form').submit()">
<input type="checkbox" <?= !empty($flag['is_enabled']) ? 'checked' : '' ?> aria-label="<?= View::e($label) ?>" onchange="<?= $isMaintenance ? "if (!confirm(this.checked ? 'تفعيل وضع الصيانة يمنع اللاعبين من دخول التطبيق. متأكد؟' : 'إيقاف وضع الصيانة؟')) { this.checked = !this.checked; return; } " : '' ?>this.closest('form').submit()">
<span class="toggle-track"></span>
</label>
</form>
</div>
<div class="flex gap-2 mt-4 pt-3" style="border-top: 1px solid var(--border);">
<a href="/feature-flags/<?= View::e($flag['id']) ?>/edit" class="btn btn-ghost btn-sm">تعديل</a>
<button class="btn btn-ghost btn-sm" style="color: var(--danger);" onclick="confirmDelete('/feature-flags/<?= View::e($flag['id']) ?>/delete', '<?= View::e($flag['label_ar']) ?>')">حذف</button>
<?php if (!isset(FeatureFlagsController::KNOWN[$flag['id']])): ?>
<button class="btn btn-ghost btn-sm" style="color: var(--danger);" onclick="confirmDelete('/feature-flags/<?= View::e($flag['id']) ?>/delete', '<?= View::e($label) ?>')">حذف</button>
<?php endif; ?>
</div>
</div>
<?php endforeach; ?>
......
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