feat(logging): Implement database logging with app_log table
- Created migration to set up app_log table for storing application logs. - Added AppLog entity and repository for ORM handling of logs. - Developed DbLogger service to persist logs of level WARNING and above to the database while maintaining existing logging behavior. - Implemented tests for admin log retrieval and DbLogger functionality to ensure proper logging behavior. - Enhanced logging context sanitization for better error tracking.
This commit is contained in:
@@ -0,0 +1,229 @@
|
||||
# لاگینگ سراسری پروژه + نمایش لاگها در پنل ادمین
|
||||
|
||||
## پروژه
|
||||
|
||||
`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`.
|
||||
```
|
||||
@@ -23,6 +23,7 @@ import RepresentationDetailPage from './pages/RepresentationDetailPage';
|
||||
import CommentsPage from './pages/CommentsPage';
|
||||
import RatingsPage from './pages/RatingsPage';
|
||||
import SmsPage from './pages/SmsPage';
|
||||
import LogsPage from './pages/LogsPage';
|
||||
import CategoriesPage from './pages/CategoriesPage';
|
||||
import BlogsPage from './pages/BlogsPage';
|
||||
import BlogFormPage from './pages/BlogFormPage';
|
||||
@@ -156,6 +157,7 @@ export default function App() {
|
||||
<Route path="comments" element={<RoleRoute roles={['admin']}><CommentsPage /></RoleRoute>} />
|
||||
<Route path="ratings" element={<RoleRoute roles={['admin']}><RatingsPage /></RoleRoute>} />
|
||||
<Route path="sms" element={<RoleRoute roles={['admin']}><SmsPage /></RoleRoute>} />
|
||||
<Route path="logs" element={<RoleRoute roles={['admin']}><LogsPage /></RoleRoute>} />
|
||||
<Route path="categories" element={<RoleRoute roles={['admin']}><CategoriesPage /></RoleRoute>} />
|
||||
<Route path="blogs" element={<RoleRoute roles={['admin']}><BlogsPage /></RoleRoute>} />
|
||||
<Route path="blogs/new" element={<RoleRoute roles={['admin']}><BlogFormPage /></RoleRoute>} />
|
||||
|
||||
@@ -134,6 +134,11 @@ function buildSections(
|
||||
{
|
||||
label: "سیستم",
|
||||
items: [
|
||||
{
|
||||
to: "/admin/logs",
|
||||
icon: ClipboardDocumentCheckIcon,
|
||||
label: "لاگها",
|
||||
},
|
||||
{
|
||||
to: "/admin/categories",
|
||||
icon: TagIcon,
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { api } from '../lib/api';
|
||||
import type { PaginatedResponse } from '../lib/api';
|
||||
import type { AppLog } from '../types';
|
||||
import { formatDateTime } from '../lib/utils';
|
||||
import DataTable, { Column } from '../components/ui/DataTable';
|
||||
import Pagination from '../components/ui/Pagination';
|
||||
import Modal from '../components/ui/Modal';
|
||||
import SearchableSelect from '../components/ui/SearchableSelect';
|
||||
|
||||
const LEVEL_META: Record<string, { label: string; cls: string }> = {
|
||||
emergency: { label: 'اضطراری', cls: 'red' },
|
||||
alert: { label: 'هشدار جدی', cls: 'red' },
|
||||
critical: { label: 'بحرانی', cls: 'red' },
|
||||
error: { label: 'خطا', cls: 'red' },
|
||||
warning: { label: 'اخطار', cls: 'amber' },
|
||||
notice: { label: 'اطلاع', cls: 'gray' },
|
||||
info: { label: 'اطلاعات', cls: 'gray' },
|
||||
debug: { label: 'دیباگ', cls: 'gray' },
|
||||
};
|
||||
|
||||
const LEVEL_FILTER_OPTIONS = [
|
||||
{ value: '', label: 'همه سطوح' },
|
||||
{ value: 'error', label: 'خطا' },
|
||||
{ value: 'warning', label: 'اخطار' },
|
||||
{ value: 'critical', label: 'بحرانی' },
|
||||
];
|
||||
|
||||
export default function LogsPage() {
|
||||
const [page, setPage] = useState(1);
|
||||
const [level, setLevel] = useState('');
|
||||
const [search, setSearch] = useState('');
|
||||
const [viewLog, setViewLog] = useState<AppLog | null>(null);
|
||||
const limit = 25;
|
||||
|
||||
const logsQuery = useQuery({
|
||||
queryKey: ['admin-logs', page, level, search],
|
||||
queryFn: () =>
|
||||
api.get<PaginatedResponse<AppLog>>(
|
||||
`/api/v1/admin/logs?page=${page}&limit=${limit}` +
|
||||
(level ? `&level=${level}` : '') +
|
||||
(search ? `&search=${encodeURIComponent(search)}` : ''),
|
||||
),
|
||||
});
|
||||
|
||||
const columns: Column<AppLog>[] = [
|
||||
{ key: 'created_at', header: 'زمان', render: (l) => formatDateTime(l.created_at) },
|
||||
{
|
||||
key: 'level',
|
||||
header: 'سطح',
|
||||
render: (l) => {
|
||||
const meta = LEVEL_META[l.level] ?? { label: l.level, cls: 'gray' };
|
||||
return <span className={`badge ${meta.cls}`}><span className="bdot" />{meta.label}</span>;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'message',
|
||||
header: 'پیام',
|
||||
render: (l) => (
|
||||
<button
|
||||
type="button"
|
||||
title="نمایش جزئیات"
|
||||
onClick={() => setViewLog(l)}
|
||||
style={{ fontSize: 12.5, display: 'block', maxWidth: 420, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', textAlign: 'right', background: 'none', border: 'none', padding: 0, cursor: 'pointer', color: 'var(--primary)', width: '100%' }}
|
||||
>
|
||||
{l.message}
|
||||
</button>
|
||||
),
|
||||
},
|
||||
{ key: 'path', header: 'مسیر', render: (l) => <span dir="ltr" className="muted" style={{ fontSize: 12 }}>{l.path ?? '—'}</span> },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="fade-in">
|
||||
<div className="card-title-row" style={{ marginBottom: 'var(--gap)' }}>
|
||||
<div>
|
||||
<h1 className="section-title">لاگها</h1>
|
||||
<div className="muted">رویدادهای ثبتشدهی سیستم (اخطار و بالاتر)</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="card-pad">
|
||||
<div style={{ marginBottom: 12, display: 'flex', gap: 12, justifyContent: 'flex-end', flexWrap: 'wrap' }}>
|
||||
<input
|
||||
className="input"
|
||||
style={{ maxWidth: 260 }}
|
||||
placeholder="جستجو در متن..."
|
||||
value={search}
|
||||
onChange={(e) => { setSearch(e.target.value); setPage(1); }}
|
||||
/>
|
||||
<div style={{ width: 200 }}>
|
||||
<SearchableSelect
|
||||
options={LEVEL_FILTER_OPTIONS}
|
||||
value={level}
|
||||
onChange={(v) => { setLevel(v ? String(v) : ''); setPage(1); }}
|
||||
placeholder="همه سطوح"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DataTable<AppLog>
|
||||
columns={columns}
|
||||
data={logsQuery.data?.data ?? []}
|
||||
loading={logsQuery.isLoading}
|
||||
emptyMessage="لاگی یافت نشد"
|
||||
/>
|
||||
<Pagination
|
||||
page={page}
|
||||
total={logsQuery.data?.meta?.totalRecords ?? 0}
|
||||
limit={limit}
|
||||
onPageChange={setPage}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Modal open={!!viewLog} title="جزئیات لاگ" onClose={() => setViewLog(null)}>
|
||||
{viewLog && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12, minWidth: 360, maxWidth: 640 }}>
|
||||
<div style={{ display: 'flex', gap: 16, flexWrap: 'wrap', fontSize: 12.5 }}>
|
||||
<div><span className="muted">سطح: </span>{LEVEL_META[viewLog.level]?.label ?? viewLog.level}</div>
|
||||
<div><span className="muted">زمان: </span>{formatDateTime(viewLog.created_at)}</div>
|
||||
{viewLog.path && <div><span className="muted">مسیر: </span><span dir="ltr">{viewLog.path}</span></div>}
|
||||
</div>
|
||||
<div style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word', lineHeight: 1.9, fontSize: 13.5, padding: '12px 14px', borderRadius: 'var(--r-sm)', background: 'var(--surface-3)', border: '1px solid var(--border)' }} dir="ltr">
|
||||
{viewLog.message}
|
||||
</div>
|
||||
{viewLog.context && (
|
||||
<div>
|
||||
<div className="muted" style={{ fontSize: 12, marginBottom: 4 }}>context</div>
|
||||
<pre style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word', fontSize: 12, padding: '10px 12px', borderRadius: 'var(--r-sm)', background: 'var(--surface)', border: '1px solid var(--border)', margin: 0 }} dir="ltr">
|
||||
{viewLog.context}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -200,6 +200,16 @@ export interface SmsLog {
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface AppLog {
|
||||
id: number;
|
||||
level: string;
|
||||
message: string;
|
||||
context: string | null;
|
||||
channel: string | null;
|
||||
path: string | null;
|
||||
created_at: number;
|
||||
}
|
||||
|
||||
export interface SmsMessageText {
|
||||
tag: string;
|
||||
title: string;
|
||||
|
||||
@@ -41,6 +41,13 @@ services:
|
||||
$baseUrl: '%env(default:default_api_ir_base_url:API_IR_BASE_URL)%'
|
||||
$token: '%env(default::API_IR_TOKEN)%'
|
||||
|
||||
# Persist warning+ logs to the app_log table while keeping stderr output.
|
||||
App\Shared\Logging\DbLogger:
|
||||
decorates: 'logger'
|
||||
arguments:
|
||||
$inner: '@.inner'
|
||||
$conn: '@doctrine.dbal.default_connection'
|
||||
|
||||
App\Auth\Service\OtpService:
|
||||
arguments:
|
||||
$otpTtl: '%env(int:OTP_TTL)%'
|
||||
|
||||
@@ -1078,3 +1078,53 @@ Reject a pending request. **Permission:** `ROLE_ADMIN`
|
||||
تأیید/رد از طریق `POST /api/v1/settlement/{uuid}/approve|reject` (در `docs/api/settlement.md`).
|
||||
|
||||
**Errors:** `NOT_FOUND` (404) — درخواست یافت نشد.
|
||||
|
||||
---
|
||||
|
||||
## Application Logs
|
||||
|
||||
Persisted application logs (`warning` level and above). Written by the `DbLogger`
|
||||
decorator over the `logger` service into the `app_log` table — every
|
||||
`LoggerInterface::warning()/error()/critical()/...` call across the backend lands
|
||||
here, while `info`/`debug` go to stderr only.
|
||||
|
||||
### GET `/api/v1/admin/logs`
|
||||
|
||||
List persisted logs with pagination and filters.
|
||||
|
||||
**Permission:** `ROLE_ADMIN`
|
||||
|
||||
### Query Parameters
|
||||
| Param | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `page` | integer | ❌ | Default: 1 |
|
||||
| `limit` | integer | ❌ | Default: 25 (max 100) |
|
||||
| `level` | string | ❌ | Exact PSR level: `warning`, `error`, `critical`, `alert`, `emergency` |
|
||||
| `search` | string | ❌ | Substring match on the message |
|
||||
| `from` | integer | ❌ | Unix timestamp lower bound (`created_at >=`) |
|
||||
| `to` | integer | ❌ | Unix timestamp upper bound (`created_at <=`) |
|
||||
|
||||
Ordered by newest first (`id DESC`).
|
||||
|
||||
### Response `200`
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": [
|
||||
{
|
||||
"id": 4213,
|
||||
"level": "error",
|
||||
"message": "Unhandled exception: RuntimeException: boom @ /var/www/html/src/Foo.php:42 [path=/oauth/userinfo]",
|
||||
"context": "{\"exception\":\"RuntimeException: boom @ /var/www/html/src/Foo.php:42\"}",
|
||||
"channel": "app",
|
||||
"path": "/oauth/userinfo",
|
||||
"created_at": 1717000000
|
||||
}
|
||||
],
|
||||
"meta": { "totalRecords": 137, "totalPages": 6, "currentPage": 1 }
|
||||
}
|
||||
```
|
||||
|
||||
Notes:
|
||||
- `context` is a JSON string (or `null`); a `Throwable` in the context is stored as a compact `Class: message @ file:line` string, never the raw object.
|
||||
- `created_at` is a Unix timestamp (integer).
|
||||
|
||||
@@ -670,5 +670,10 @@
|
||||
"668": "Community 668",
|
||||
"669": "Community 669",
|
||||
"670": "Community 670",
|
||||
"671": "Community 671"
|
||||
"671": "Community 671",
|
||||
"672": "Community 672",
|
||||
"673": "Community 673",
|
||||
"674": "Community 674",
|
||||
"675": "Community 675",
|
||||
"676": "Community 676"
|
||||
}
|
||||
|
||||
+102
-105
@@ -1,16 +1,16 @@
|
||||
# Graph Report - clinicpro (2026-06-29)
|
||||
|
||||
## Corpus Check
|
||||
- 646 files · ~446,941 words
|
||||
- 654 files · ~450,645 words
|
||||
- Verdict: corpus is large enough that graph structure adds value.
|
||||
|
||||
## Summary
|
||||
- 8212 nodes · 11323 edges · 672 communities (551 shown, 121 thin omitted)
|
||||
- Extraction: 98% EXTRACTED · 2% INFERRED · 0% AMBIGUOUS · INFERRED: 261 edges (avg confidence: 0.8)
|
||||
- 8274 nodes · 11437 edges · 677 communities (549 shown, 128 thin omitted)
|
||||
- Extraction: 98% EXTRACTED · 2% INFERRED · 0% AMBIGUOUS · INFERRED: 264 edges (avg confidence: 0.8)
|
||||
- Token cost: 0 input · 0 output
|
||||
|
||||
## Graph Freshness
|
||||
- Built from commit: `afb5b282`
|
||||
- Built from commit: `830f7e8d`
|
||||
- Run `git rev-parse HEAD` and compare to check if the graph is stale.
|
||||
- Run `graphify update .` after code changes (no API cost).
|
||||
|
||||
@@ -672,11 +672,16 @@
|
||||
- [[_COMMUNITY_Community 668|Community 668]]
|
||||
- [[_COMMUNITY_Community 669|Community 669]]
|
||||
- [[_COMMUNITY_Community 671|Community 671]]
|
||||
- [[_COMMUNITY_Community 672|Community 672]]
|
||||
- [[_COMMUNITY_Community 673|Community 673]]
|
||||
- [[_COMMUNITY_Community 674|Community 674]]
|
||||
- [[_COMMUNITY_Community 675|Community 675]]
|
||||
- [[_COMMUNITY_Community 676|Community 676]]
|
||||
|
||||
## God Nodes (most connected - your core abstractions)
|
||||
1. `BaseController` - 74 edges
|
||||
2. `ApiTestCase` - 68 edges
|
||||
3. `api` - 54 edges
|
||||
2. `ApiTestCase` - 70 edges
|
||||
3. `api` - 55 edges
|
||||
4. `UserProfile` - 52 edges
|
||||
5. `Clinic` - 50 edges
|
||||
6. `Doctor` - 48 edges
|
||||
@@ -690,53 +695,53 @@
|
||||
assets/admin/components/ServiceTariffModal.tsx → assets/admin/lib/utils.ts
|
||||
- `Pagination()` --calls--> `formatNumber()` [EXTRACTED]
|
||||
assets/admin/components/ui/Pagination.tsx → assets/admin/lib/utils.ts
|
||||
- `PersianDatePicker()` --calls--> `formatDate()` [EXTRACTED]
|
||||
assets/admin/components/ui/PersianDatePicker.tsx → assets/admin/lib/utils.ts
|
||||
- `SettlementsPage()` --calls--> `formatRial()` [EXTRACTED]
|
||||
assets/admin/pages/SettlementsPage.tsx → assets/admin/lib/utils.ts
|
||||
- `LogsPage()` --calls--> `formatDateTime()` [EXTRACTED]
|
||||
assets/admin/pages/LogsPage.tsx → assets/admin/lib/utils.ts
|
||||
- `SmsPage()` --calls--> `formatDateTime()` [EXTRACTED]
|
||||
assets/admin/pages/SmsPage.tsx → assets/admin/lib/utils.ts
|
||||
|
||||
## Import Cycles
|
||||
- None detected.
|
||||
|
||||
## Communities (672 total, 121 thin omitted)
|
||||
## Communities (677 total, 128 thin omitted)
|
||||
|
||||
### Community 0 - "Community 0"
|
||||
Cohesion: 0.05
|
||||
Nodes (40): ALLOWED_ROLES, PrivateRoute(), PublicRoute(), RoleRoute(), queryClient, toastStyle, AddForm, addSchema (+32 more)
|
||||
Nodes (43): ALLOWED_ROLES, PrivateRoute(), PublicRoute(), RoleRoute(), queryClient, toastStyle, AddForm, addSchema (+35 more)
|
||||
|
||||
### Community 1 - "Community 1"
|
||||
Cohesion: 0.03
|
||||
Nodes (41): AddressData, AddrForm, addrSchema, AVATAR_COLORS, BookingMeta, CityOpt, DateOverrideData, DEFAULT_BOOKING_META (+33 more)
|
||||
|
||||
### Community 2 - "Community 2"
|
||||
Cohesion: 0.09
|
||||
Nodes (19): get, api, ApiError, getToken(), refreshOnce(), request(), { refreshMock, logoutMock }, replaceMock (+11 more)
|
||||
Cohesion: 0.06
|
||||
Nodes (29): Contract, InsuranceOption, KIND_LABEL, get, api, ApiError, getToken(), refreshOnce() (+21 more)
|
||||
|
||||
### Community 3 - "Community 3"
|
||||
Cohesion: 0.05
|
||||
Nodes (42): PaginatedResponse, formatDate(), STATUS_FILTERS, FILTERS, Breakdown, SOURCE_LABEL, Summary, STATUS_FILTERS (+34 more)
|
||||
Nodes (42): ACTION_HEADERS, ALL_ACTIONS, ClinicDoctor, CreateForm, createSchema, DEFAULT_PERMISSIONS, PERMISSION_LABELS, PermSection (+34 more)
|
||||
|
||||
### Community 4 - "Community 4"
|
||||
Cohesion: 0.05
|
||||
Nodes (11): DoctorServiceController, DoctorService, Doctor, DoctorServiceRepository, Collection, self, User, WeeklySchedule (+3 more)
|
||||
Cohesion: 0.08
|
||||
Nodes (5): Doctor, Collection, self, User, WeeklySchedule
|
||||
|
||||
### Community 5 - "Community 5"
|
||||
Cohesion: 0.07
|
||||
Nodes (3): UserProfile, self, User
|
||||
|
||||
### Community 6 - "Community 6"
|
||||
Cohesion: 0.17
|
||||
Nodes (8): SettlementController, SettlementRepository, Settlement, JsonResponse, Request, User, ManagerRegistry, User
|
||||
Cohesion: 0.12
|
||||
Nodes (12): SettlementController, SettlementRepository, WalletTransactionRepository, Settlement, JsonResponse, Request, User, ManagerRegistry (+4 more)
|
||||
|
||||
### Community 7 - "Community 7"
|
||||
Cohesion: 0.07
|
||||
Nodes (5): Clinic, Collection, Doctor, self, User
|
||||
|
||||
### Community 8 - "Community 8"
|
||||
Cohesion: 0.08
|
||||
Nodes (27): useSubscription(), AdminLayout(), avatarBg(), buildSections(), HUES, Props, ROLE_LABELS, Section (+19 more)
|
||||
Cohesion: 0.07
|
||||
Nodes (28): useSubscription(), AdminLayout(), avatarBg(), buildSections(), HUES, Props, ROLE_LABELS, Section (+20 more)
|
||||
|
||||
### Community 9 - "Community 9"
|
||||
Cohesion: 0.04
|
||||
@@ -747,28 +752,28 @@ Cohesion: 0.04
|
||||
Nodes (46): Clinic Address Management, Clinic API, DELETE `/api/v1/admin/clinic/{clinicUuid}/doctor/{doctorUuid}`, `DELETE /api/v1/clinic/{clinicUuid}/address/{addressUuid}`, Errors, Errors, Errors, Errors (+38 more)
|
||||
|
||||
### Community 11 - "Community 11"
|
||||
Cohesion: 0.25
|
||||
Nodes (6): IbanItem, RepProfile, RepresentationProfilePage(), unwrap(), PersianDatePicker(), Props
|
||||
Cohesion: 0.08
|
||||
Nodes (21): PaginatedResponse, STATUS_FILTERS, FILTERS, Breakdown, SOURCE_LABEL, Summary, LEVEL_FILTER_OPTIONS, LEVEL_META (+13 more)
|
||||
|
||||
### Community 12 - "Community 12"
|
||||
Cohesion: 0.05
|
||||
Nodes (44): Clinic Doctor Invitation API, DELETE `/api/v1/admin/clinic/invitation/{invUuid}`, Errors, Errors, Errors, Errors, Errors, Errors (+36 more)
|
||||
|
||||
### Community 13 - "Community 13"
|
||||
Cohesion: 0.08
|
||||
Nodes (29): ApiResponse, cn(), formatDateTime(), iranMobileOptionalSchema, iranMobileSchema, isValidIranMobile(), maskMobile(), sanitizeMobileInput() (+21 more)
|
||||
Cohesion: 0.06
|
||||
Nodes (37): cn(), formatDate(), formatDateTime(), iranMobileOptionalSchema, iranMobileSchema, isValidIranMobile(), maskMobile(), sanitizeMobileInput() (+29 more)
|
||||
|
||||
### Community 14 - "Community 14"
|
||||
Cohesion: 0.15
|
||||
Nodes (8): AppointmentSettingsController, Holiday, HolidayRepository, JsonResponse, Request, User, Doctor, ManagerRegistry
|
||||
Cohesion: 0.10
|
||||
Nodes (12): AppointmentSettingsController, DateOverride, Holiday, DateOverrideRepository, HolidayRepository, JsonResponse, Request, User (+4 more)
|
||||
|
||||
### Community 15 - "Community 15"
|
||||
Cohesion: 0.10
|
||||
Nodes (13): PaymentController, MockGateway, SepGateway, JsonResponse, Payment, PaymentGatewayInterface, Request, Response (+5 more)
|
||||
|
||||
### Community 16 - "Community 16"
|
||||
Cohesion: 0.09
|
||||
Nodes (6): PatientSession, Appointment, Collection, PatientRecord, self, SessionService
|
||||
Cohesion: 0.07
|
||||
Nodes (7): PatientSession, SmsWallet, Appointment, Collection, PatientRecord, self, SessionService
|
||||
|
||||
### Community 17 - "Community 17"
|
||||
Cohesion: 0.05
|
||||
@@ -779,20 +784,20 @@ Cohesion: 0.05
|
||||
Nodes (38): API, API, API, API, API, Route, Route, Route (+30 more)
|
||||
|
||||
### Community 19 - "Community 19"
|
||||
Cohesion: 0.07
|
||||
Nodes (21): Contract, InsuranceOption, KIND_LABEL, Claim, ClaimItem, DebtRow, InsuranceOption, KIND_LABEL (+13 more)
|
||||
Cohesion: 0.20
|
||||
Nodes (8): AdminUser, ChangeRoleModal(), getPrimaryRole(), HUES_LIST, ROLE_META, ROLE_TABS, RoleBadge(), UserStats
|
||||
|
||||
### Community 20 - "Community 20"
|
||||
Cohesion: 0.15
|
||||
Nodes (3): AdminApiController, JsonResponse, Request
|
||||
|
||||
### Community 21 - "Community 21"
|
||||
Cohesion: 0.05
|
||||
Nodes (34): formatNumber(), ClinicDetailPage(), AdminCharts, AdminDashboard(), AdminRecent, AdminStats, APPT_CLS, APPT_COLOR (+26 more)
|
||||
Cohesion: 0.04
|
||||
Nodes (49): FreeVisitPrice(), Pricing, formatNumber(), formatRial(), ClinicDetailPage(), AdminCharts, AdminDashboard(), AdminRecent (+41 more)
|
||||
|
||||
### Community 22 - "Community 22"
|
||||
Cohesion: 0.12
|
||||
Nodes (10): RatingController, Like, CommentListNPlusOneTest, LikeRepository, JsonResponse, Request, User, Comment (+2 more)
|
||||
Cohesion: 0.11
|
||||
Nodes (14): RatingController, Like, Rate, LikeRepository, RateRepository, JsonResponse, Request, User (+6 more)
|
||||
|
||||
### Community 23 - "Community 23"
|
||||
Cohesion: 0.05
|
||||
@@ -843,8 +848,8 @@ Cohesion: 0.06
|
||||
Nodes (34): require, doctrine/doctrine-bundle, doctrine/doctrine-migrations-bundle, doctrine/orm, ext-ctype, ext-iconv, lexik/jwt-authentication-bundle, nelmio/api-doc-bundle (+26 more)
|
||||
|
||||
### Community 35 - "Community 35"
|
||||
Cohesion: 0.06
|
||||
Nodes (32): Errors, Errors, Errors, Errors, Errors, Errors, Errors, FinancialBreakdown (لاگ مالی) (+24 more)
|
||||
Cohesion: 0.07
|
||||
Nodes (27): Errors, Errors, Errors, Errors, Errors, Errors, FinancialBreakdown (لاگ مالی), GET `/api/v1/settlement` (+19 more)
|
||||
|
||||
### Community 36 - "Community 36"
|
||||
Cohesion: 0.06
|
||||
@@ -867,8 +872,8 @@ Cohesion: 0.09
|
||||
Nodes (4): User, PasswordAuthenticatedUserInterface, self, UserInterface
|
||||
|
||||
### Community 41 - "Community 41"
|
||||
Cohesion: 0.04
|
||||
Nodes (52): ALL_STATUSES, AppointmentDetailPage(), CityForm, citySchema, InsuranceForm, insuranceSchema, LogoUploadField(), ProvinceForm (+44 more)
|
||||
Cohesion: 0.03
|
||||
Nodes (56): usePaymentConfig(), CityForm, citySchema, InsuranceForm, insuranceSchema, LogoUploadField(), ProvinceForm, provinceSchema (+48 more)
|
||||
|
||||
### Community 42 - "Community 42"
|
||||
Cohesion: 0.07
|
||||
@@ -915,8 +920,8 @@ Cohesion: 0.07
|
||||
Nodes (26): الزامات UI, باگفیکس صفحه نوبتها, باگ ۱ — کرش تقویم, باگ ۲ — روز هفته در DateNavigator, باگ ۳ — پیام «slot نیست», باگ ۴ — نوبت جدید: نام اجباری + find-or-create patient, باگ ۵ — patient_mobile نشان میدهد موبایل پزشک, باگ ۶ — نوبتهای رزرو شده در نمایش زمانبندی (+18 more)
|
||||
|
||||
### Community 53 - "Community 53"
|
||||
Cohesion: 0.10
|
||||
Nodes (13): ClaimItemRepository, PreRegistrationRepository, SessionServiceRepository, SiteConfigRepository, SmsSettingsRepository, ServiceEntityRepository, SmsSettings, ManagerRegistry (+5 more)
|
||||
Cohesion: 0.11
|
||||
Nodes (13): AppLogRepository, ClaimItemRepository, DoctorInsuranceRepository, PreRegistrationRepository, SmsSettingsRepository, ServiceEntityRepository, SmsSettings, ManagerRegistry (+5 more)
|
||||
|
||||
### Community 54 - "Community 54"
|
||||
Cohesion: 0.10
|
||||
@@ -966,10 +971,6 @@ Nodes (4): SmsWalletController, JsonResponse, Request, User
|
||||
Cohesion: 0.08
|
||||
Nodes (24): 2. کاربر (User), 4. 🔵 `POST` verify code, 5. 🔵 `POST` send code, 6. 🔵 `POST` register, 7. 🔴 `DELETE` delete user, 8. 🟡 `PATCH` patch, 9. 🟢 `GET` list secretary, Request Body (+16 more)
|
||||
|
||||
### Community 66 - "Community 66"
|
||||
Cohesion: 0.07
|
||||
Nodes (4): ClinicStaff, SmsWallet, AppointmentExpiryService, self
|
||||
|
||||
### Community 67 - "Community 67"
|
||||
Cohesion: 0.11
|
||||
Nodes (4): Invoice, Collection, InvoiceItem, self
|
||||
@@ -1019,8 +1020,8 @@ Cohesion: 0.09
|
||||
Nodes (22): dependencies, @ckeditor/ckeditor5-build-classic, @ckeditor/ckeditor5-react, @fontsource/vazirmatn, @heroicons/react, @hookform/resolvers, jalaali-js, leaflet (+14 more)
|
||||
|
||||
### Community 82 - "Community 82"
|
||||
Cohesion: 0.12
|
||||
Nodes (5): KavehNegarProvider, RanginehProvider, SmsService, SendSmsMessage, SmsProviderInterface
|
||||
Cohesion: 0.06
|
||||
Nodes (12): RepositoryClassMappingTest, KernelTestCase, LoggerInterface, DbLogger, KavehNegarProvider, RanginehProvider, ApiIrService, SmsService (+4 more)
|
||||
|
||||
### Community 83 - "Community 83"
|
||||
Cohesion: 0.09
|
||||
@@ -1035,8 +1036,8 @@ Cohesion: 0.07
|
||||
Nodes (29): devDependencies, @babel/core, @babel/preset-env, @babel/preset-react, @babel/preset-typescript, core-js, @hotwired/stimulus, jsdom (+21 more)
|
||||
|
||||
### Community 86 - "Community 86"
|
||||
Cohesion: 0.07
|
||||
Nodes (13): AppointmentExpiryServiceTest, LowTierFixesTest, SendCodeMobileRateLimitTest, ClaimsListPaginationTest, ServiceItemStaffOwnershipTest, EntityManagerInterface, KernelBrowser, CommentPaginationTest (+5 more)
|
||||
Cohesion: 0.06
|
||||
Nodes (15): AppointmentExpiryServiceTest, SendCodeMobileRateLimitTest, ClaimsListPaginationTest, ServiceItemDeleteCleanupTest, ServiceItemStaffOwnershipTest, EntityManagerInterface, TenantInsuranceCleanupTest, KernelBrowser (+7 more)
|
||||
|
||||
### Community 87 - "Community 87"
|
||||
Cohesion: 0.10
|
||||
@@ -1107,8 +1108,8 @@ Cohesion: 0.35
|
||||
Nodes (5): PatientController, JsonResponse, PatientSession, Request, User
|
||||
|
||||
### Community 104 - "Community 104"
|
||||
Cohesion: 0.35
|
||||
Nodes (3): TagController, JsonResponse, Request
|
||||
Cohesion: 0.18
|
||||
Nodes (6): TagController, TagRepository, JsonResponse, Request, ManagerRegistry, Tag
|
||||
|
||||
### Community 105 - "Community 105"
|
||||
Cohesion: 0.11
|
||||
@@ -1331,8 +1332,8 @@ Cohesion: 0.13
|
||||
Nodes (13): Architecture, Auth, Backend (PHP/Symfony), Backend — `src/`, Category / Bundle system, Commands, Database, First-time setup (+5 more)
|
||||
|
||||
### Community 163 - "Community 163"
|
||||
Cohesion: 0.11
|
||||
Nodes (18): autoload, autoload-dev, psr-4, psr-4, conflict, symfony/symfony, description, extra (+10 more)
|
||||
Cohesion: 0.13
|
||||
Nodes (14): autoload, autoload-dev, psr-4, psr-4, conflict, symfony/symfony, description, license (+6 more)
|
||||
|
||||
### Community 164 - "Community 164"
|
||||
Cohesion: 0.29
|
||||
@@ -1403,8 +1404,8 @@ Cohesion: 0.23
|
||||
Nodes (3): WeeklySchedule, Doctor, self
|
||||
|
||||
### Community 182 - "Community 182"
|
||||
Cohesion: 0.04
|
||||
Nodes (45): FreeVisitPrice(), Pricing, usePaymentConfig(), formatRial(), emptyFeatures(), FEATURE_KEYS, FEATURE_LABELS, PeriodForm (+37 more)
|
||||
Cohesion: 0.15
|
||||
Nodes (11): emptyFeatures(), FEATURE_KEYS, FEATURE_LABELS, PeriodForm, periodSchema, PLAN_DISPLAY, PlanForm, planSchema (+3 more)
|
||||
|
||||
### Community 183 - "Community 183"
|
||||
Cohesion: 0.14
|
||||
@@ -1476,7 +1477,7 @@ Nodes (13): Endpoint ها, PATCH /api/v1/secretary/{uuid}, POST /api/v1/secretar
|
||||
|
||||
### Community 201 - "Community 201"
|
||||
Cohesion: 0.15
|
||||
Nodes (13): Admin API, Clinic Invitation Management, GET `/api/v1/admin/payments`, GET /api/v1/admin/settings, GET `/api/v1/admin/settlements`, PATCH /api/v1/admin/settings, Payment Management, Query Parameters (+5 more)
|
||||
Nodes (13): Admin API, Clinic Invitation Management, GET `/api/v1/admin/representations`, GET `/api/v1/admin/secretaries`, GET /api/v1/admin/settings, PATCH /api/v1/admin/settings, Query Parameters, Query Parameters (+5 more)
|
||||
|
||||
### Community 202 - "Community 202"
|
||||
Cohesion: 0.15
|
||||
@@ -1491,8 +1492,8 @@ Cohesion: 0.21
|
||||
Nodes (6): AuthenticationException, ExceptionSubscriber, SecurityHeadersSubscriber, EventSubscriberInterface, ExceptionEvent, ResponseEvent
|
||||
|
||||
### Community 205 - "Community 205"
|
||||
Cohesion: 0.16
|
||||
Nodes (10): Command, CancelExpiredAppointmentsCommand, CreateAdminCommand, SeedSmsMessageTemplatesCommand, InputInterface, OutputInterface, InputInterface, OutputInterface (+2 more)
|
||||
Cohesion: 0.10
|
||||
Nodes (14): Command, CancelExpiredAppointmentsCommand, CreateAdminCommand, SeedSmsMessageTemplatesCommand, SmsMessageTemplateRepository, SmsTextResolver, SmsMessageTemplate, InputInterface (+6 more)
|
||||
|
||||
### Community 206 - "Community 206"
|
||||
Cohesion: 0.35
|
||||
@@ -1507,7 +1508,7 @@ Cohesion: 0.23
|
||||
Nodes (4): Like, Comment, self, User
|
||||
|
||||
### Community 213 - "Community 213"
|
||||
Cohesion: 0.24
|
||||
Cohesion: 0.27
|
||||
Nodes (3): MellatGateway, PaymentInitResult, PaymentVerifyResult
|
||||
|
||||
### Community 214 - "Community 214"
|
||||
@@ -1592,7 +1593,7 @@ Nodes (3): SmsWalletTransaction, Payment, SmsWallet
|
||||
|
||||
### Community 235 - "Community 235"
|
||||
Cohesion: 0.13
|
||||
Nodes (12): AdminUserDetail, AVATAR_COLORS, EditForm, editSchema, GENDER_LABELS, getPrimaryRole(), MARITAL_LABELS, MEDICAL_SECTIONS (+4 more)
|
||||
Nodes (14): زمینه, فایلهای مرتبط, لاگینگ سراسری پروژه + نمایش لاگها در پنل ادمین, نکات مهم, هدف, وضعیت فعلی (نمونه catch بیصدا), وظایف, پروژه (+6 more)
|
||||
|
||||
### Community 236 - "Community 236"
|
||||
Cohesion: 0.17
|
||||
@@ -1856,7 +1857,7 @@ Nodes (16): آمادهسازی پروژه ClinicPro برای دیپلوی ر
|
||||
|
||||
### Community 304 - "Community 304"
|
||||
Cohesion: 0.22
|
||||
Nodes (9): 29. 🟢 `GET` clinic list 🆕, 30. 🟢 `GET` get 🆕, 33. 🔵 `POST` image_clinic, 6. کلینیک (Clinic), هدرهای اضافی, پارامترهای Query, پاسخها, پاسخها (+1 more)
|
||||
Nodes (9): 30. 🟢 `GET` get 🆕, 33. 🔵 `POST` image_clinic, 36. 🟢 `GET` get my rate, 6. کلینیک (Clinic), هدرهای اضافی, هدرهای اضافی, پاسخها, پاسخها (+1 more)
|
||||
|
||||
### Community 306 - "Community 306"
|
||||
Cohesion: 0.07
|
||||
@@ -1907,8 +1908,8 @@ Cohesion: 0.36
|
||||
Nodes (3): TariffRepository, ManagerRegistry, Tariff
|
||||
|
||||
### Community 318 - "Community 318"
|
||||
Cohesion: 0.12
|
||||
Nodes (6): ServiceItemDeleteCleanupTest, ServiceCoverageNPlusOneTest, TenantServiceCoverageRepository, TenantInsuranceCleanupService, ManagerRegistry, TenantServiceCoverage
|
||||
Cohesion: 0.13
|
||||
Nodes (7): EntityInsurancePricing, EntityInsurancePricingRepository, TenantServiceCoverageRepository, TenantInsuranceCleanupService, ManagerRegistry, ManagerRegistry, TenantServiceCoverage
|
||||
|
||||
### Community 319 - "Community 319"
|
||||
Cohesion: 0.39
|
||||
@@ -2019,8 +2020,8 @@ Cohesion: 0.39
|
||||
Nodes (3): ProvinceRepository, ManagerRegistry, Province
|
||||
|
||||
### Community 349 - "Community 349"
|
||||
Cohesion: 0.22
|
||||
Nodes (4): EntityInsurancePricing, TenantInsuranceCleanupTest, EntityInsurancePricingRepository, ManagerRegistry
|
||||
Cohesion: 0.36
|
||||
Nodes (3): DoctorServiceController, JsonResponse, Request
|
||||
|
||||
### Community 350 - "Community 350"
|
||||
Cohesion: 0.43
|
||||
@@ -2030,6 +2031,10 @@ Nodes (3): SmsWalletRepository, ManagerRegistry, SmsWallet
|
||||
Cohesion: 0.50
|
||||
Nodes (4): UserActiveContextRepository, ManagerRegistry, User, UserActiveContext
|
||||
|
||||
### Community 352 - "Community 352"
|
||||
Cohesion: 0.39
|
||||
Nodes (3): DoctorService, DoctorServiceRepository, ManagerRegistry
|
||||
|
||||
### Community 354 - "Community 354"
|
||||
Cohesion: 0.36
|
||||
Nodes (3): TariffService, ServiceItem, Tariff
|
||||
@@ -2086,10 +2091,6 @@ Nodes (7): ادمین, دکتر نمونه کامل — تبریز, دکتران
|
||||
Cohesion: 0.29
|
||||
Nodes (7): Dashboard, GET `/api/v1/admin/dashboard/charts`, GET `/api/v1/admin/dashboard/recent`, GET `/api/v1/admin/dashboard/stats`, Response `200`, Response `200`, Response `200`
|
||||
|
||||
### Community 368 - "Community 368"
|
||||
Cohesion: 0.21
|
||||
Nodes (5): DateOverrideOwnershipTest, DateOverride, DateOverrideRepository, Doctor, ManagerRegistry
|
||||
|
||||
### Community 369 - "Community 369"
|
||||
Cohesion: 0.29
|
||||
Nodes (7): php-http/discovery, symfony/flex, symfony/runtime, config, allow-plugins, bump-after-update, sort-packages
|
||||
@@ -2202,25 +2203,21 @@ Nodes (6): ClaimAmountBoundsTest, ClaimsListNPlusOneTest, ClaimItem, Claim, Doct
|
||||
Cohesion: 0.33
|
||||
Nodes (4): initiate(), verify(), PaymentInitResult, PaymentVerifyResult
|
||||
|
||||
### Community 401 - "Community 401"
|
||||
Cohesion: 0.38
|
||||
Nodes (5): Rate, RateRepository, Doctor, ManagerRegistry, User
|
||||
### Community 399 - "Community 399"
|
||||
Cohesion: 0.23
|
||||
Nodes (5): AbstractMigration, Schema, Version20260609130407, Schema, Version20260619121047
|
||||
|
||||
### Community 405 - "Community 405"
|
||||
Cohesion: 0.47
|
||||
Nodes (3): CommissionService, Payment, Representation
|
||||
|
||||
### Community 406 - "Community 406"
|
||||
Cohesion: 0.23
|
||||
Nodes (5): AbstractMigration, Schema, Version20260609133546, Schema, Version20260625135132
|
||||
|
||||
### Community 418 - "Community 418"
|
||||
Cohesion: 0.17
|
||||
Nodes (11): دیپلوی ClinicPro روی Coolify (Docker Compose), رفع اشکال, مراحل دیپلوی, معماری دیپلوی, نکات عملیاتی, چند دامنه فرانتاند (مهم), ۱. ساخت Resource در Coolify, ۲. اختصاص دامنه (+3 more)
|
||||
|
||||
### Community 452 - "Community 452"
|
||||
Cohesion: 0.10
|
||||
Nodes (12): ClinicAddress, ClinicDoctorItem, ClinicInvitation, EditForm, editSchema, HUES_LIST, INV_STATUS_MAP, IRAN_CENTER (+4 more)
|
||||
Cohesion: 0.07
|
||||
Nodes (19): ApiResponse, ClinicAddress, ClinicDoctorItem, ClinicInvitation, EditForm, editSchema, HUES_LIST, INV_STATUS_MAP (+11 more)
|
||||
|
||||
### Community 456 - "Community 456"
|
||||
Cohesion: 0.29
|
||||
@@ -2246,10 +2243,6 @@ Nodes (6): ادمین — همه لینکهای فعلی (بدون تغییر
|
||||
Cohesion: 0.33
|
||||
Nodes (6): سناریوهای چند-context, قانون انتخاب context:, مفاهیم کلیدی معماری Multi-Context (مهم — قبل از پیادهسازی بخوان), مفهوم `available_contexts`, مفهوم `db_key`, مفهوم `db_uuid`
|
||||
|
||||
### Community 463 - "Community 463"
|
||||
Cohesion: 0.39
|
||||
Nodes (3): TagRepository, ManagerRegistry, Tag
|
||||
|
||||
### Community 465 - "Community 465"
|
||||
Cohesion: 0.12
|
||||
Nodes (16): راهاندازی تست و نوشتن تستسوئیت کامل برای Admin SPA, زمینه, فایلهای مرتبط, مشکل / هدف, نکات مهم, وضعیت فعلی, وظایف, پروژه (+8 more)
|
||||
@@ -2450,10 +2443,6 @@ Nodes (4): مجوزها در سیستم, مجوزهای منشی, نقش کار
|
||||
Cohesion: 0.40
|
||||
Nodes (4): ساختار فایلها, معماری — تسک ۱۶: ماژول داشبورد دکتر, نمودار جریان, کوئری درآمد سالانه (بر اساس ماههای شمسی)
|
||||
|
||||
### Community 522 - "Community 522"
|
||||
Cohesion: 0.24
|
||||
Nodes (5): WalletTransactionRepository, WalletTransactionsPaginationTest, ManagerRegistry, User, WalletTransaction
|
||||
|
||||
### Community 523 - "Community 523"
|
||||
Cohesion: 0.43
|
||||
Nodes (3): SmsTemplateRepository, SmsTemplate, ManagerRegistry
|
||||
@@ -2463,8 +2452,8 @@ Cohesion: 0.12
|
||||
Nodes (16): دیپلوی ClinicPro (Symfony) روی لیارا با پلتفرم PHP (بدون داکر), زمینه, فایلهای مرتبط, مشکل / هدف, نکات مهم, وضعیت فعلی (کد واقعی), وظایف, پروژه (+8 more)
|
||||
|
||||
### Community 525 - "Community 525"
|
||||
Cohesion: 0.50
|
||||
Nodes (4): GET `/api/v1/admin/representations`, Query Parameters, Representation Management, Response `200`
|
||||
Cohesion: 0.53
|
||||
Nodes (3): SessionServiceRepository, ManagerRegistry, SessionService
|
||||
|
||||
### Community 526 - "Community 526"
|
||||
Cohesion: 0.50
|
||||
@@ -2695,8 +2684,8 @@ Cohesion: 0.67
|
||||
Nodes (3): POST `/api/v1/admin/sms/template/{uuid}/approve`, Request Body (`application/json`), Response `200`
|
||||
|
||||
### Community 602 - "Community 602"
|
||||
Cohesion: 0.48
|
||||
Nodes (3): DoctorInsuranceRepository, DoctorInsurance, ManagerRegistry
|
||||
Cohesion: 0.40
|
||||
Nodes (5): Errors, Path Parameters, POST `/api/v1/settlement/{uuid}/approve`, Request Body (`application/json`), Response `200`
|
||||
|
||||
### Community 603 - "Community 603"
|
||||
Cohesion: 0.67
|
||||
@@ -2726,10 +2715,6 @@ Nodes (5): 37. 🔵 `POST` post, Request Body, مثال Request, هدرهای ا
|
||||
Cohesion: 0.67
|
||||
Nodes (3): بکاند, فرانتاند, وضعیت فعلی کد (مهم — قبل از تغییر بخوان)
|
||||
|
||||
### Community 618 - "Community 618"
|
||||
Cohesion: 0.25
|
||||
Nodes (4): SmsMessageTemplateRepository, SmsTextResolver, SmsMessageTemplate, ManagerRegistry
|
||||
|
||||
### Community 631 - "Community 631"
|
||||
Cohesion: 0.50
|
||||
Nodes (4): DELETE `/api/v1/doctor/{uuid}`, Errors, Path Parameters, Response `200`
|
||||
@@ -2828,7 +2813,7 @@ Nodes (4): ۲.۱۳ نظرات، لایک و امتیازدهی, ۲.۱۳.۱ نظ
|
||||
|
||||
### Community 662 - "Community 662"
|
||||
Cohesion: 0.50
|
||||
Nodes (4): GET `/api/v1/admin/secretaries`, Query Parameters, Response `200`, Secretary Management
|
||||
Nodes (4): Application Logs, GET `/api/v1/admin/logs`, Query Parameters, Response `200`
|
||||
|
||||
### Community 664 - "Community 664"
|
||||
Cohesion: 0.67
|
||||
@@ -2839,32 +2824,44 @@ Cohesion: 0.67
|
||||
Nodes (3): 43. 🟢 `GET` get, هدرهای اضافی, پاسخها
|
||||
|
||||
### Community 667 - "Community 667"
|
||||
Cohesion: 0.67
|
||||
Nodes (3): 36. 🟢 `GET` get my rate, هدرهای اضافی, پاسخها
|
||||
Cohesion: 0.50
|
||||
Nodes (4): GET `/api/v1/admin/payments`, Payment Management, Query Parameters, Response `200`
|
||||
|
||||
### Community 671 - "Community 671"
|
||||
Cohesion: 0.67
|
||||
Nodes (3): 34. 🔵 `POST` image logo, هدرهای اضافی, پاسخها
|
||||
|
||||
### Community 672 - "Community 672"
|
||||
Cohesion: 0.50
|
||||
Nodes (4): GET `/api/v1/admin/settlements`, Query Parameters, Response `200`, Settlement Management
|
||||
|
||||
### Community 674 - "Community 674"
|
||||
Cohesion: 0.50
|
||||
Nodes (4): extra, symfony, allow-contrib, require
|
||||
|
||||
### Community 676 - "Community 676"
|
||||
Cohesion: 0.67
|
||||
Nodes (3): 29. 🟢 `GET` clinic list 🆕, پارامترهای Query, پاسخها
|
||||
|
||||
## Knowledge Gaps
|
||||
- **3533 isolated node(s):** `ALLOWED_ROLES`, `Pricing`, `TenantInsurance`, `CoverageRow`, `Draft` (+3528 more)
|
||||
- **3550 isolated node(s):** `ALLOWED_ROLES`, `Pricing`, `TenantInsurance`, `CoverageRow`, `Draft` (+3545 more)
|
||||
These have ≤1 connection - possible missing edges or undocumented components.
|
||||
- **121 thin communities (<3 nodes) omitted from report** — run `graphify query` to explore isolated nodes.
|
||||
- **128 thin communities (<3 nodes) omitted from report** — run `graphify query` to explore isolated nodes.
|
||||
|
||||
## Suggested Questions
|
||||
_Questions this graph is uniquely positioned to answer:_
|
||||
|
||||
- **Why does `BaseController` connect `Community 108` to `Community 4`, `Community 6`, `Community 138`, `Community 139`, `Community 14`, `Community 15`, `Community 275`, `Community 20`, `Community 22`, `Community 26`, `Community 164`, `Community 295`, `Community 300`, `Community 301`, `Community 175`, `Community 176`, `Community 177`, `Community 58`, `Community 59`, `Community 63`, `Community 64`, `Community 70`, `Community 71`, `Community 75`, `Community 206`, `Community 230`, `Community 103`, `Community 104`, `Community 107`, `Community 109`, `Community 121`, `Community 122`, `Community 252`?**
|
||||
_High betweenness centrality (0.026) - this node is a cross-community bridge._
|
||||
- **Why does `BaseController` connect `Community 108` to `Community 6`, `Community 138`, `Community 139`, `Community 14`, `Community 15`, `Community 275`, `Community 20`, `Community 22`, `Community 26`, `Community 164`, `Community 295`, `Community 300`, `Community 301`, `Community 175`, `Community 176`, `Community 177`, `Community 58`, `Community 59`, `Community 63`, `Community 64`, `Community 70`, `Community 71`, `Community 75`, `Community 206`, `Community 349`, `Community 230`, `Community 103`, `Community 104`, `Community 107`, `Community 109`, `Community 121`, `Community 122`, `Community 252`?**
|
||||
_High betweenness centrality (0.027) - this node is a cross-community bridge._
|
||||
- **Why does `AppointmentRepository` connect `Community 119` to `Community 53`?**
|
||||
_High betweenness centrality (0.018) - this node is a cross-community bridge._
|
||||
- **Why does `Version20260614181657` connect `Community 431` to `Community 406`?**
|
||||
_High betweenness centrality (0.016) - this node is a cross-community bridge._
|
||||
_High betweenness centrality (0.022) - this node is a cross-community bridge._
|
||||
- **Why does `Version20260614181657` connect `Community 431` to `Community 399`?**
|
||||
_High betweenness centrality (0.017) - this node is a cross-community bridge._
|
||||
- **What connects `ALLOWED_ROLES`, `Pricing`, `TenantInsurance` to the rest of the system?**
|
||||
_3533 weakly-connected nodes found - possible documentation gaps or missing edges._
|
||||
_3550 weakly-connected nodes found - possible documentation gaps or missing edges._
|
||||
- **Should `Community 0` be split into smaller, more focused modules?**
|
||||
_Cohesion score 0.05137844611528822 - nodes in this community are weakly interconnected._
|
||||
_Cohesion score 0.047107014848950336 - nodes in this community are weakly interconnected._
|
||||
- **Should `Community 1` be split into smaller, more focused modules?**
|
||||
_Cohesion score 0.028985507246376812 - nodes in this community are weakly interconnected._
|
||||
- **Should `Community 2` be split into smaller, more focused modules?**
|
||||
_Cohesion score 0.0873015873015873 - nodes in this community are weakly interconnected._
|
||||
_Cohesion score 0.05656108597285068 - nodes in this community are weakly interconnected._
|
||||
graphify-out/cache/ast/v0.8.44/3970d300cb4b804fed477773b47b653edb7aa3754b8cefd0e3f7d31a49417e6f.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.8.44/467e207cc4ef518da9d34cbc907c3de3ffc355fcd67f30c3155fe38624853f8b.json
Vendored
+1
@@ -0,0 +1 @@
|
||||
{"nodes": [{"id": "users_hamed_pj_my_pj_clinic_pro_clinicpro_src_shared_logging_applogrepository_php", "label": "AppLogRepository.php", "file_type": "code", "source_file": "src/Shared/Logging/AppLogRepository.php", "source_location": "L1"}, {"id": "logging_applogrepository_applogrepository", "label": "AppLogRepository", "file_type": "code", "source_file": "src/Shared/Logging/AppLogRepository.php", "source_location": "L8"}, {"id": "serviceentityrepository", "label": "ServiceEntityRepository", "file_type": "code", "source_file": "", "source_location": ""}, {"id": "logging_applogrepository_applogrepository_construct", "label": ".__construct()", "file_type": "code", "source_file": "src/Shared/Logging/AppLogRepository.php", "source_location": "L10"}, {"id": "managerregistry", "label": "ManagerRegistry", "file_type": "code", "source_file": "src/Shared/Logging/AppLogRepository.php", "source_location": "L10"}], "edges": [{"source": "users_hamed_pj_my_pj_clinic_pro_clinicpro_src_shared_logging_applogrepository_php", "target": "serviceentityrepository", "relation": "imports", "context": "import", "confidence": "EXTRACTED", "source_file": "src/Shared/Logging/AppLogRepository.php", "source_location": "L5", "weight": 1.0}, {"source": "users_hamed_pj_my_pj_clinic_pro_clinicpro_src_shared_logging_applogrepository_php", "target": "managerregistry", "relation": "imports", "context": "import", "confidence": "EXTRACTED", "source_file": "src/Shared/Logging/AppLogRepository.php", "source_location": "L6", "weight": 1.0}, {"source": "users_hamed_pj_my_pj_clinic_pro_clinicpro_src_shared_logging_applogrepository_php", "target": "logging_applogrepository_applogrepository", "relation": "contains", "confidence": "EXTRACTED", "source_file": "src/Shared/Logging/AppLogRepository.php", "source_location": "L8", "weight": 1.0}, {"source": "logging_applogrepository_applogrepository", "target": "serviceentityrepository", "relation": "inherits", "confidence": "EXTRACTED", "source_file": "src/Shared/Logging/AppLogRepository.php", "source_location": "L8", "weight": 1.0}, {"source": "logging_applogrepository_applogrepository", "target": "logging_applogrepository_applogrepository_construct", "relation": "method", "confidence": "EXTRACTED", "source_file": "src/Shared/Logging/AppLogRepository.php", "source_location": "L10", "weight": 1.0}, {"source": "logging_applogrepository_applogrepository_construct", "target": "managerregistry", "relation": "references", "confidence": "EXTRACTED", "source_file": "src/Shared/Logging/AppLogRepository.php", "source_location": "L10", "weight": 1.0, "context": "parameter_type"}], "raw_calls": [{"caller_nid": "logging_applogrepository_applogrepository_construct", "callee": "parent", "is_member_call": false, "source_file": "/Users/hamed/pj/my_pj/clinic_pro/clinicpro/src/Shared/Logging/AppLogRepository.php", "source_location": "L10", "receiver": null}]}
|
||||
graphify-out/cache/ast/v0.8.44/48db5c733cc7e02a8a226fc4ed552cd5282c91d400dc72b32f3f122c127033bf.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.8.44/4d5328c774fe4619eb75f9de434173ae4a4b5ca53f7729383399b12c2a222805.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.8.44/5b3237552e7ae040e5a3b2a289e8fc0f0518a1df84553665ea214efd36c2321d.json
Vendored
+1
@@ -0,0 +1 @@
|
||||
{"nodes": [{"id": "users_hamed_pj_my_pj_clinic_pro_clinicpro_src_shared_logging_applog_php", "label": "AppLog.php", "file_type": "code", "source_file": "src/Shared/Logging/AppLog.php", "source_location": "L1"}, {"id": "logging_applog_applog", "label": "AppLog", "file_type": "code", "source_file": "src/Shared/Logging/AppLog.php", "source_location": "L8"}, {"id": "logging_applog_applog_construct", "label": ".__construct()", "file_type": "code", "source_file": "src/Shared/Logging/AppLog.php", "source_location": "L37"}, {"id": "logging_applog_applog_toarray", "label": ".toArray()", "file_type": "code", "source_file": "src/Shared/Logging/AppLog.php", "source_location": "L47"}], "edges": [{"source": "users_hamed_pj_my_pj_clinic_pro_clinicpro_src_shared_logging_applog_php", "target": "applogrepository", "relation": "imports", "context": "import", "confidence": "EXTRACTED", "source_file": "src/Shared/Logging/AppLog.php", "source_location": "L5", "weight": 1.0}, {"source": "users_hamed_pj_my_pj_clinic_pro_clinicpro_src_shared_logging_applog_php", "target": "mapping", "relation": "imports", "context": "import", "confidence": "EXTRACTED", "source_file": "src/Shared/Logging/AppLog.php", "source_location": "L6", "weight": 1.0}, {"source": "users_hamed_pj_my_pj_clinic_pro_clinicpro_src_shared_logging_applog_php", "target": "logging_applog_applog", "relation": "contains", "confidence": "EXTRACTED", "source_file": "src/Shared/Logging/AppLog.php", "source_location": "L8", "weight": 1.0}, {"source": "logging_applog_applog", "target": "logging_applog_applog_construct", "relation": "method", "confidence": "EXTRACTED", "source_file": "src/Shared/Logging/AppLog.php", "source_location": "L37", "weight": 1.0}, {"source": "logging_applog_applog", "target": "logging_applog_applog_toarray", "relation": "method", "confidence": "EXTRACTED", "source_file": "src/Shared/Logging/AppLog.php", "source_location": "L47", "weight": 1.0}], "raw_calls": [{"caller_nid": "logging_applog_applog_construct", "callee": "time", "is_member_call": false, "source_file": "/Users/hamed/pj/my_pj/clinic_pro/clinicpro/src/Shared/Logging/AppLog.php", "source_location": "L44", "receiver": null}]}
|
||||
graphify-out/cache/ast/v0.8.44/5c94ca6ec5ce79cd14d07da357d72b77bcaba0e1f311e187cd3c8eeb2ab4c46b.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.8.44/5f7ed3260ea0be9c83ebc1afec10ca24dd4d6118a7079299105fed545eadb817.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.8.44/6b0c84481084c4920b085db292f903c4d296f1bc05b91e906e5917ad517660f3.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.8.44/8e10ac183f88465faeecb560f4dfedb97a9faacf46758d37dae0a268df96d093.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.8.44/a8f899cc061053e5664833c2ebcbf1fe2613c8d5b150973bb95e621750267c61.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.8.44/c0e9b10f345c3d894d27267c90618903fab9693fd4be3542a755fbed3787fcba.json
Vendored
+1
@@ -0,0 +1 @@
|
||||
{"nodes": [{"id": "users_hamed_pj_my_pj_clinic_pro_clinicpro_migrations_version20260629161536_php", "label": "Version20260629161536.php", "file_type": "code", "source_file": "migrations/Version20260629161536.php", "source_location": "L1"}, {"id": "migrations_version20260629161536_version20260629161536", "label": "Version20260629161536", "file_type": "code", "source_file": "migrations/Version20260629161536.php", "source_location": "L13"}, {"id": "abstractmigration", "label": "AbstractMigration", "file_type": "code", "source_file": "", "source_location": ""}, {"id": "migrations_version20260629161536_version20260629161536_getdescription", "label": ".getDescription()", "file_type": "code", "source_file": "migrations/Version20260629161536.php", "source_location": "L15"}, {"id": "migrations_version20260629161536_version20260629161536_up", "label": ".up()", "file_type": "code", "source_file": "migrations/Version20260629161536.php", "source_location": "L20"}, {"id": "schema", "label": "Schema", "file_type": "code", "source_file": "migrations/Version20260629161536.php", "source_location": "L20"}, {"id": "migrations_version20260629161536_version20260629161536_down", "label": ".down()", "file_type": "code", "source_file": "migrations/Version20260629161536.php", "source_location": "L26"}], "edges": [{"source": "users_hamed_pj_my_pj_clinic_pro_clinicpro_migrations_version20260629161536_php", "target": "schema", "relation": "imports", "context": "import", "confidence": "EXTRACTED", "source_file": "migrations/Version20260629161536.php", "source_location": "L7", "weight": 1.0}, {"source": "users_hamed_pj_my_pj_clinic_pro_clinicpro_migrations_version20260629161536_php", "target": "abstractmigration", "relation": "imports", "context": "import", "confidence": "EXTRACTED", "source_file": "migrations/Version20260629161536.php", "source_location": "L8", "weight": 1.0}, {"source": "users_hamed_pj_my_pj_clinic_pro_clinicpro_migrations_version20260629161536_php", "target": "migrations_version20260629161536_version20260629161536", "relation": "contains", "confidence": "EXTRACTED", "source_file": "migrations/Version20260629161536.php", "source_location": "L13", "weight": 1.0}, {"source": "migrations_version20260629161536_version20260629161536", "target": "abstractmigration", "relation": "inherits", "confidence": "EXTRACTED", "source_file": "migrations/Version20260629161536.php", "source_location": "L13", "weight": 1.0}, {"source": "migrations_version20260629161536_version20260629161536", "target": "migrations_version20260629161536_version20260629161536_getdescription", "relation": "method", "confidence": "EXTRACTED", "source_file": "migrations/Version20260629161536.php", "source_location": "L15", "weight": 1.0}, {"source": "migrations_version20260629161536_version20260629161536", "target": "migrations_version20260629161536_version20260629161536_up", "relation": "method", "confidence": "EXTRACTED", "source_file": "migrations/Version20260629161536.php", "source_location": "L20", "weight": 1.0}, {"source": "migrations_version20260629161536_version20260629161536_up", "target": "schema", "relation": "references", "confidence": "EXTRACTED", "source_file": "migrations/Version20260629161536.php", "source_location": "L20", "weight": 1.0, "context": "parameter_type"}, {"source": "migrations_version20260629161536_version20260629161536", "target": "migrations_version20260629161536_version20260629161536_down", "relation": "method", "confidence": "EXTRACTED", "source_file": "migrations/Version20260629161536.php", "source_location": "L26", "weight": 1.0}, {"source": "migrations_version20260629161536_version20260629161536_down", "target": "schema", "relation": "references", "confidence": "EXTRACTED", "source_file": "migrations/Version20260629161536.php", "source_location": "L26", "weight": 1.0, "context": "parameter_type"}], "raw_calls": [{"caller_nid": "migrations_version20260629161536_version20260629161536_up", "callee": "addSql", "is_member_call": true, "source_file": "/Users/hamed/pj/my_pj/clinic_pro/clinicpro/migrations/Version20260629161536.php", "source_location": "L23", "receiver": null}, {"caller_nid": "migrations_version20260629161536_version20260629161536_down", "callee": "addSql", "is_member_call": true, "source_file": "/Users/hamed/pj/my_pj/clinic_pro/clinicpro/migrations/Version20260629161536.php", "source_location": "L29", "receiver": null}]}
|
||||
graphify-out/cache/ast/v0.8.44/c9214091aa37726eb1cb7109234bd2a24ce6cd77674d50ec83fc188cad819a0c.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.8.44/d2a46876b0e6e0cc5cf9a88c3ea9067de7a0f64df5bc59099c215c14a123a305.json
Vendored
+1
File diff suppressed because one or more lines are too long
graphify-out/cache/ast/v0.8.44/e2910a9314c0a63d60a4d5df210a421d12149e64db2550cb58bf021bb84f19c7.json
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+1
-1
File diff suppressed because one or more lines are too long
+2303
-484
File diff suppressed because it is too large
Load Diff
+62
-22
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"assets/admin/App.tsx": {
|
||||
"mtime": 1782400063.6389112,
|
||||
"ast_hash": "2f0a7050be8ada2f7008f4aae236f703",
|
||||
"mtime": 1782750328.975341,
|
||||
"ast_hash": "496200a3a353f2057d3a37621f80aa07",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"assets/admin/components/FreeVisitPrice.tsx": {
|
||||
@@ -30,8 +30,8 @@
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"assets/admin/components/layout/Sidebar.tsx": {
|
||||
"mtime": 1782400092.7212493,
|
||||
"ast_hash": "02cce7c3b866abb5b4db9944beab3b6b",
|
||||
"mtime": 1782750354.360757,
|
||||
"ast_hash": "1e0b143a269672d9e611f4e702dd8cac",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"assets/admin/components/layout/Topbar.tsx": {
|
||||
@@ -400,8 +400,8 @@
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"assets/admin/types/index.ts": {
|
||||
"mtime": 1782222413.7646248,
|
||||
"ast_hash": "478d7eddb5720c5479cecc351e04db5a",
|
||||
"mtime": 1782750264.645306,
|
||||
"ast_hash": "61c032545f706815210d6f66b080bdae",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"assets/app.js": {
|
||||
@@ -810,8 +810,8 @@
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"src/Admin/Controller/AdminApiController.php": {
|
||||
"mtime": 1782728407.188377,
|
||||
"ast_hash": "d0fb995cfa2841c3abd52a2c61ab2936",
|
||||
"mtime": 1782750165.3401215,
|
||||
"ast_hash": "09658d49a5db4c963fdbb7a167c8a38f",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"src/Admin/Controller/AdminController.php": {
|
||||
@@ -1370,8 +1370,8 @@
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"src/Payment/Gateway/MellatGateway.php": {
|
||||
"mtime": 1781591920.5083737,
|
||||
"ast_hash": "a4452ebb35de07a6fb274b66bcde6d48",
|
||||
"mtime": 1782750018.5149405,
|
||||
"ast_hash": "0f96d01c7e17a2894388f74825a232b3",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"src/Payment/Gateway/MockGateway.php": {
|
||||
@@ -1395,8 +1395,8 @@
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"src/Payment/Gateway/SepGateway.php": {
|
||||
"mtime": 1781591920.5088227,
|
||||
"ast_hash": "071ad614e25b59f22b91d69f885539a2",
|
||||
"mtime": 1782750047.5329745,
|
||||
"ast_hash": "c08e78093b3efe725e6798c81bf63dd2",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"src/Payment/Repository/PaymentRepository.php": {
|
||||
@@ -1580,8 +1580,8 @@
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"src/Shared/Service/ApiIrService.php": {
|
||||
"mtime": 1782403597.5695918,
|
||||
"ast_hash": "727088b0f129f6841af82a3f616af47b",
|
||||
"mtime": 1782750061.8790064,
|
||||
"ast_hash": "5fb80e5fce2191b2ece9385cf295b983",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"src/Shared/Service/FileValidatorService.php": {
|
||||
@@ -1650,13 +1650,13 @@
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"src/Sms/Provider/KavehNegarProvider.php": {
|
||||
"mtime": 1782748838.317826,
|
||||
"ast_hash": "b014a73717612d8c6196b21513a6a460",
|
||||
"mtime": 1782749956.4059756,
|
||||
"ast_hash": "fd66bf9a71b06d16beebf133d8f8a847",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"src/Sms/Provider/RanginehProvider.php": {
|
||||
"mtime": 1782748853.3315036,
|
||||
"ast_hash": "6ddebcd3c562b18563b270e3624819d5",
|
||||
"mtime": 1782749977.7460625,
|
||||
"ast_hash": "386ef07ebd18747e614e717a86447adf",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"src/Sms/Provider/SmsProviderInterface.php": {
|
||||
@@ -2300,8 +2300,8 @@
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"config/services.yaml": {
|
||||
"mtime": 1782399435.1211493,
|
||||
"ast_hash": "b915f22d2fa1213be06732b639b8e00e",
|
||||
"mtime": 1782749831.6264865,
|
||||
"ast_hash": "e2fbe3625e44dc10f140933838278ac1",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"docs/Architecture_Audit.md": {
|
||||
@@ -2340,8 +2340,8 @@
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"docs/api/admin.md": {
|
||||
"mtime": 1782728407.10617,
|
||||
"ast_hash": "05cd1f677bf970d48e78b6d022ca81a6",
|
||||
"mtime": 1782750435.7712955,
|
||||
"ast_hash": "6a68974b5f71e8d4104ffea4cab99edf",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"docs/api/appointment-settings.md": {
|
||||
@@ -3373,5 +3373,45 @@
|
||||
"mtime": 1782732335.2708814,
|
||||
"ast_hash": "935e36360352c12270b34ff25e66c483",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"assets/admin/pages/LogsPage.tsx": {
|
||||
"mtime": 1782750290.0145962,
|
||||
"ast_hash": "76db47af844faf3459083afa2ded8aaf",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"migrations/Version20260629161536.php": {
|
||||
"mtime": 1782749766.4726315,
|
||||
"ast_hash": "74fdeef99f76b3951b5d420e1655d929",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"src/Shared/Logging/AppLog.php": {
|
||||
"mtime": 1782749711.1944597,
|
||||
"ast_hash": "9089bd9d329d30edade338cb47fd4822",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"src/Shared/Logging/AppLogRepository.php": {
|
||||
"mtime": 1782749721.788873,
|
||||
"ast_hash": "b0bba22cca2483a041b2c719812d7729",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"src/Shared/Logging/DbLogger.php": {
|
||||
"mtime": 1782749810.2277756,
|
||||
"ast_hash": "15a0793868378a0fd1af918944b2e61e",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"tests/Admin/AdminLogsTest.php": {
|
||||
"mtime": 1782750195.979033,
|
||||
"ast_hash": "6622637d75fe6e5fc81b9c9236f03191",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
"tests/Shared/DbLoggerTest.php": {
|
||||
"mtime": 1782749879.1659822,
|
||||
"ast_hash": "c09d6d560c768e4d78753c425a7d6f92",
|
||||
"semantic_hash": ""
|
||||
},
|
||||
".claude/prompt/project-wide-logging.md": {
|
||||
"mtime": 1782749549.9584448,
|
||||
"ast_hash": "6e005bb338617608a0441724126a5bd3",
|
||||
"semantic_hash": ""
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Auto-generated Migration: Please modify to your needs!
|
||||
*/
|
||||
final class Version20260629161536 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Create app_log table for persisted application logs';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
// this up() migration is auto-generated, please modify it to your needs
|
||||
$this->addSql('CREATE TABLE app_log (id INT AUTO_INCREMENT NOT NULL, level VARCHAR(16) NOT NULL, message LONGTEXT NOT NULL, context LONGTEXT DEFAULT NULL, channel VARCHAR(32) DEFAULT NULL, path VARCHAR(255) DEFAULT NULL, created_at INT NOT NULL, INDEX idx_app_log_level (level, created_at), INDEX idx_app_log_created (created_at), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
// this down() migration is auto-generated, please modify it to your needs
|
||||
$this->addSql('DROP TABLE app_log');
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@ use App\Representation\Entity\Representation;
|
||||
use App\Secretary\Entity\DoctorSecretary;
|
||||
use App\Settlement\Entity\FinancialBreakdown;
|
||||
use App\Settlement\Entity\Settlement;
|
||||
use App\Shared\Logging\AppLog;
|
||||
use App\Sms\Entity\SmsLog;
|
||||
use App\Sms\Entity\SmsTemplate;
|
||||
use App\Shared\Controller\BaseController;
|
||||
@@ -1929,4 +1930,63 @@ class AdminApiController extends BaseController
|
||||
'period' => ['from' => $from, 'to' => $to],
|
||||
]);
|
||||
}
|
||||
|
||||
// ── Application logs ────────────────────────────────────────────────────────
|
||||
|
||||
#[OA\Get(
|
||||
path: '/api/v1/admin/logs',
|
||||
summary: 'List persisted application logs (paginated)',
|
||||
security: [['bearerAuth' => []]],
|
||||
parameters: [
|
||||
new OA\Parameter(name: 'page', in: 'query', required: false, schema: new OA\Schema(type: 'integer', default: 1)),
|
||||
new OA\Parameter(name: 'limit', in: 'query', required: false, schema: new OA\Schema(type: 'integer', default: 25)),
|
||||
new OA\Parameter(name: 'level', in: 'query', required: false, description: 'PSR level filter (warning/error/critical/...)', schema: new OA\Schema(type: 'string')),
|
||||
new OA\Parameter(name: 'search', in: 'query', required: false, description: 'substring match on message', schema: new OA\Schema(type: 'string')),
|
||||
new OA\Parameter(name: 'from', in: 'query', required: false, description: 'unix timestamp lower bound', schema: new OA\Schema(type: 'integer')),
|
||||
new OA\Parameter(name: 'to', in: 'query', required: false, description: 'unix timestamp upper bound', schema: new OA\Schema(type: 'integer')),
|
||||
],
|
||||
responses: [new OA\Response(response: 200, description: 'Paginated list of logs')]
|
||||
)]
|
||||
#[Route('/api/v1/admin/logs', methods: ['GET'])]
|
||||
public function logs(Request $request): JsonResponse
|
||||
{
|
||||
$page = max(1, (int) $request->query->get('page', 1));
|
||||
$limit = min(100, max(5, (int) $request->query->get('limit', 25)));
|
||||
$level = trim((string) $request->query->get('level', ''));
|
||||
$search = trim((string) $request->query->get('search', ''));
|
||||
$from = trim((string) $request->query->get('from', ''));
|
||||
$to = trim((string) $request->query->get('to', ''));
|
||||
|
||||
$qb = $this->em->createQueryBuilder()
|
||||
->select('l.id, l.level, l.message, l.context, l.channel, l.path, l.createdAt')
|
||||
->from(AppLog::class, 'l');
|
||||
|
||||
if ($level !== '') {
|
||||
$qb->andWhere('l.level = :level')->setParameter('level', $level);
|
||||
}
|
||||
if ($search !== '') {
|
||||
$qb->andWhere('l.message LIKE :s')->setParameter('s', '%' . $search . '%');
|
||||
}
|
||||
if ($from !== '') {
|
||||
$qb->andWhere('l.createdAt >= :from')->setParameter('from', (int) $from);
|
||||
}
|
||||
if ($to !== '') {
|
||||
$qb->andWhere('l.createdAt <= :to')->setParameter('to', (int) $to);
|
||||
}
|
||||
|
||||
$qb->orderBy('l.id', 'DESC');
|
||||
|
||||
$total = (clone $qb)->select('COUNT(l.id)')->getQuery()->getSingleScalarResult();
|
||||
$rows = $qb->setFirstResult(($page - 1) * $limit)->setMaxResults($limit)->getQuery()->getArrayResult();
|
||||
|
||||
return $this->paginated(array_map(fn(array $l) => [
|
||||
'id' => (int) $l['id'],
|
||||
'level' => $l['level'],
|
||||
'message' => $l['message'],
|
||||
'context' => $l['context'],
|
||||
'channel' => $l['channel'],
|
||||
'path' => $l['path'],
|
||||
'created_at' => (int) $l['createdAt'],
|
||||
], $rows), (int) $total, $page, $limit);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Payment\Gateway;
|
||||
|
||||
use App\Config\Repository\SiteConfigRepository;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||
|
||||
class MellatGateway implements PaymentGatewayInterface
|
||||
@@ -12,6 +13,7 @@ class MellatGateway implements PaymentGatewayInterface
|
||||
public function __construct(
|
||||
private readonly HttpClientInterface $httpClient,
|
||||
private readonly SiteConfigRepository $configRepo,
|
||||
private readonly LoggerInterface $logger,
|
||||
private readonly string $terminalId = '',
|
||||
private readonly string $username = '',
|
||||
private readonly string $password = '',
|
||||
@@ -46,6 +48,7 @@ class MellatGateway implements PaymentGatewayInterface
|
||||
|
||||
return new PaymentInitResult(true, redirectUrl: $redirectUrl, token: $refId);
|
||||
} catch (\Throwable $e) {
|
||||
$this->logger->error(sprintf('Payment initiate failed (mellat): %s @ %s:%d', $e->getMessage(), $e->getFile(), $e->getLine()), ['exception' => $e, 'orderId' => $orderId, 'amount' => $amountRials]);
|
||||
return new PaymentInitResult(false, errorMessage: $e->getMessage());
|
||||
}
|
||||
}
|
||||
@@ -79,6 +82,7 @@ class MellatGateway implements PaymentGatewayInterface
|
||||
|
||||
return new PaymentVerifyResult(true, referenceId: $refId);
|
||||
} catch (\Throwable $e) {
|
||||
$this->logger->error(sprintf('Payment verify failed (mellat): %s @ %s:%d', $e->getMessage(), $e->getFile(), $e->getLine()), ['exception' => $e, 'refId' => $refId]);
|
||||
return new PaymentVerifyResult(false, errorMessage: $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Payment\Gateway;
|
||||
|
||||
use App\Config\Repository\SiteConfigRepository;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||
|
||||
class SepGateway implements PaymentGatewayInterface
|
||||
@@ -13,6 +14,7 @@ class SepGateway implements PaymentGatewayInterface
|
||||
public function __construct(
|
||||
private readonly HttpClientInterface $httpClient,
|
||||
private readonly SiteConfigRepository $configRepo,
|
||||
private readonly LoggerInterface $logger,
|
||||
private readonly string $terminalId = '',
|
||||
) {}
|
||||
|
||||
@@ -47,6 +49,7 @@ class SepGateway implements PaymentGatewayInterface
|
||||
|
||||
return new PaymentInitResult(true, redirectUrl: $redirectUrl, token: $token);
|
||||
} catch (\Throwable $e) {
|
||||
$this->logger->error(sprintf('Payment initiate failed (sep): %s @ %s:%d', $e->getMessage(), $e->getFile(), $e->getLine()), ['exception' => $e, 'orderId' => $orderId, 'amount' => $amountRials]);
|
||||
return new PaymentInitResult(false, errorMessage: $e->getMessage());
|
||||
}
|
||||
}
|
||||
@@ -84,6 +87,7 @@ class SepGateway implements PaymentGatewayInterface
|
||||
amountRials: (int) $data['TransactionDetail']['AffectiveAmount']
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
$this->logger->error(sprintf('Payment verify failed (sep): %s @ %s:%d', $e->getMessage(), $e->getFile(), $e->getLine()), ['exception' => $e, 'refNum' => $refNum]);
|
||||
return new PaymentVerifyResult(false, errorMessage: $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
namespace App\Shared\Logging;
|
||||
|
||||
use App\Shared\Logging\AppLogRepository;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
|
||||
#[ORM\Entity(repositoryClass: AppLogRepository::class)]
|
||||
#[ORM\Table(name: 'app_log')]
|
||||
#[ORM\Index(columns: ['level', 'created_at'], name: 'idx_app_log_level')]
|
||||
#[ORM\Index(columns: ['created_at'], name: 'idx_app_log_created')]
|
||||
class AppLog
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column(type: 'integer')]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 16)]
|
||||
private string $level;
|
||||
|
||||
#[ORM\Column(type: 'text')]
|
||||
private string $message;
|
||||
|
||||
#[ORM\Column(type: 'text', nullable: true)]
|
||||
private ?string $context = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 32, nullable: true)]
|
||||
private ?string $channel = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 255, nullable: true)]
|
||||
private ?string $path = null;
|
||||
|
||||
#[ORM\Column(name: 'created_at', type: 'integer')]
|
||||
private int $createdAt;
|
||||
|
||||
public function __construct(string $level, string $message, ?string $context = null, ?string $channel = null, ?string $path = null)
|
||||
{
|
||||
$this->level = $level;
|
||||
$this->message = $message;
|
||||
$this->context = $context;
|
||||
$this->channel = $channel;
|
||||
$this->path = $path;
|
||||
$this->createdAt = time();
|
||||
}
|
||||
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'level' => $this->level,
|
||||
'message' => $this->message,
|
||||
'context' => $this->context,
|
||||
'channel' => $this->channel,
|
||||
'path' => $this->path,
|
||||
'created_at' => $this->createdAt,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace App\Shared\Logging;
|
||||
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
class AppLogRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry) { parent::__construct($registry, AppLog::class); }
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
|
||||
namespace App\Shared\Logging;
|
||||
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Psr\Log\LogLevel;
|
||||
use Symfony\Component\HttpFoundation\RequestStack;
|
||||
|
||||
/**
|
||||
* Decorates Symfony's minimal `logger` service: every existing LoggerInterface
|
||||
* injection keeps writing to stderr (so logs surface in `liara logs`) AND is
|
||||
* persisted to the app_log table for `warning` and above, making them queryable
|
||||
* from the admin panel. Logging must never slow down or break the request, so the
|
||||
* DB write uses a raw DBAL INSERT (independent of the request's ORM transaction)
|
||||
* wrapped in a catch-all.
|
||||
*/
|
||||
final class DbLogger implements LoggerInterface
|
||||
{
|
||||
private const PERSIST = [
|
||||
LogLevel::WARNING,
|
||||
LogLevel::ERROR,
|
||||
LogLevel::CRITICAL,
|
||||
LogLevel::ALERT,
|
||||
LogLevel::EMERGENCY,
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
private readonly LoggerInterface $inner,
|
||||
private readonly Connection $conn,
|
||||
private readonly RequestStack $requestStack,
|
||||
) {}
|
||||
|
||||
public function log($level, \Stringable|string $message, array $context = []): void
|
||||
{
|
||||
$this->inner->log($level, $message, $context);
|
||||
|
||||
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 | JSON_PARTIAL_OUTPUT_ON_ERROR) : null,
|
||||
'channel' => 'app',
|
||||
'path' => $this->requestStack->getCurrentRequest()?->getPathInfo(),
|
||||
'created_at' => time(),
|
||||
]);
|
||||
} catch (\Throwable) {
|
||||
// Logging must never break the request; a failed persist stays on stderr only.
|
||||
}
|
||||
}
|
||||
|
||||
public function emergency(\Stringable|string $message, array $context = []): void { $this->log(LogLevel::EMERGENCY, $message, $context); }
|
||||
public function alert(\Stringable|string $message, array $context = []): void { $this->log(LogLevel::ALERT, $message, $context); }
|
||||
public function critical(\Stringable|string $message, array $context = []): void { $this->log(LogLevel::CRITICAL, $message, $context); }
|
||||
public function error(\Stringable|string $message, array $context = []): void { $this->log(LogLevel::ERROR, $message, $context); }
|
||||
public function warning(\Stringable|string $message, array $context = []): void { $this->log(LogLevel::WARNING, $message, $context); }
|
||||
public function notice(\Stringable|string $message, array $context = []): void { $this->log(LogLevel::NOTICE, $message, $context); }
|
||||
public function info(\Stringable|string $message, array $context = []): void { $this->log(LogLevel::INFO, $message, $context); }
|
||||
public function debug(\Stringable|string $message, array $context = []): void { $this->log(LogLevel::DEBUG, $message, $context); }
|
||||
|
||||
/**
|
||||
* Replace a raw Throwable in context with a compact string — the full object
|
||||
* is huge and not JSON-friendly. Keeps the rest of the context as-is.
|
||||
*/
|
||||
private function sanitize(array $context): array
|
||||
{
|
||||
if (isset($context['exception']) && $context['exception'] instanceof \Throwable) {
|
||||
$e = $context['exception'];
|
||||
$context['exception'] = sprintf('%s: %s @ %s:%d', $e::class, $e->getMessage(), $e->getFile(), $e->getLine());
|
||||
}
|
||||
|
||||
return $context;
|
||||
}
|
||||
}
|
||||
@@ -96,7 +96,7 @@ class ApiIrService
|
||||
} catch (AppException $e) {
|
||||
throw $e;
|
||||
} catch (\Throwable $e) {
|
||||
$this->logger->error('api.ir inquiry failed', ['path' => $path, 'error' => $e->getMessage()]);
|
||||
$this->logger->error(sprintf('api.ir inquiry failed: %s @ %s:%d', $e->getMessage(), $e->getFile(), $e->getLine()), ['exception' => $e, 'path' => $path]);
|
||||
throw new AppException(ErrorCodes::ERR_EXTERNAL_001, null, 502);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Sms\Provider;
|
||||
|
||||
use App\Config\Repository\SiteConfigRepository;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||
|
||||
class KavehNegarProvider implements SmsProviderInterface
|
||||
@@ -12,6 +13,7 @@ class KavehNegarProvider implements SmsProviderInterface
|
||||
public function __construct(
|
||||
private readonly HttpClientInterface $httpClient,
|
||||
private readonly SiteConfigRepository $configRepo,
|
||||
private readonly LoggerInterface $logger,
|
||||
// Nullable: env `%env(default::KAVENEGAR_API_KEY)%` resolves to null when unset
|
||||
// (the real key normally comes from DB site config below, not env).
|
||||
private readonly ?string $apiKey = null,
|
||||
@@ -38,7 +40,8 @@ class KavehNegarProvider implements SmsProviderInterface
|
||||
);
|
||||
$data = $resp->toArray();
|
||||
return ($data['return']['status'] ?? 0) === 200;
|
||||
} catch (\Throwable) {
|
||||
} 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;
|
||||
}
|
||||
}
|
||||
@@ -58,7 +61,8 @@ class KavehNegarProvider implements SmsProviderInterface
|
||||
);
|
||||
$data = $resp->toArray();
|
||||
return ($data['return']['status'] ?? 0) === 200;
|
||||
} catch (\Throwable) {
|
||||
} catch (\Throwable $e) {
|
||||
$this->logger->error(sprintf('SMS sendTemplate failed (kavenegar): %s @ %s:%d', $e->getMessage(), $e->getFile(), $e->getLine()), ['exception' => $e, 'mobile' => $mobile, 'template' => $templateCode]);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Sms\Provider;
|
||||
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||
|
||||
class RanginehProvider implements SmsProviderInterface
|
||||
@@ -10,6 +11,7 @@ class RanginehProvider implements SmsProviderInterface
|
||||
|
||||
public function __construct(
|
||||
private readonly HttpClientInterface $httpClient,
|
||||
private readonly LoggerInterface $logger,
|
||||
// Nullable: env `%env(default::RANGINEH_API_KEY)%` resolves to null when unset,
|
||||
// which would crash construction (TypeError) before the provider is ever used.
|
||||
private readonly ?string $apiKey = null,
|
||||
@@ -27,7 +29,8 @@ class RanginehProvider implements SmsProviderInterface
|
||||
'timeout' => 10,
|
||||
]);
|
||||
return $resp->getStatusCode() === 200;
|
||||
} catch (\Throwable) {
|
||||
} catch (\Throwable $e) {
|
||||
$this->logger->error(sprintf('SMS send failed (rangineh): %s @ %s:%d', $e->getMessage(), $e->getFile(), $e->getLine()), ['exception' => $e, 'mobile' => $mobile]);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -45,7 +48,8 @@ class RanginehProvider implements SmsProviderInterface
|
||||
'timeout' => 10,
|
||||
]);
|
||||
return $resp->getStatusCode() === 200;
|
||||
} catch (\Throwable) {
|
||||
} catch (\Throwable $e) {
|
||||
$this->logger->error(sprintf('SMS sendTemplate failed (rangineh): %s @ %s:%d', $e->getMessage(), $e->getFile(), $e->getLine()), ['exception' => $e, 'mobile' => $mobile, 'template' => $templateCode]);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Admin;
|
||||
|
||||
use App\Tests\ApiTestCase;
|
||||
|
||||
/**
|
||||
* GET /api/v1/admin/logs — admin-only, paginated, filterable by level.
|
||||
*/
|
||||
class AdminLogsTest extends ApiTestCase
|
||||
{
|
||||
private function seedLog(string $level, string $message): void
|
||||
{
|
||||
$this->em->getConnection()->insert('app_log', [
|
||||
'level' => $level,
|
||||
'message' => $message,
|
||||
'channel' => 'app',
|
||||
'path' => '/test',
|
||||
'created_at' => time(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function testNonAdminForbidden(): void
|
||||
{
|
||||
$user = $this->createUser(['ROLE_USER']);
|
||||
$this->authJson('GET', '/api/v1/admin/logs', $user);
|
||||
self::assertSame(403, $this->responseCode());
|
||||
}
|
||||
|
||||
public function testAdminSeesLogsFilteredByLevel(): void
|
||||
{
|
||||
$marker = 'ADMINLOGTEST_' . bin2hex(random_bytes(5));
|
||||
$this->seedLog('error', $marker . '_err');
|
||||
$this->seedLog('warning', $marker . '_warn');
|
||||
|
||||
$admin = $this->createUser(['ROLE_ADMIN']);
|
||||
$body = $this->authJson('GET', '/api/v1/admin/logs?level=error&search=' . $marker, $admin);
|
||||
|
||||
self::assertSame(200, $this->responseCode());
|
||||
self::assertTrue($body['success']);
|
||||
self::assertCount(1, $body['data']);
|
||||
self::assertSame('error', $body['data'][0]['level']);
|
||||
self::assertSame($marker . '_err', $body['data'][0]['message']);
|
||||
self::assertArrayHasKey('totalRecords', $body['meta']);
|
||||
|
||||
$this->em->getConnection()->executeStatement('DELETE FROM app_log WHERE message LIKE ?', [$marker . '%']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests\Shared;
|
||||
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
|
||||
|
||||
/**
|
||||
* The `logger` service is decorated by App\Shared\Logging\DbLogger, which persists
|
||||
* warning-and-above to the app_log table while leaving info/debug on stderr only.
|
||||
*/
|
||||
class DbLoggerTest extends KernelTestCase
|
||||
{
|
||||
public function testWarningPersistedButInfoIsNot(): void
|
||||
{
|
||||
self::bootKernel();
|
||||
$c = static::getContainer();
|
||||
/** @var LoggerInterface $logger */
|
||||
$logger = $c->get('logger');
|
||||
/** @var Connection $conn */
|
||||
$conn = $c->get('doctrine.dbal.default_connection');
|
||||
|
||||
$marker = 'DBLOGGER_TEST_' . bin2hex(random_bytes(5));
|
||||
|
||||
$logger->info($marker . '_info');
|
||||
$logger->warning($marker . '_warn', ['exception' => new \RuntimeException('boom')]);
|
||||
|
||||
$warn = (int) $conn->fetchOne('SELECT COUNT(*) FROM app_log WHERE message = ?', [$marker . '_warn']);
|
||||
$info = (int) $conn->fetchOne('SELECT COUNT(*) FROM app_log WHERE message = ?', [$marker . '_info']);
|
||||
|
||||
self::assertSame(1, $warn, 'warning must be persisted to app_log');
|
||||
self::assertSame(0, $info, 'info must NOT be persisted to app_log');
|
||||
|
||||
// The Throwable in context is stored as a compact string, not a raw object.
|
||||
$row = $conn->fetchAssociative('SELECT context, level, channel FROM app_log WHERE message = ?', [$marker . '_warn']);
|
||||
self::assertSame('warning', $row['level']);
|
||||
self::assertSame('app', $row['channel']);
|
||||
self::assertStringContainsString('RuntimeException: boom', (string) $row['context']);
|
||||
|
||||
$conn->executeStatement('DELETE FROM app_log WHERE message LIKE ?', [$marker . '%']);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user