Files
clinicpro/.claude/prompt/maintenance-mode.md
T
hamedandClaude Fable 5 7ac8ddbd25 feat(config): add central maintenance mode
Adds a platform-wide maintenance switch controlled from the admin panel.
A single kernel.request subscriber (priority 6, after the firewall listener)
short-circuits every request with 503, so no controller has to check it and
all API clients — the admin SPA, nobat724_front and clinic-pro-tauri — are
covered at once.

- SiteConfig gains five maintenance_* keys; no entity change, no migration
- MaintenanceService caches the state in Redis for 30s and is fail-open:
  a Redis or database failure never takes the site down by itself
- API responses reuse the BaseController::error() envelope with code
  MAINTENANCE_MODE plus a Retry-After header; browsers get a self-contained
  Twig page (inline CSS, noindex) that renders even mid-deploy
- Whitelist keeps /oauth/*, the login endpoints and /api/v1/admin/settings
  reachable, otherwise an admin could neither sign in nor switch it back off
- Admin bypass falls back to decoding the Authorization JWT, because several
  admin-panel endpoints sit in the public_endpoints firewall (security: false)
  where no token is ever resolved and isGranted always returns false
- A kernel.exception handler at priority 20 covers routing 404/405 and
  firewall 401, which are thrown before the request listener runs
- app:maintenance on|off|status is the escape hatch when the panel is down

Also removes a stray `APP_SECRET = ...` line from .env.dev: the spaces around
`=` are rejected by Symfony Dotenv, which made every console command and the
whole app fatal. The secret already lives in .env.local, as the comment above
that line instructs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 22:01:34 +03:30

263 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Maintenance Mode مرکزی (Web + API + پنل‌ها)
## پروژه
`clinicpro` (backend Symfony + پنل ادمین React)
کنترل مرکزی کاملاً در backend انجام می‌شود؛ چون `nobat724_front` و `clinic-pro-tauri` هر دو کلاینت همان `/api/v1/...` هستند، وقتی API با `503` پاسخ دهد آن‌ها هم عملاً وارد حالت تعمیرات می‌شوند. **نیازی به تغییر جداگانه در آن ریپوها نیست** (اختیاری: نمایش زیباتر پیام در فرانت — خارج از این پرامپت).
## زمینه
پروژه از قبل یک key-value store برای تنظیمات سایت دارد:
- Entity: `src/Config/Entity/SiteConfig.php` (`config_key` PK، `config_value` text، `updated_at` int)
- Repository: `src/Config/Repository/SiteConfigRepository.php``DEFAULTS` const، `get()`، `getAll()`، `set()`
- Controller: `src/Config/Controller/SiteConfigController.php``GET/PATCH /api/v1/admin/settings` با `#[IsGranted('ROLE_ADMIN')]` و whitelist `ALLOWED_KEYS`
- صفحه ادمین: `assets/admin/pages/SettingsPage.tsx` (route `settings` در `assets/admin/App.tsx:201`)
پس **نباید Entity جدید ساخت** — فقط کلید جدید به همین جدول اضافه می‌شود؛ **migration لازم نیست**.
همچنین چهار EventSubscriber در `src/Shared/EventSubscriber/` وجود دارد که الگوی موجود پروژه است:
| کلاس | رویداد | priority |
|---|---|---|
| `ExceptionSubscriber` | `kernel.exception` | 10 |
| `NumericFieldNormalizerSubscriber` | `kernel.request` | 8 |
| `AdminCspSubscriber` | `kernel.response` | 0 |
| `SecurityHeadersSubscriber` | `kernel.response` | 0 |
## هدف
یک Maintenance Mode حرفه‌ای که:
- با یک سوییچ از پنل ادمین کل سیستم (Web + API + همه کلاینت‌ها) وارد حالت تعمیرات شود
- کاربر عادی فقط صفحه/پاسخ maintenance ببیند
- فقط `ROLE_ADMIN` بتواند وارد شود و از پنل ادمین استفاده کند
- پیام و عنوان صفحه maintenance از پنل ادمین قابل ویرایش باشد
- کنترل **مرکزی** باشد (یک `kernel.request` subscriber) نه پراکنده در کنترلرها
## فایل‌های مرتبط
| فایل | نقش |
|------|-----|
| `src/Config/Repository/SiteConfigRepository.php` | افزودن کلیدهای جدید به `DEFAULTS` + کش |
| `src/Config/Controller/SiteConfigController.php` | افزودن کلیدها به `ALLOWED_KEYS` |
| `src/Config/Service/MaintenanceService.php` | **جدید** — منبع واحد تصمیم maintenance |
| `src/Shared/EventSubscriber/MaintenanceSubscriber.php` | **جدید** — کنترل مرکزی روی `kernel.request` |
| `src/Shared/Controller/HealthController.php` | باید در whitelist بماند |
| `src/Admin/Controller/AdminController.php` | catch-all `/admin/{reactRouting}` — SPA shell |
| `templates/maintenance.html.twig` | **جدید** — صفحه HTML حالت تعمیرات |
| `assets/admin/pages/SettingsPage.tsx` | افزودن بخش «حالت تعمیرات» |
| `docs/api/admin.md` | مستندسازی کلیدهای جدید + رفتار 503 |
## وضعیت فعلی
`src/Config/Repository/SiteConfigRepository.php:11`
```php
class SiteConfigRepository extends ServiceEntityRepository
{
public const DEFAULTS = [
'site_name' => '...',
// ... ~28 کلید
];
public function get(string $key): ?string { /* DB، fallback به DEFAULTS */ }
public function getAll(): array { /* findAll() + پر کردن با DEFAULTS */ }
public function set(string $key, ?string $value): void { /* بدون flush */ }
}
```
`src/Config/Controller/SiteConfigController.php:18`
```php
private const ALLOWED_KEYS = [ /* whitelist 28 کلید */ ];
// PATCH کلیدهای خارج از whitelist را بی‌صدا نادیده می‌گیرد
```
`src/Shared/EventSubscriber/NumericFieldNormalizerSubscriber.php:34` — الگوی subscriber روی `kernel.request`:
```php
public static function getSubscribedEvents(): array
{
return [KernelEvents::REQUEST => ['onKernelRequest', 8]];
}
```
`config/packages/security.yaml`**بدون `role_hierarchy`**؛ نقش‌ها تخت‌اند. `/admin/*` در هیچ firewall pattern نیست (HTML عمومی؛ auth سمت کلاینت + روی `/api/v1`).
`config/packages/cache.yaml` — app pool روی `cache.adapter.redis` (`REDIS_URL`). هیچ‌جا `SiteConfig` کش نمی‌شود.
---
## وظایف
### ۱. افزودن کلیدهای تنظیمات
در `SiteConfigRepository::DEFAULTS` این کلیدها اضافه شوند:
```php
'maintenance_enabled' => '0',
'maintenance_title' => 'در حال به‌روزرسانی سیستم',
'maintenance_message' => 'سامانه موقتاً برای انجام عملیات فنی در دسترس نیست. لطفاً چند دقیقه دیگر مجدداً تلاش کنید.',
'maintenance_retry_after' => '600', // ثانیه — هدر Retry-After
'maintenance_allowed_ips' => '', // CSV، اختیاری — دور زدن maintenance برای IP خاص
```
همین پنج کلید به `SiteConfigController::ALLOWED_KEYS` اضافه شوند تا از `PATCH /api/v1/admin/settings` قابل تغییر باشند.
**نکته:** `maintenance_enabled` باید boolean-ish پارس شود (`'1'`, `'true'`, `'on'` → true). مقدار ورودی از PATCH ممکن است `true` boolean یا `"1"` باشد.
### ۲. کش کردن خواندن تنظیمات (الزامی، نه اختیاری)
`MaintenanceSubscriber` روی **هر** درخواست اجرا می‌شود؛ زدن به DB در هر request قابل قبول نیست.
در `MaintenanceService` مقدار را از `CacheInterface` (app pool، همان الگوی `TokenService.php:15`) با TTL کوتاه (مثلاً ۳۰ ثانیه) بخوان:
```php
final class MaintenanceService
{
private const CACHE_KEY = 'maintenance_state';
private const TTL = 30;
public function __construct(
private SiteConfigRepository $configRepo,
private CacheInterface $cache,
) {}
/** @return array{enabled:bool,title:string,message:string,retryAfter:int,allowedIps:string[]} */
public function getState(): array
{
return $this->cache->get(self::CACHE_KEY, function (ItemInterface $item): array {
$item->expiresAfter(self::TTL);
// خواندن ۵ کلید از configRepo و نرمال‌سازی نوع
});
}
public function invalidate(): void
{
$this->cache->delete(self::CACHE_KEY);
}
}
```
در `SiteConfigController` بعد از هر `PATCH` موفق روی هر کلید `maintenance_*`، متد `invalidate()` صدا زده شود تا تغییر فوراً اعمال شود (نه بعد از ۳۰ ثانیه).
**Edge case:** اگر Redis در دسترس نبود، `getState()` نباید کل سایت را down کند — استثنا را بگیر و مستقیم از DB بخوان؛ اگر DB هم خطا داد `enabled => false` برگردان (fail-open). Maintenance mode نباید خودش عامل قطعی شود.
### ۳. Subscriber مرکزی
فایل جدید `src/Shared/EventSubscriber/MaintenanceSubscriber.php`، دقیقاً با الگوی سایر subscriberها در همان پوشه.
```php
public static function getSubscribedEvents(): array
{
// priority بالا تا قبل از firewall/router کار کند اما بعد از تشخیص مسیر
return [KernelEvents::REQUEST => ['onKernelRequest', 6]];
}
```
منطق:
```
if (!$event->isMainRequest()) return;
if (!$state['enabled']) return;
$path = $request->getPathInfo();
// 1) whitelist مسیرهایی که هرگز نباید بلاک شوند
if (isWhitelisted($path)) return;
// 2) IP allowlist
if (in_array($request->getClientIp(), $state['allowedIps'], true)) return;
// 3) اگر کاربر لاگین‌شده ROLE_ADMIN دارد → عبور
if ($this->security->isGranted('ROLE_ADMIN')) return;
// 4) بلاک
$event->setResponse($this->buildResponse($request, $state));
$event->stopPropagation();
```
**whitelist مسیرها (بحرانی — بدون این، ادمین قفل بیرون می‌ماند):**
- `/api/v1/auth/*` (کل مسیرهای ورود/OTP/refresh token) — وگرنه ادمین نمی‌تواند لاگین کند
- `/api/v1/admin/settings` (GET و PATCH) — وگرنه راه خاموش کردن maintenance بسته می‌شود
- مسیر health check از `src/Shared/Controller/HealthController.php`
- `/admin` و `/admin/*` (SPA shell) — خودِ HTML باید بارگذاری شود؛ محافظت واقعی روی `/api/v1` است
- asset‌های Encore (`/build/*`) و `/favicon.ico`
- در محیط `dev`: `/_wdt/*`، `/_profiler/*`
whitelist را به‌صورت آرایه‌ی const از prefixها در همان کلاس تعریف کن، نه پراکنده در if.
**نکته ترتیب اجرا:** `Security::isGranted()` نیاز به توکن firewall دارد. Firewall listener روی `kernel.request` با priority `8` اجرا می‌شود. اگر priority انتخابی باعث شود توکن هنوز ست نشده باشد، `isGranted` همیشه false برمی‌گرداند و ادمین هم بلاک می‌شود. **این را عملاً تست کن**: با کاربر ادمین لاگین‌شده و maintenance روشن، یک endpoint معمولی `/api/v1/...` را صدا بزن و مطمئن شو `200` می‌گیری نه `503`. اگر بلاک شد، priority را پایین‌تر از firewall ببر (عدد کوچک‌تر) تا بعد از authentication اجرا شود.
### ۴. تفکیک پاسخ API از پاسخ Web
`buildResponse()` باید بر اساس نوع درخواست تصمیم بگیرد:
**درخواست API** — اگر `str_starts_with($path, '/api/')` یا هدر `Accept` شامل `application/json` بود:
```php
return new JsonResponse([
'success' => false,
'data' => null,
'errors' => [[
'code' => 'MAINTENANCE_MODE',
'message' => $state['message'],
]],
], 503, ['Retry-After' => (string) $state['retryAfter']]);
```
فرمت دقیقاً باید با envelope خطای `BaseController::error()` (`src/Shared/Controller/BaseController.php:32`) یکی باشد تا `nobat724_front/services/response.js` و `clinic-pro-tauri/src/service/response.js` بدون تغییر بتوانند آن را parse کنند.
**درخواست Web** — رندر `templates/maintenance.html.twig` با status `503` و همان هدر `Retry-After`.
قالب باید:
- RTL، فارسی، فونت Vazir (هماهنگ با بقیه templateها)
- بدون وابستگی به build اسِت‌ها (CSS اینلاین) — چون ممکن است در حین deploy اجرا شود
- `title` و `message` را از state بگیرد
- `noindex` در متا (`<meta name="robots" content="noindex">`) تا صفحه تعمیرات ایندکس نشود
### ۵. تعامل با `ExceptionSubscriber`
`ExceptionSubscriber` روی `kernel.exception` با priority `10` است. چون maintenance پاسخ را روی `kernel.request` ست می‌کند و exception پرتاب نمی‌کند، تداخلی نباید باشد — اما بررسی کن که `setResponse()` + `stopPropagation()` باعث رد شدن از `SecurityHeadersSubscriber` (روی `kernel.response`) نشود. `stopPropagation` فقط روی همان event اثر دارد، پس `kernel.response` همچنان اجرا می‌شود — تأیید کن هدرهای امنیتی روی پاسخ 503 هم ست می‌شوند.
### ۶. UI پنل ادمین
در `assets/admin/pages/SettingsPage.tsx` یک بخش (Card/Section هم‌شکل با بخش‌های موجود همان فایل) با عنوان «حالت تعمیرات» اضافه کن:
- سوییچ/چک‌باکس `maintenance_enabled`
- `TextField` برای `maintenance_title`
- `TextField` چندخطی برای `maintenance_message`
- عدد برای `maintenance_retry_after` (ثانیه)
- ورودی متنی `maintenance_allowed_ips` (CSV) با helper text
الزامات UI:
- **حتماً از کامپوننت‌ها و تم موجود همان صفحه استفاده کن؛ طراحی جدید نساز.** اگر `select` لازم شد، `SearchableSelect` استفاده شود نه `<select>` بومی.
- وقتی سوییچ روشن می‌شود، قبل از ذخیره یک تأیید (confirm dialog) نشان بده — این عمل کل سایت را برای کاربران عادی از دسترس خارج می‌کند.
- وقتی maintenance فعال است، یک بنر هشدار ثابت و واضح در بالای صفحه (یا `Topbar`) نمایش داده شود تا ادمین فراموش نکند سایت down است.
- خواندن/نوشتن دقیقاً از همان `GET/PATCH /api/v1/admin/settings` موجود؛ endpoint جدید نساز.
- دسترسی صفحه فقط نقش `admin` (همان الگوی `RoleRoute` در `App.tsx`).
### ۷. دستور کنسول (اختیاری اما توصیه‌شده)
یک command مثل `app:maintenance` با آرگومان `on|off|status` بساز تا اگر پنل ادمین به هر دلیل در دسترس نبود، از طریق `ddev exec php bin/console app:maintenance off` بتوان خارج شد. باید بعد از تغییر، کش را `invalidate()` کند.
## نکات مهم
- **Entity جدید نساز و migration ننویس** — `SiteConfig` کافی است.
- Fail-open الزامی است: هر خطای Redis/DB داخل `MaintenanceService` نباید سایت را بلاک کند.
- بدون کش، subscriber به‌ازای هر request یک کوئری می‌زند — این را حتماً پیاده کن.
- `role_hierarchy` وجود ندارد؛ `ROLE_ADMIN` باید مستقیم در `roles` کاربر باشد. فرض نکن نقش‌های دیگر آن را ارث می‌برند.
- کاربران با نقش `ROLE_DOCTOR` / `ROLE_CLINIC` / `ROLE_SECRETARY` **باید** بلاک شوند — فقط ادمین عبور می‌کند.
- پاسخ 503 برای API باید دقیقاً envelope خطای پروژه را داشته باشد؛ اگر شکل متفاوتی برگردانی، کلاینت‌های `nobat724_front` و `clinic-pro-tauri` هنگام parse خطا می‌دهند.
- بعد از اتمام، `docs/api/admin.md` را به‌روز کن: کلیدهای جدید تنظیمات + توضیح رفتار `503 MAINTENANCE_MODE` و هدر `Retry-After` روی همه endpointها.
- تست دستی الزامی (با اکانت `09390039833 / 09390039833`):
1. maintenance روشن → کاربر ناشناس روی `/api/v1/doctor` باید `503` بگیرد
2. کاربر ادمین لاگین‌شده روی همان endpoint باید `200` بگیرد
3. `POST /api/v1/auth/...` (لاگین) باید در حالت maintenance کار کند
4. `PATCH /api/v1/admin/settings` با `maintenance_enabled=0` باید سایت را فوراً برگرداند (نه بعد از ۳۰ ثانیه)
5. باز کردن `/` در مرورگر → صفحه HTML تعمیرات با status 503