# لاگینگ سراسری پروژه + نمایش لاگ‌ها در پنل ادمین

## پروژه

`clinicpro` (backend Symfony + پنل ادمین React). تک-ریپو، cross-repo نیست.

## زمینه

الان پروژه **monolog ندارد** (در `composer.lock` فقط به‌عنوان suggestion آمده، نصب نیست). در نتیجه `Psr\Log\LoggerInterface` به logger مینیمال Symfony (`Symfony\Component\HttpKernel\Log\Logger`) بایند می‌شود که فقط رشته‌ی message را به **stderr** می‌نویسد. روی Liara این یعنی لاگ‌ها فقط در `liara logs` دیده می‌شوند و هیچ‌جا persist نمی‌شوند.

دو مشکل:
1. از ۲۰۴ فایل `src/`، فقط ۵ فایل اصلاً لاگ می‌زنند (`PasswordAuthenticator`, `PreRegistrationController`, `ApiIrService`, `ExceptionSubscriber`, `PatientController`). خیلی از catch blockها استثناء را بی‌صدا می‌خورند (مثلاً `KavehNegarProvider::send()` که `catch (\Throwable) { return false; }`). وقتی روی prod چیزی می‌شکند، رد قابل‌ردیابی نمی‌ماند.
2. ادمین هیچ راهی برای دیدن لاگ‌ها از داخل پنل ندارد؛ باید به shell سرور دسترسی داشته باشد.

نمونه‌ی استانداردِ خوب که تازه در `ExceptionSubscriber` نوشته شده و باید **الگوی کل پروژه** شود (کلاس/پیام/محل در خودِ message تا روی پلتفرم‌هایی با logger پیش‌فرض هم دیده شود):

```php
// src/Shared/EventSubscriber/ExceptionSubscriber.php  (وضعیت فعلی، الگوی مرجع)
$this->logger->error(sprintf(
    'Unhandled exception: %s: %s @ %s:%d [path=%s]',
    $exception::class,
    $exception->getMessage(),
    $exception->getFile(),
    $exception->getLine(),
    $event->getRequest()->getPathInfo(),
), [
    'exception' => $exception,
]);
```

## هدف

1. یک **استاندارد لاگینگ** در کل backend: همه‌ی نقاط مهم (یکپارچه‌سازی‌های بیرونی، catchهای بی‌صدا، گذارهای حالت مهم) با فرمت پیام غنیِ بالا لاگ بزنند.
2. لاگ‌ها علاوه بر stderr، در **دیتابیس** هم persist شوند (سطح `warning` به بالا) تا قابل‌کوئری باشند.
3. یک **صفحه‌ی ادمین** برای دیدن/فیلتر لاگ‌ها (دقیقاً مثل الگوی موجود `SmsLog` + `SmsPage`).

## فایل‌های مرتبط

| فایل | نقش |
|------|-----|
| `config/services.yaml` | بایند کردن decorator لاگر |
| `src/Shared/Logging/DbLogger.php` (جدید) | decorator روی سرویس `logger`؛ forward به stderr + persist در DB |
| `src/Shared/Logging/AppLog.php` (جدید) | Entity جدول لاگ |
| `src/Shared/Logging/AppLogRepository.php` (جدید) | کوئری لیست برای ادمین (DQL array hydration) |
| `migrations/VersionXXChangeLog.php` (جدید) | ساخت جدول `app_log` |
| `src/Admin/Controller/AdminApiController.php` | افزودن endpoint `GET /api/v1/admin/logs` |
| `assets/admin/pages/LogsPage.tsx` (جدید) | صفحه‌ی نمایش لاگ |
| `assets/admin/App.tsx` | افزودن route |
| `assets/admin/components/layout/Sidebar.tsx` | افزودن آیتم منو |
| `assets/admin/lib/api.ts` + `types/index.ts` | تابع fetch + تایپ |
| `docs/api/admin.md` | مستند endpoint جدید |
| `src/Sms/Provider/KavehNegarProvider.php`, `RanginehProvider.php`, `src/Payment/*`, `src/Shared/Service/ApiIrService.php` | نمونه نقاطی که باید لاگ اضافه شود |

> الگوی مرجع برای جدول DB + صفحه ادمین: `src/Sms/Entity/SmsLog.php` و `assets/admin/pages/SmsPage.tsx` (همین حالا وجود دارند — از همان ساختار کپی کن).

## وضعیت فعلی (نمونه catch بی‌صدا)

```php
// src/Sms/Provider/KavehNegarProvider.php  — خطا بی‌صدا خورده می‌شود
public function send(string $mobile, string $message): bool
{
    try {
        $resp = $this->httpClient->request('POST', self::BASE . '/' . $this->key() . '/sms/send.json', [...]);
        $data = $resp->toArray();
        return ($data['return']['status'] ?? 0) === 200;
    } catch (\Throwable) {
        return false;   // ← هیچ ردی نمی‌ماند
    }
}
```

## وظایف

### ۱. ساخت Entity جدول لاگ — `src/Shared/Logging/AppLog.php`

از الگوی `SmsLog` پیروی کن. ستون‌ها:

| ستون | نوع | توضیح |
|------|-----|------|
| `id` | int, auto | |
| `level` | string(16) | `error` / `warning` / `critical` / ... (PSR-3 level) |
| `message` | text | پیام غنی |
| `context` | text/json, nullable | `json_encode` شده‌ی context (بدون آبجکت exception خام؛ فقط trace کوتاه) |
| `channel` | string(32), nullable | کانال PSR (پیش‌فرض `app`) |
| `path` | string(255), nullable | مسیر request اگر در حال سرو بود |
| `createdAt` | int (unix timestamp) | **حتماً Unix timestamp صحیح، نه DateTime object** (الگوی کل پروژه) |

سپس `doctrine:migrations:diff` برای ساخت migration. (Entity تغییر کرد ⇒ migration لازم است.)

### ۲. Decorator لاگر — `src/Shared/Logging/DbLogger.php`

سرویس مینیمال `logger` را decorate کن تا همه‌ی تزریق‌های موجودِ `LoggerInterface` خودکار persist شوند. PSR-3 را پیاده کن (یا `AbstractLogger` را extend کن):

```php
namespace App\Shared\Logging;

use Psr\Log\LoggerInterface;
use Psr\Log\LogLevel;
use Doctrine\DBAL\Connection;
use Symfony\Component\HttpFoundation\RequestStack;

final class DbLogger implements LoggerInterface
{
    // سطوحی که در DB ذخیره می‌شوند (info/debug فقط stderr).
    private const PERSIST = [LogLevel::WARNING, LogLevel::ERROR, LogLevel::CRITICAL, LogLevel::ALERT, LogLevel::EMERGENCY];

    public function __construct(
        private readonly LoggerInterface $inner,   // سرویس اصلی Symfony (stderr) — decorates: logger
        private readonly Connection $conn,         // DBAL خام، مستقل از EntityManager/transaction درخواست
        private readonly RequestStack $requestStack,
    ) {}

    public function log($level, \Stringable|string $message, array $context = []): void
    {
        $this->inner->log($level, $message, $context);          // همیشه stderr (برای liara logs)

        if (!in_array((string) $level, self::PERSIST, true)) {
            return;
        }
        try {
            $this->conn->insert('app_log', [
                'level'      => (string) $level,
                'message'    => (string) $message,
                'context'    => $context ? json_encode($this->sanitize($context), JSON_UNESCAPED_UNICODE) : null,
                'channel'    => 'app',
                'path'       => $this->requestStack->getCurrentRequest()?->getPathInfo(),
                'created_at' => time(),
            ]);
        } catch (\Throwable) {
            // لاگ‌کردن هرگز نباید خود درخواست را بشکند.
        }
    }
    // emergency()/alert()/.../debug() همگی به log() فوروارد شوند.

    private function sanitize(array $ctx): array
    {
        // آبجکت exception خام را به رشته‌ی کوتاه تبدیل کن (نه کل trace حجیم).
        if (isset($ctx['exception']) && $ctx['exception'] instanceof \Throwable) {
            $e = $ctx['exception'];
            $ctx['exception'] = sprintf('%s: %s @ %s:%d', $e::class, $e->getMessage(), $e->getFile(), $e->getLine());
        }
        return $ctx;
    }
}
```

بایند در `config/services.yaml`:

```yaml
    App\Shared\Logging\DbLogger:
        decorates: 'logger'
        arguments:
            $inner: '@.inner'
            $conn: '@doctrine.dbal.default_connection'
```

**نکات مهم decorator:**
- از **DBAL خام** (`Connection::insert`) استفاده کن، نه `EntityManager`. اگر درخواست داخل transaction شکست‌خورده باشد، نوشتن با EM هم می‌شکند (همان دام savepoint که قبلاً دیدیم). یک INSERT مستقل با connection خام مطمئن‌تر است.
- اگر INSERT داخل transaction باز و rollback‌شده گیر کرد، باز هم `try/catch` جلوی شکستن درخواست را می‌گیرد. (اگر لازم شد، می‌توان از یک connection ثانویه استفاده کرد — ولی اول همین ساده را پیاده کن.)
- روی Liara fs فقط-خواندنی است؛ این طراحی به فایل وابسته نیست (DB + stderr) ⇒ سازگار.

### ۳. استانداردِ لاگ در نقاط مهم (کل backend)

فرمت پیام **همه‌جا** مطابق الگوی مرجع: `sprintf('<عنوان>: %s @ %s:%d', $e->getMessage(), $e->getFile(), $e->getLine())` + `['exception' => $e]` در context.

نقاط حداقلی که باید لاگ اضافه شود:

- **همه‌ی catchهای بی‌صدا**: `KavehNegarProvider::send()`, `RanginehProvider::send()` و هر `catch (\Throwable) { return false/null; }` دیگر:
  ```php
  } catch (\Throwable $e) {
      $this->logger->error(sprintf('SMS send failed (kavenegar): %s @ %s:%d', $e->getMessage(), $e->getFile(), $e->getLine()), ['exception' => $e, 'mobile' => $mobile]);
      return false;
  }
  ```
- **یکپارچه‌سازی‌های بیرونی**: `src/Payment/*` (درگاه ملت/سپ — نتیجه‌ی verify/callback)، `src/Shared/Service/ApiIrService.php` (Shahkar/Iban — قبلاً LoggerInterface دارد، فقط پوشش را کامل کن).
- **گذارهای حالت مهم**: تغییر وضعیت پرداخت، تغییر وضعیت نوبت (`Appointment status`), تسویه (`Settlement`). سطح `info` برای موفق، `warning`/`error` برای شکست.
- جایی که AppException دامنه‌ای throw می‌شود، **لاگ تکراری نزن** — `ExceptionSubscriber` متمرکز هندل می‌کند. فقط استثناهای غیرمنتظره/خورده‌شده را لاگ کن.

> برای پیدا کردن همه‌ی catchهای بی‌صدا:
> `grep -rn "catch (\\\\Throwable)" src/` و `grep -rn "catch (.*Exception .*e) {$" src/`

### ۴. Endpoint ادمین — `GET /api/v1/admin/logs`

در `AdminApiController` (که از `BaseController` ارث می‌برد و `#[IsGranted('ROLE_ADMIN')]` دارد). الگوی دقیقاً مثل بقیه‌ی listهای admin:

- query params: `page` (پیش‌فرض ۱)، `limit` (پیش‌فرض ۱۵)، `level` (فیلتر اختیاری)، `search` (روی message)، `from`/`to` (unix ts اختیاری).
- با `EntityManager::createQueryBuilder()` روی `AppLog` و **`->getArrayResult()`** (هرگز getter موجودیت در listهای admin).
- خروجی با `$this->paginated($items, $total, $page, $limit)`.

```php
#[OA\Get(path: '/api/v1/admin/logs', summary: 'List application logs (paginated)', security: [['bearerAuth' => []]], ...)]
#[Route('/api/v1/admin/logs', methods: ['GET'])]
public function listLogs(Request $request): JsonResponse
{
    $page  = max(1, (int) $request->query->get('page', 1));
    $limit = min(100, max(1, (int) $request->query->get('limit', 15)));
    $level = $request->query->get('level');
    // ... QueryBuilder + فیلترها + getArrayResult()
    return $this->paginated($items, $total, $page, $limit);
}
```

### ۵. صفحه‌ی ادمین — `assets/admin/pages/LogsPage.tsx`

از `SmsPage.tsx` کپی کن (همان `useQuery` + `<DataTable>` + `<Pagination>`). 

- query key: `['admin-logs', page, level, search]`.
- نوع پاسخ: `PaginatedResponse<AppLog>` — items از `data?.data`، total از `data?.meta?.totalRecords`.
- ستون‌ها: زمان (با `formatDateTime()` شمسی از `lib/utils`)، سطح (با `<StatusBadge>` رنگی: error قرمز، warning زرد)، پیام، path. context را در یک modal/expand نشان بده.
- فیلترها: dropdown سطح + input جستجو.
- تایپ `AppLog` را در `types/index.ts` اضافه کن؛ تابع fetch را در `lib/api.ts`.
- route در `App.tsx`: `<Route path="logs" element={<RoleRoute roles={['admin']}><LogsPage /></RoleRoute>} />` و آیتم منو در `Sidebar.tsx` (فقط admin).

### ۶. مستندات

`docs/api/admin.md` را با endpoint جدید `GET /api/v1/admin/logs` (params، شکل پاسخ paginated، سطوح) به‌روز کن. (قانون standing پروژه: تغییر API ⇒ به‌روزرسانی `docs/api/` در همان session.)

## نکات مهم

- **createdAt حتماً Unix timestamp (int)** — نه DateTime؛ الگوی کل entityهای پروژه.
- **listهای admin فقط `getArrayResult()`** — استفاده از getter موجودیت در listها خطا می‌دهد.
- پاسخ paginated در فرانت: items از `data?.data`، total از `data?.meta?.totalRecords` (دو-سطحی نیست).
- JWT از `localStorage['clinicpro-auth']` خوانده می‌شود (خودکار در `api.ts`).
- لاگینگ نباید درخواست را کند یا بشکند: فقط `warning+` در DB، INSERT خام، همه‌چیز در `try/catch`.
- **حلقه‌ی بازخورد**: چون DbLogger روی هر لاگ به DB می‌نویسد، مراقب باش خطای خودِ DB لاگ بی‌نهایت نسازد — `catch` داخل DbLogger این را می‌گیرد (لاگِ خطای persist را دوباره persist نکن، فقط stderr).
- حجم جدول: برای آینده یک دستور پاکسازی (`messenger`/cron یا یک command ساده برای حذف لاگ‌های قدیمی‌تر از N روز) در نظر بگیر — در این پرامپت اختیاری، فقط در docs ذکر کن.
- بعد از تغییرات backend مرتبط با API، طبق hook پروژه، تست‌ها و `docs/api/*.md` همان session به‌روز شوند.
- تست: یک تست سبک برای `DbLogger` (که `warning+` را insert می‌کند و `info` را نه) و یک تست endpoint `listLogs`.
```
