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:
hamed
2026-06-29 20:01:03 +03:30
parent 830f7e8d0c
commit 803196108c
38 changed files with 3284 additions and 618 deletions
+229
View File
@@ -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`.
```
+2
View File
@@ -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,
+142
View File
@@ -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>
);
}
+10
View File
@@ -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;
+7
View File
@@ -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)%'
+50
View File
@@ -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).
+6 -1
View File
@@ -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
View File
@@ -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._
File diff suppressed because one or more lines are too long
@@ -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}]}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -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}]}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -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}]}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+2303 -484
View File
File diff suppressed because it is too large Load Diff
+62 -22
View File
@@ -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": ""
}
}
+31
View File
@@ -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);
}
}
+4
View File
@@ -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());
}
}
+4
View File
@@ -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());
}
}
+59
View File
@@ -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,
];
}
}
+11
View File
@@ -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); }
}
+78
View File
@@ -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;
}
}
+1 -1
View File
@@ -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);
}
}
+6 -2
View File
@@ -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;
}
}
+6 -2
View File
@@ -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;
}
}
+48
View File
@@ -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 . '%']);
}
}
+43
View File
@@ -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 . '%']);
}
}