feat: add ClinicInvitationWebController and related templates for handling clinic invitations

- Implemented ClinicInvitationWebController to manage the invitation process via web.
- Added view and respond methods to handle invitation display and responses.
- Created result.html.twig and view.html.twig templates for rendering invitation results and views.
- Integrated CSRF protection for form submissions.
- Established routes for invitation viewing and responding.
This commit is contained in:
hamed
2026-07-11 09:33:55 +03:30
parent a5e3408e85
commit 744a40c0f6
19 changed files with 2125 additions and 671 deletions
@@ -0,0 +1,263 @@
# صفحه Twig دعوت پزشک + کوتاه‌کردن URL پیامک
## زمینه
پیامک دعوت پزشک به کلینیک یک لینک بلند دارد که برای SMS نامناسب است و باعث خطای `431 Request Header Fields Too Large` سمت کاوه‌نگار هم شده. نمونه پیامک فعلی:
```
دکتر گرامی، کلینیک {clinic} شما را برای همکاری دعوت کرده است.
برای بررسی: https://clinic-pro.ir/clinic-invitation/fd18ae2ed174db3e79271c27ae03e0317ef81c8989836a9cfeea4933999f4dff56d795ba0420a05f1c04eb6a4602603b
این لینک تا ۷۲ ساعت معتبر است.
```
مشکل دوم: مسیر `https://clinic-pro.ir/clinic-invitation/{token}` در `clinicpro` **هیچ route وبی ندارد** — فقط نسخهٔ `/api/v1/clinic-invitation/{token}` (JSON) وجود دارد. پس وقتی پزشک روی لینک پیامک می‌زند، صفحه‌ای برای رد/تایید نمی‌بیند. باید یک صفحهٔ HTML با **Twig** ساخته شود که پزشک بتواند دعوت را «تایید» یا «رد» کند و بعد از تایید پیام «درخواست شما تایید شد و می‌توانید وارد پنل ادمین شوید» نمایش داده شود.
> نکته: توکن در تسک قبلی از `random_bytes(48)` به `random_bytes(16)` (۳۲ کاراکتر hex) کوتاه شد؛ این تسک آن را کوتاه‌تر می‌کند و مصرف‌کننده (صفحه Twig) را می‌سازد.
## مشکل / هدف
۱. **کوتاه‌کردن توکن و لینک** تا پیامک کوتاه و بدون خطای 431 باشد.
۲. **ساخت صفحه Twig عمومی** روی مسیر بدونِ `/api` که وضعیت دعوت را نشان می‌دهد و دو دکمهٔ تایید/رد دارد.
۳. **صفحهٔ نتیجه**: بعد از تایید → پیام موفقیت + لینک ورود به پنل ادمین؛ بعد از رد → پیام رد؛ برای توکن منقضی/نامعتبر/استفاده‌شده → پیام مناسب (نه خطای ۵۰۰).
## فایل‌های مرتبط
| فایل | نقش |
|------|-----|
| `src/ClinicInvitation/Entity/ClinicDoctorInvitation.php` | تولید توکن (خط ۷۷ و ۹۷)؛ ستون `token` خط ۵۴؛ `isUsable()`، `getStatus()`، `STATUS_*` |
| `src/ClinicInvitation/Service/ClinicInvitationService.php` | `sendSms()` خط ۱۱۳ (ساخت لینک)؛ `accept()`، `reject()` |
| `src/ClinicInvitation/Controller/ClinicInvitationController.php` | endpointهای JSON فعلی (`/api/v1/clinic-invitation/{token}` + accept/reject) — دست‌نخورده می‌مانند |
| `src/ClinicInvitation/Repository/ClinicDoctorInvitationRepository.php` | `findByToken()` خط ۱۷ |
| `templates/payment/result.html.twig` | الگوی استایل صفحهٔ نتیجه (Vazirmatn، RTL، noindex، متغیرهای رنگ) — **از این کپی کن** |
| `templates/base.html.twig` | لِی‌اوت پایه |
| `config/packages/security.yaml` | firewall اصلی فقط `^/(api|oauth|file/upload)/` را پوشش می‌دهد؛ مسیر وبِ جدید بیرون آن = عمومی |
| `docs/api/clinic-invitation.md` | باید route‌های وب جدید مستند شوند |
## وضعیت فعلی
توکن (خط ۷۷ و ۹۷ در Entity):
```php
$this->token = bin2hex(random_bytes(16)); // ۳۲ کاراکتر
```
ساخت لینک پیامک (`ClinicInvitationService.php:113`):
```php
$link = rtrim($this->appUrl, '/') . '/clinic-invitation/' . $inv->getToken();
```
سرویس accept (`ClinicInvitationService.php`):
```php
public function accept(ClinicDoctorInvitation $inv): void
{
if (!$inv->isUsable()) {
throw new AppException('ERR_NOT_FOUND_001', 'دعوتنامه منقضی یا غیرمعتبر است', 410);
}
$inv->setStatus(ClinicDoctorInvitation::STATUS_ACCEPTED);
$inv->markUsed();
$doctor = $inv->getDoctor();
if ($doctor === null) {
$doctor = $this->doctorRepo->findOneByMobile($inv->getMobile());
if ($doctor !== null) { $inv->setDoctor($doctor); }
}
if ($doctor !== null) {
$clinic = $inv->getClinic();
if (!$clinic->getDoctors()->contains($doctor)) {
$clinic->getDoctors()->add($doctor);
}
}
$this->em->flush();
}
```
مسیرهای JSON عمومی موجود (در `ClinicInvitationController`) — **حذف نشوند** (کلاینت React/اپ از آن‌ها استفاده می‌کند):
```php
#[Route('/api/v1/clinic-invitation/{token}', methods: ['GET'])] // viewInvitation
#[Route('/api/v1/clinic-invitation/{token}/accept', methods: ['POST'])] // acceptInvitation
#[Route('/api/v1/clinic-invitation/{token}/reject', methods: ['POST'])] // rejectInvitation
```
الگوی render در پروژه (`PaymentController`):
```php
return $this->render('payment/redirect.html.twig', ['action' => $action, 'params' => $params]);
```
## وظایف
### ۱. کوتاه‌کردن توکن دعوت
در `ClinicDoctorInvitation.php` خط ۷۷ (constructor) و ۹۷ (`refresh()`) توکن را کوتاه‌تر کن. یک توکن تک‌مصرفِ ۷۲ ساعته نیازی به ۳۲ کاراکتر ندارد — ۱۲ کاراکتر hex (۴۸ بیت آنتروپی) کافی و امن است:
```php
$this->token = bin2hex(random_bytes(6)); // ۱۲ کاراکتر
```
- ستون `token` روی `string` است (خط ۵۴، unique index `idx_cdi_token`)؛ کوتاه‌تر شدن مقدار **migration لازم ندارد**.
- چون index یکتاست، احتمال برخورد در ۱۲ کاراکتر عملاً صفر است؛ ولی برای اطمینان، اگر جای دیگری توکن با تضمین یکتایی ساخته می‌شود همان الگو را نگه‌دار. (اگر می‌خواهی strict باشی: در سرویسِ سازندهٔ دعوت، در صورت `UniqueConstraintViolation` یک بار دیگر توکن بساز.)
> نتیجه: لینک از `.../clinic-invitation/<۳۲>` به `.../clinic-invitation/<۱۲>` می‌رسد؛ اگر route کوتاه `/i/{token}` را هم اضافه کنی (وظیفهٔ ۲، اختیاری) لینک به `https://clinic-pro.ir/i/<۱۲>` (~۳۲ کاراکتر) می‌رسد.
### ۲. صفحهٔ Twig دعوت (نمایش + تایید/رد)
یک کنترلر وبِ جدید بساز: `src/ClinicInvitation/Controller/ClinicInvitationWebController.php` که از `BaseController` ارث می‌برد (پس `render()` در دسترس است). مسیرها **بدون** پیشوند `/api` تا خارج از firewall JWT و عمومی بمانند (مثل صفحهٔ `home` و صفحات Twig پرداخت):
```php
namespace App\ClinicInvitation\Controller;
use App\ClinicInvitation\Repository\ClinicDoctorInvitationRepository;
use App\ClinicInvitation\Service\ClinicInvitationService;
use App\ClinicInvitation\Entity\ClinicDoctorInvitation;
use App\Shared\Controller\BaseController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
class ClinicInvitationWebController extends BaseController
{
public function __construct(
private readonly ClinicDoctorInvitationRepository $invRepo,
private readonly ClinicInvitationService $invitationService,
) {}
// صفحهٔ دعوت — لینک پیامک اینجا باز می‌شود (GET، بدون auth)
#[Route('/clinic-invitation/{token}', methods: ['GET'], name: 'invitation_web_view')]
public function view(string $token): Response
{
$inv = $this->invRepo->findByToken($token);
if ($inv === null) {
return $this->render('invitation/result.html.twig', ['state' => 'notfound'])
->setStatusCode(404);
}
// اگر قبلاً پاسخ داده شده یا منقضی است، مستقیم صفحهٔ وضعیت را نشان بده
if (!$inv->isUsable()) {
return $this->render('invitation/result.html.twig', [
'state' => $inv->getStatus() === ClinicDoctorInvitation::STATUS_ACCEPTED ? 'accepted'
: ($inv->getStatus() === ClinicDoctorInvitation::STATUS_REJECTED ? 'rejected' : 'expired'),
'admin_url' => $this->adminUrl(),
]);
}
return $this->render('invitation/view.html.twig', [
'token' => $token,
'clinic' => $inv->getClinic()->getName() ?: 'کلینیک',
'doctor' => $inv->getInvitedName(),
'expires_at' => $inv->getExpiresAt(),
]);
}
// تایید/رد — فقط POST تا لینکِ GET (پیش‌فچ مرورگر/ربات) به‌طور ناخواسته accept نکند
#[Route('/clinic-invitation/{token}/respond', methods: ['POST'], name: 'invitation_web_respond')]
public function respond(string $token, Request $request): Response
{
$inv = $this->invRepo->findByToken($token);
if ($inv === null) {
return $this->render('invitation/result.html.twig', ['state' => 'notfound'])->setStatusCode(404);
}
// CSRF: توکن در فرمِ صفحهٔ view رندر می‌شود
$action = (string) $request->request->get('action', '');
if (!$this->isCsrfTokenValid('invitation_' . $token, (string) $request->request->get('_token'))) {
return $this->render('invitation/result.html.twig', ['state' => 'expired'])->setStatusCode(403);
}
try {
if ($action === 'accept') {
$this->invitationService->accept($inv);
return $this->render('invitation/result.html.twig', [
'state' => 'accepted',
'admin_url' => $this->adminUrl(),
]);
}
if ($action === 'reject') {
$this->invitationService->reject($inv);
return $this->render('invitation/result.html.twig', ['state' => 'rejected']);
}
} catch (\App\Shared\Exception\AppException $e) {
return $this->render('invitation/result.html.twig', ['state' => 'expired']);
}
return $this->render('invitation/result.html.twig', ['state' => 'expired'])->setStatusCode(422);
}
private function adminUrl(): string
{
return rtrim($_ENV['APP_BASE_URL'] ?? '', '/') . '/admin';
}
}
```
> **دربارهٔ route کوتاه (اختیاری ولی توصیه‌شده):** برای کوتاه‌ترین لینک ممکن، همین متد `view` را با یک alias کوتاه هم expose کن: `#[Route('/i/{token}', methods: ['GET'])]` و در `sendSms()` از `/i/` استفاده کن. اگر این کار را کردی، مطمئن شو `/i/{token}` با route دیگری تداخل ندارد (`ddev exec php bin/console debug:router | grep '/i/'`).
**به‌روزرسانی لینک پیامک** در `ClinicInvitationService::sendSms()` (خط ۱۱۳) — اگر route کوتاه اضافه کردی:
```php
$link = rtrim($this->appUrl, '/') . '/i/' . $inv->getToken();
```
اگر route کوتاه اضافه نکردی، این خط بدون تغییر می‌ماند (`/clinic-invitation/`).
### ۳. تمپلیت‌های Twig
دو فایل بساز. استایل را از `templates/payment/result.html.twig` کپی کن (فونت Vazirmatn، `dir="rtl"`، `<meta name="robots" content="noindex,nofollow">`، متغیرهای رنگ `--ok`/`--err`/`--primary`، کارت وسط‌چین). فونت را از همان CDN فعلی بگیر.
**`templates/invitation/view.html.twig`** — صفحهٔ دعوت با دو دکمه:
- عنوان: «دعوت به همکاری»
- متن: «کلینیک **{{ clinic }}** شما را برای همکاری دعوت کرده است.» (و اگر `doctor` مقدار دارد، «{{ doctor }} عزیز،» بالای آن)
- اعتبار: «این دعوت تا {{ expires_at }} معتبر است» (تاریخ را با `date` فیلترِ Twig یا متن ثابت «۷۲ ساعت» نشان بده؛ اگر شمسی خواستی از یک فیلتر موجود استفاده کن، وگرنه متن ثابت کافی است)
- دو فرمِ POST جدا (یا یک فرم با دو دکمهٔ `name="action"`):
```twig
<form method="post" action="{{ path('invitation_web_respond', {token: token}) }}">
<input type="hidden" name="_token" value="{{ csrf_token('invitation_' ~ token) }}">
<button type="submit" name="action" value="accept" class="btn btn-ok">تایید و پذیرش دعوت</button>
<button type="submit" name="action" value="reject" class="btn btn-err">رد دعوت</button>
</form>
```
**`templates/invitation/result.html.twig`** — بر اساس متغیر `state`:
| `state` | پیام | جزئیات |
|--------|------|--------|
| `accepted` | **درخواست شما تایید شد و می‌توانید وارد پنل ادمین شوید** | دکمهٔ «ورود به پنل ادمین» با `href="{{ admin_url }}"` |
| `rejected` | «دعوت رد شد.» | بدون دکمه |
| `expired` | «این دعوت منقضی شده یا معتبر نیست.» | بدون دکمه |
| `notfound` | «دعوتنامه یافت نشد.» | بدون دکمه |
مثال بلوک:
```twig
{% if state == 'accepted' %}
<div class="icon ok">✓</div>
<h1>درخواست شما تایید شد</h1>
<p>می‌توانید وارد پنل ادمین شوید.</p>
<a class="btn btn-primary" href="{{ admin_url }}">ورود به پنل ادمین</a>
{% elseif state == 'rejected' %}
...
{% endif %}
```
### ۴. مستندسازی
`docs/api/clinic-invitation.md` را به‌روز کن: بخش جدیدی برای **صفحات وب (HTML)** اضافه کن:
- `GET /clinic-invitation/{token}` (و در صورت افزودن، `GET /i/{token}`) — صفحهٔ HTML دعوت، عمومی، بدون JWT.
- `POST /clinic-invitation/{token}/respond` — بدنهٔ `action=accept|reject` + `_token` (CSRF)؛ خروجی HTML صفحهٔ نتیجه.
- ذکر کن endpointهای JSON قبلی (`/api/v1/clinic-invitation/...`) دست‌نخورده باقی مانده‌اند و برای کلاینت React/اپ‌اند؛ صفحات وب جدید مخصوص گیرندهٔ پیامک (مرورگر) هستند.
## نکات مهم
- **عمومی بودن مسیر**: firewall اصلی فقط `^/(api|oauth|file/upload)/` است؛ چون route جدید زیر `/api` نیست، مثل صفحهٔ `home` و صفحات Twig پرداخت به‌صورت عمومی سِرو می‌شود. بعد از افزودن، با یک `curl` بدون توکن تست کن که ۲۰۰ برمی‌گردد نه ۴۰۱/302. اگر به هر دلیل firewall آن را گرفت، الگوی `payment` را در `security.yaml` دنبال کن.
- **accept فقط با POST**: هرگز روی GET، accept/reject انجام نده — پیش‌فچ مرورگر یا اسکنر ربات لینک پیامک (GET) نباید دعوت را تغییر دهد. GET فقط نمایش است.
- **CSRF**: توکن CSRF در صفحهٔ `view` رندر و در `respond` اعتبارسنجی شود (`csrf_token()` / `isCsrfTokenValid()`). چون فرم پس از باز شدن صفحه ارسال می‌شود، این کار امکان‌پذیر است.
- **edge — پزشک بدون حساب**: `accept()` پزشک را با موبایل پیدا می‌کند؛ اگر پزشکی با آن موبایل ثبت نشده باشد، دعوت `accepted` می‌شود ولی به کلینیک لینک نمی‌شود و کاربر حسابی برای ورود ندارد. پیام «می‌توانید وارد پنل ادمین شوید» برای این حالت گمراه‌کننده است — می‌توانی در `result.html.twig` وقتی `admin_url` هست ولی حساب نیست، جمله را نرم کنی (مثلاً «در صورت داشتن حساب می‌توانید وارد شوید»). حداقل این edge را در نظر بگیر؛ رفتار پیش‌فرض همان متن ثابت خواستهٔ کاربر است.
- **حالت‌های توکن**: منقضی/استفاده‌شده/نامعتبر همه باید صفحهٔ HTML مؤدبانه بدهند، نه ۵۰۰ یا JSON خام.
- **بدون کتابخانهٔ CSS جدید**: استایل inline در تمپلیت مثل `payment/result.html.twig`.
- **تست دستی**:
```bash
ddev exec php bin/console debug:router | grep clinic-invitation
# یک token معتبر از دیتابیس بردار و در مرورگر/‌curl باز کن
curl -sk -o /dev/null -w "%{http_code}\n" https://clinic-pro.ddev.site/clinic-invitation/<token>
```
+4
View File
@@ -120,6 +120,10 @@ services:
arguments:
$appUrl: '%env(APP_BASE_URL)%'
App\ClinicInvitation\Controller\ClinicInvitationWebController:
arguments:
$appUrl: '%env(APP_BASE_URL)%'
App\Secretary\Controller\SecretaryController:
arguments:
$appUrl: '%env(APP_BASE_URL)%'
+39 -2
View File
@@ -1,8 +1,8 @@
# Clinic Doctor Invitation API
> **Prefix:** `/api/v1/admin/clinic/...` (admin) and `/api/v1/clinic-invitation/...` (public)
> **Prefix:** `/api/v1/admin/clinic/...` (admin) and `/api/v1/clinic-invitation/...` (public JSON) and `/i/...`, `/clinic-invitation/...` (public HTML pages)
Admins invite doctors to clinics via SMS. The doctor receives a secure 96-char token link valid for 72 hours.
Admins invite doctors to clinics via SMS. The doctor receives a short (12-char hex) token link valid for 72 hours. Tapping the link opens a server-rendered HTML page (Twig) where the doctor accepts or rejects the invitation (see **Web pages** at the bottom).
---
@@ -52,6 +52,7 @@ Send an invitation to a doctor (by mobile number) to join a clinic.
> SMS is dispatched **asynchronously** via Symfony Messenger → Redis queue.
> SMS text: `"دکتر گرامی، کلینیک {name} شما را برای همکاری دعوت کرده است.\nبرای بررسی: {link}\nاین لینک تا ۷۲ ساعت معتبر است."`
> `{link}` = `{APP_BASE_URL}/i/{token}` — short path + 12-char token to keep the SMS small (a long URL caused Kavenegar `431`).
### Errors
| Code | HTTP | Description |
@@ -385,3 +386,39 @@ Doctor accepts or rejects an invitation from their panel (no SMS token needed).
| `ERR_NOT_FOUND_001` | 404 | Invitation not found or not owned by doctor |
| `ERR_NOT_FOUND_001` | 410 | Invitation expired or already used |
| `ERR_VALIDATION_001` | 422 | action is not accept or reject |
---
## Web pages (HTML, Twig) — SMS link target
Public, no JWT. These render **HTML** (not JSON) and are the target of the invitation SMS link. They are served outside the `^/(api|oauth|file/upload)/` firewall (like the payment result pages). Controller: `src/ClinicInvitation/Controller/ClinicInvitationWebController.php`; templates: `templates/invitation/{view,result}.html.twig`. The existing `/api/v1/clinic-invitation/...` JSON endpoints above are unchanged and remain for the React admin / app clients.
### GET `/i/{token}` (and alias `GET /clinic-invitation/{token}`)
Renders the invitation page. `/i/{token}` is the short form used in the SMS.
- **Usable invitation** → `200`, `view.html.twig`: clinic name, invited name, "valid 72h", and a POST form with **accept** / **reject** buttons (carries a CSRF token).
- **Already accepted / rejected / expired / used** → `200`, `result.html.twig` in the matching state.
- **Token not found** → `404`, `result.html.twig` state `notfound`.
### POST `/clinic-invitation/{token}/respond`
Processes the doctor's choice. **POST only** — accept/reject never happens on GET, so browser/bot prefetch of the SMS link cannot mutate the invitation.
**Form body (`application/x-www-form-urlencoded`):**
| Field | Type | Values |
|-------|------|--------|
| `_token` | string | CSRF token `invitation_{token}` (rendered in the GET page) |
| `action` | string | `accept` \| `reject` |
**Responses (all HTML):**
| Situation | HTTP | Rendered |
|-----------|------|----------|
| `accept` ok | `200` | «درخواست شما تایید شد» + "ورود به پنل ادمین" button (`{APP_BASE_URL}/admin`) |
| `reject` ok | `200` | «دعوت رد شد» |
| Invalid/missing CSRF | `403` | `expired` page |
| Expired / already used (service throws) | `200` | `expired` page |
| Unknown `action` | `422` | `expired` page |
| Token not found | `404` | `notfound` page |
> `accept` links the doctor to the clinic **only if** a doctor account exists for the invitation mobile (`accept()` looks it up by mobile). If none exists, the invitation is marked accepted but the doctor must still have/create an account to actually log in.
+8 -1
View File
@@ -740,5 +740,12 @@
"738": "Community 738",
"739": "Community 739",
"740": "Community 740",
"741": "Community 741"
"741": "Community 741",
"742": "Community 742",
"743": "Community 743",
"744": "Community 744",
"745": "Community 745",
"746": "Community 746",
"747": "Community 747",
"748": "Community 748"
}
+101 -74
View File
@@ -1,16 +1,16 @@
# Graph Report - clinicpro (2026-07-11)
## Corpus Check
- 740 files · ~550,094 words
- 743 files · ~552,971 words
- Verdict: corpus is large enough that graph structure adds value.
## Summary
- 9347 nodes · 12943 edges · 742 communities (598 shown, 144 thin omitted)
- 9384 nodes · 12986 edges · 749 communities (603 shown, 146 thin omitted)
- Extraction: 98% EXTRACTED · 2% INFERRED · 0% AMBIGUOUS · INFERRED: 281 edges (avg confidence: 0.8)
- Token cost: 0 input · 0 output
## Graph Freshness
- Built from commit: `f30bf5df`
- Built from commit: `a5e3408e`
- Run `git rev-parse HEAD` and compare to check if the graph is stale.
- Run `graphify update .` after code changes (no API cost).
@@ -743,9 +743,16 @@
- [[_COMMUNITY_Community 739|Community 739]]
- [[_COMMUNITY_Community 740|Community 740]]
- [[_COMMUNITY_Community 741|Community 741]]
- [[_COMMUNITY_Community 742|Community 742]]
- [[_COMMUNITY_Community 743|Community 743]]
- [[_COMMUNITY_Community 744|Community 744]]
- [[_COMMUNITY_Community 745|Community 745]]
- [[_COMMUNITY_Community 746|Community 746]]
- [[_COMMUNITY_Community 747|Community 747]]
- [[_COMMUNITY_Community 748|Community 748]]
## God Nodes (most connected - your core abstractions)
1. `BaseController` - 80 edges
1. `BaseController` - 82 edges
2. `ApiTestCase` - 76 edges
3. `Doctor` - 59 edges
4. `api` - 55 edges
@@ -771,7 +778,7 @@
## Import Cycles
- None detected.
## Communities (742 total, 144 thin omitted)
## Communities (749 total, 146 thin omitted)
### Community 0 - "Community 0"
Cohesion: 0.05
@@ -802,8 +809,8 @@ Cohesion: 0.12
Nodes (12): SettlementController, SettlementRepository, WalletTransactionRepository, Settlement, JsonResponse, Request, User, ManagerRegistry (+4 more)
### Community 7 - "Community 7"
Cohesion: 0.05
Nodes (11): DoctorServiceController, DoctorService, Clinic, DoctorServiceRepository, Collection, Doctor, self, User (+3 more)
Cohesion: 0.06
Nodes (8): DoctorService, Clinic, DoctorServiceRepository, Collection, Doctor, self, User, ManagerRegistry
### Community 8 - "Community 8"
Cohesion: 0.08
@@ -822,8 +829,8 @@ Cohesion: 0.50
Nodes (4): UserActiveContextRepository, ManagerRegistry, User, UserActiveContext
### Community 12 - "Community 12"
Cohesion: 0.05
Nodes (39): Clinic Doctor Invitation API, DELETE `/api/v1/admin/clinic/invitation/{invUuid}`, Errors, Errors, Errors, Errors, Errors, Errors (+31 more)
Cohesion: 0.04
Nodes (47): Clinic Doctor Invitation API, DELETE `/api/v1/admin/clinic/invitation/{invUuid}`, Errors, Errors, Errors, Errors, Errors, Errors (+39 more)
### Community 13 - "Community 13"
Cohesion: 0.05
@@ -838,8 +845,8 @@ Cohesion: 0.25
Nodes (7): PaymentController, Appointment, JsonResponse, Payment, Request, Response, User
### Community 16 - "Community 16"
Cohesion: 0.05
Nodes (10): PatientSession, SmsWallet, AppLog, LogPruneService, AppointmentExpiryService, Appointment, Collection, PatientRecord (+2 more)
Cohesion: 0.09
Nodes (6): PatientSession, Appointment, Collection, PatientRecord, self, SessionService
### Community 17 - "Community 17"
Cohesion: 0.05
@@ -862,8 +869,8 @@ Cohesion: 0.03
Nodes (48): formatNumber(), ClinicAddress, ClinicDetailPage(), ClinicDoctorItem, ClinicInvitation, EditForm, editSchema, HUES_LIST (+40 more)
### Community 22 - "Community 22"
Cohesion: 0.12
Nodes (10): RatingController, Like, CommentListNPlusOneTest, LikeRepository, JsonResponse, Request, User, Comment (+2 more)
Cohesion: 0.15
Nodes (9): RatingController, Like, LikeRepository, JsonResponse, Request, User, Comment, ManagerRegistry (+1 more)
### Community 23 - "Community 23"
Cohesion: 0.05
@@ -882,8 +889,8 @@ Cohesion: 0.24
Nodes (4): InsuranceController, JsonResponse, Request, User
### Community 27 - "Community 27"
Cohesion: 0.05
Nodes (40): 55. 🟢 `GET` all tag, 56. 🟢 `GET` supplementary_insurance, 57. 🟢 `GET` categories list, 58. 🟢 `GET` all state, 59. 🟢 `GET` all city, 60. 🟢 `GET` all specially doctor, 61. 🔵 `POST` post, 62. 🟡 `PATCH` patch (+32 more)
Cohesion: 0.06
Nodes (35): 55. 🟢 `GET` all tag, 56. 🟢 `GET` supplementary_insurance, 57. 🟢 `GET` categories list, 58. 🟢 `GET` all state, 59. 🟢 `GET` all city, 60. 🟢 `GET` all specially doctor, 62. 🟡 `PATCH` patch, 63. 🔴 `DELETE` DELETE (+27 more)
### Community 28 - "Community 28"
Cohesion: 0.08
@@ -983,7 +990,7 @@ Nodes (26): الزامات UI, باگ‌فیکس صفحه نوبت‌ها, با
### Community 53 - "Community 53"
Cohesion: 0.08
Nodes (16): AppLogRepository, ClaimItemRepository, DoctorInsuranceRepository, PreRegistrationRepository, ProvinceRepository, SessionServiceRepository, ServiceEntityRepository, ManagerRegistry (+8 more)
Nodes (15): AppLogRepository, PaymentLog, ClaimItemRepository, PaymentLogRepository, PreRegistrationRepository, SiteConfigRepository, SmsSettingsRepository, ServiceEntityRepository (+7 more)
### Community 54 - "Community 54"
Cohesion: 0.10
@@ -1026,16 +1033,20 @@ Cohesion: 0.06
Nodes (41): FreeVisitPrice(), Pricing, cn(), formatRial(), iranMobileOptionalSchema, iranMobileSchema, isValidIranMobile(), maskMobile() (+33 more)
### Community 64 - "Community 64"
Cohesion: 0.19
Nodes (7): SmsWalletController, SmsSettingsRepository, SmsSettings, JsonResponse, Request, User, ManagerRegistry
Cohesion: 0.29
Nodes (4): SmsWalletController, JsonResponse, Request, User
### Community 65 - "Community 65"
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.06
Nodes (6): ClinicStaff, SmsWallet, AppLog, LogPruneService, AppointmentExpiryService, self
### Community 67 - "Community 67"
Cohesion: 0.11
Nodes (4): Invoice, Collection, InvoiceItem, self
Cohesion: 0.08
Nodes (7): FinancialBreakdown, Invoice, Collection, InvoiceItem, self, Payment, User
### Community 68 - "Community 68"
Cohesion: 0.12
@@ -1103,7 +1114,7 @@ Nodes (30): devDependencies, @babel/core, @babel/preset-env, @babel/preset-react
### Community 86 - "Community 86"
Cohesion: 0.05
Nodes (16): AppointmentExpiryServiceTest, ScheduleOwnershipTest, LowTierFixesTest, SendCodeMobileRateLimitTest, ClaimsListPaginationTest, CaptchaFlowTest, ServiceItemStaffOwnershipTest, KernelBrowser (+8 more)
Nodes (17): AppointmentExpiryServiceTest, DateOverrideOwnershipTest, LowTierFixesTest, SendCodeMobileRateLimitTest, ClaimsListPaginationTest, CaptchaFlowTest, ServiceItemDeleteCleanupTest, ServiceItemStaffOwnershipTest (+9 more)
### Community 87 - "Community 87"
Cohesion: 0.10
@@ -1174,8 +1185,8 @@ Cohesion: 0.11
Nodes (18): `AdminApiController::paymentDetail` — الان فقط GET, `MellatGateway.php` — الگوی موجود REST/SOAP (پس از کار sandbox), `PaymentGatewayInterface.php`, `PaymentManager.php` — الگوی log و transaction, برگشت/استرداد وجه ملت از پنل ادمین (bpReversalRequest / bpRefundRequest), زمینه, فایل‌های مرتبط, نکات مهم (+10 more)
### 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
@@ -1190,8 +1201,8 @@ Cohesion: 0.33
Nodes (6): AppointmentController, Appointment, Doctor, JsonResponse, Request, User
### Community 108 - "Community 108"
Cohesion: 0.13
Nodes (9): CaptchaController, BaseController, CategoryController, CategoryImportController, JsonResponse, JsonResponse, Request, JsonResponse (+1 more)
Cohesion: 0.14
Nodes (9): CaptchaController, BaseController, CategoryController, SiteContextController, JsonResponse, JsonResponse, Request, JsonResponse (+1 more)
### Community 109 - "Community 109"
Cohesion: 0.29
@@ -1290,8 +1301,8 @@ Cohesion: 0.12
Nodes (16): Endpoint ها, GET /api/v1/representation/filter/{id}, GET /api/v1/representation/filter/{representationId}, GET /api/v1/representation/my-appointments/{id}, GET /api/v1/representation/{uuid}, GET /api/v1/representation/yearly-income/{id}, GET /api/v1/representation/yearly-income/{representationId}, POST /api/v1/representations/{id}/bank-accounts (+8 more)
### Community 135 - "Community 135"
Cohesion: 0.09
Nodes (22): Appointment Settings API, Available Locations, Day Index Convention, DELETE `/api/v1/booking-setting/{uuid}`, Errors, Errors, Errors, Errors (+14 more)
Cohesion: 0.12
Nodes (16): Day Index Convention, DELETE `/api/v1/booking-setting/{uuid}`, Errors, Errors, Errors, Errors, GET `/api/v1/appointment-settings/weekly-schedule/{uuid}`, PATCH `/api/v1/appointment-settings/weekly-schedule/{uuid}` (+8 more)
### Community 136 - "Community 136"
Cohesion: 0.12
@@ -1386,8 +1397,8 @@ Cohesion: 0.12
Nodes (15): Endpoint ها, GET /api/v1/payment/{uuid}, POST /api/v1/payment, POST /api/v1/payment/callback/mellat, Strategy Pattern برای درگاه‌ها, Subscription Payment — POST /api/v1/subscription-payment, ⚠ امنیت: IP Whitelist برای Callback, ⚠ امنیت: جلوگیری از Open Redirect (+7 more)
### Community 160 - "Community 160"
Cohesion: 0.13
Nodes (15): Date Overrides, DELETE `/api/v1/appointment-settings/date-override/{uuid}`, Errors, Errors, GET `/api/v1/appointment-settings/date-override/list/{doctorUuid}`, GET `/api/v1/appointment-settings/date-override/{uuid}`, PATCH `/api/v1/appointment-settings/date-override/{uuid}`, POST `/api/v1/appointment-settings/date-override` (+7 more)
Cohesion: 0.09
Nodes (21): Appointment Settings API, Available Locations, Date Overrides, DELETE `/api/v1/appointment-settings/date-override/{uuid}`, Errors, Errors, Errors, `GET /api/v1/appointment-settings/available-locations/{doctorUuid}` (+13 more)
### Community 161 - "Community 161"
Cohesion: 0.24
@@ -1398,8 +1409,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
@@ -1422,8 +1433,8 @@ Cohesion: 0.10
Nodes (20): api.ir (استعلام هویت — Shahkar / IbanMatch), اتصال به دیتابیس‌های مستقل (الزامی), اسرار (الزامی — قبل از اولین دیپلوی), امنیت و منابع, بررسی سلامت, دامنه‌ها و CORS, دیپلوی‌های بعدی, راهنمای دیپلوی ClinicPro (Coolify + Docker Compose) (+12 more)
### Community 169 - "Community 169"
Cohesion: 0.22
Nodes (4): EntityInsurancePricing, TenantInsuranceCleanupTest, EntityInsurancePricingRepository, ManagerRegistry
Cohesion: 0.13
Nodes (7): EntityInsurancePricing, EntityInsurancePricingRepository, TenantServiceCoverageRepository, TenantInsuranceCleanupService, ManagerRegistry, ManagerRegistry, TenantServiceCoverage
### Community 170 - "Community 170"
Cohesion: 0.13
@@ -1542,8 +1553,8 @@ Cohesion: 0.14
Nodes (13): Endpoint ها, PATCH /api/v1/secretary/{uuid}, POST /api/v1/secretary, تسک ۱۴: ماژول منشی, توضیح, زمان تخمینی, ساختار JSON, سیستم مجوزها — Resource-Based Permissions (مقیاس‌پذیر) (+5 more)
### Community 201 - "Community 201"
Cohesion: 0.12
Nodes (16): Admin API, Clinic Invitation Management, Dashboard, GET `/api/v1/admin/dashboard/charts`, GET `/api/v1/admin/dashboard/recent`, GET `/api/v1/admin/dashboard/stats`, GET `/api/v1/admin/pre-registrations`, GET /api/v1/admin/settings (+8 more)
Cohesion: 0.17
Nodes (12): Admin API, Clinic Invitation Management, Dashboard, GET `/api/v1/admin/dashboard/charts`, GET `/api/v1/admin/dashboard/recent`, GET `/api/v1/admin/dashboard/stats`, GET /api/v1/admin/settings, PATCH /api/v1/admin/settings (+4 more)
### Community 202 - "Community 202"
Cohesion: 0.15
@@ -1742,8 +1753,8 @@ Cohesion: 0.18
Nodes (11): GET /api/v1/doctors/{id}, GET /api/v1/doctors/{id}/insurances, GET /api/v1/representations/{id}, GET /oauth/userinfo — اطلاعات کاربر (سازگار با دروپال), POST /api/v1/representations/{id}/bank-accounts, POST /oauth/token — تجدید توکن (Refresh), POST /oauth/token — ورود به سیستم, Task-02: احراز هویت (Authentication) (+3 more)
### Community 256 - "Community 256"
Cohesion: 0.29
Nodes (3): FinancialBreakdown, Payment, User
Cohesion: 0.17
Nodes (11): `200 OK` (به‌روزرسانی شد), `200 OK` (رد به‌دلیل تصاحب‌شده), `201 Created` (ساخته شد), `422` (اعتبارسنجی), Doctor Import (IRIMC) API, idempotency, Request, Response (+3 more)
### Community 258 - "Community 258"
Cohesion: 0.15
@@ -1890,8 +1901,8 @@ Cohesion: 0.30
Nodes (6): AbstractAuthenticator, Passport, PasswordAuthenticator, Request, Response, TokenInterface
### Community 295 - "Community 295"
Cohesion: 0.20
Nodes (8): AbstractController, AdminController, HomeController, SeoController, Response, Response, Request, Response
Cohesion: 0.29
Nodes (5): AbstractController, AdminController, HomeController, Response, Response
### Community 296 - "Community 296"
Cohesion: 0.22
@@ -2275,7 +2286,7 @@ Nodes (7): initiate(), refund(), reverse(), verify(), PaymentInitResult, Payment
### Community 399 - "Community 399"
Cohesion: 0.23
Nodes (5): AbstractMigration, Schema, Version20260609130407, Schema, Version20260614183527
Nodes (5): AbstractMigration, Schema, Version20260609130407, Schema, Version20260628133044
### Community 401 - "Community 401"
Cohesion: 0.12
@@ -2286,8 +2297,8 @@ Cohesion: 0.11
Nodes (17): بازطراحی معماری پرداخت — سرویس‌محور، امن، توسعه‌پذیر (Backend), تست دستی (ddev، در حالت `payment_test_mode=1`), خروجی نهایی (طبق spec — در گزارش اجرا ارائه شود), زمینه, فایل‌های مرتبط, مشکل / هدف, نکات مهم, وضعیت فعلی (+9 more)
### Community 407 - "Community 407"
Cohesion: 0.27
Nodes (8): MessageBusInterface, MockObject, SmsServiceLookupOnlyTest, SmsLogRepository, SmsMessageTemplateRepository, SmsService, SmsTextResolver, KavehNegarProvider
Cohesion: 0.16
Nodes (11): MessageBusInterface, MockObject, SmsService, SmsServiceLookupOnlyTest, SmsLogRepository, SmsMessageTemplateRepository, SmsService, SmsTextResolver (+3 more)
### Community 414 - "Community 414"
Cohesion: 0.10
@@ -2318,8 +2329,8 @@ Cohesion: 0.33
Nodes (4): BlogController, JsonResponse, Request, User
### Community 439 - "Community 439"
Cohesion: 0.20
Nodes (4): HealthController, EntityManagerInterface, TenantInsuranceCleanupService, JsonResponse
Cohesion: 0.17
Nodes (11): زمینه, صفحه Twig دعوت پزشک + کوتاه‌کردن URL پیامک, فایل‌های مرتبط, مشکل / هدف, نکات مهم, وضعیت فعلی, وظایف, ۱. کوتاه‌کردن توکن دعوت (+3 more)
### Community 440 - "Community 440"
Cohesion: 0.11
@@ -2342,8 +2353,8 @@ Cohesion: 0.24
Nodes (10): gridItemStyle, JALALI_MONTHS, jalaliFirstWeekday(), jalaliToGregorian(), navBtnStyle, PersianCalendar(), pf, Props (+2 more)
### Community 459 - "Community 459"
Cohesion: 0.40
Nodes (5): API موجود (نیاز به تغییر ندارند), اپیک ۳ — منشی (Secretary) — تکمیل, تغییرات مورد نیاز, توضیح, نیازمندی‌های کارکردی
Cohesion: 0.33
Nodes (6): API موجود (نیاز به تغییر ندارند), اپیک‌ها, اپیک ۳ — منشی (Secretary) — تکمیل, تغییرات مورد نیاز, توضیح, نیازمندی‌های کارکردی
### Community 460 - "Community 460"
Cohesion: 0.33
@@ -2446,17 +2457,13 @@ Cohesion: 0.40
Nodes (5): Admin Endpoints, GET /api/v1/admin/sms/settings/review, GET /api/v1/admin/sms/wallet-report, POST /api/v1/admin/sms/settings/{id}/approve, POST /api/v1/admin/sms/settings/{id}/reject
### Community 490 - "Community 490"
Cohesion: 0.39
Nodes (3): TagRepository, ManagerRegistry, Tag
Cohesion: 0.36
Nodes (3): DoctorServiceController, JsonResponse, Request
### Community 491 - "Community 491"
Cohesion: 0.12
Nodes (15): `Modal.tsx` (بدون Portal), `PersianCalendar.tsx` (buttonها بدون `type`) — نمونه‌ها, باگ ۱ — علت, باگ ۲ — علت, رفع دو باگ Modal و تقویم شمسی در پنل ادمین, زمینه, فایل‌های مرتبط, مشکل / هدف (+7 more)
### Community 492 - "Community 492"
Cohesion: 0.18
Nodes (4): KavehNegarProvider, SmsService, SendSmsMessage, SmsProviderInterface
### Community 493 - "Community 493"
Cohesion: 0.20
Nodes (9): Backend Audit Backlog — ClinicPro, ☐ CRITICAL, ✅ DONE (committed on backend-audit), ☐ EPICS (design debt — cross-repo, defer; do NOT quick-fix), ☐ HIGH, ☐ LOW, ☐ MEDIUM, Progress (this audit session) (+1 more)
@@ -2482,12 +2489,12 @@ Cohesion: 0.18
Nodes (10): license, private, scripts, build, dev, dev-server, test, test:cov (+2 more)
### Community 499 - "Community 499"
Cohesion: 0.47
Nodes (3): SiteContextController, JsonResponse, Request
Cohesion: 0.36
Nodes (4): ClinicInvitationWebController, ClinicDoctorInvitation, Request, Response
### Community 500 - "Community 500"
Cohesion: 0.33
Nodes (6): API موجود (نیاز به endpoint جدید ندارد), اپیک‌ها, اپیک ۷ — داشبورد هوشمند (Smart Dashboard), تغییر مورد نیاز, توضیح, نیازمندی‌های کارکردی
Cohesion: 0.40
Nodes (5): API موجود (نیاز به endpoint جدید ندارد), اپیک ۷ — داشبورد هوشمند (Smart Dashboard), تغییر مورد نیاز, توضیح, نیازمندی‌های کارکردی
### Community 501 - "Community 501"
Cohesion: 0.40
@@ -2618,8 +2625,8 @@ Cohesion: 0.22
Nodes (3): AppException, SlotTakenException, RuntimeException
### Community 543 - "Community 543"
Cohesion: 0.47
Nodes (3): CommissionService, Payment, Representation
Cohesion: 0.21
Nodes (6): HealthController, EntityManagerInterface, CommissionService, Payment, Representation, JsonResponse
### Community 544 - "Community 544"
Cohesion: 0.15
@@ -2654,8 +2661,8 @@ Cohesion: 0.50
Nodes (4): Errors, GET `/api/v1/representation/appointments`, Query Parameters, Response `200`
### Community 552 - "Community 552"
Cohesion: 0.53
Nodes (3): PaymentLog, PaymentLogRepository, ManagerRegistry
Cohesion: 0.39
Nodes (3): ProvinceRepository, ManagerRegistry, Province
### Community 553 - "Community 553"
Cohesion: 0.50
@@ -2725,6 +2732,10 @@ Nodes (15): دیپلوی ClinicPro (Symfony) روی لیارا با Docker, زم
Cohesion: 0.40
Nodes (5): Errors, Notes, POST `/api/v1/notification-mobile/request-otp`, Request Body, Response `200`
### Community 576 - "Community 576"
Cohesion: 0.43
Nodes (3): CategoryImportController, JsonResponse, Request
### Community 577 - "Community 577"
Cohesion: 0.43
Nodes (3): SmsMessageController, JsonResponse, Request
@@ -2769,6 +2780,10 @@ Nodes (3): Entity: Payment, ساختار فایل‌ها, معماری — تس
Cohesion: 0.43
Nodes (3): SmsLogRepository, SmsLog, ManagerRegistry
### Community 596 - "Community 596"
Cohesion: 0.57
Nodes (3): SeoController, Request, Response
### Community 597 - "Community 597"
Cohesion: 0.29
Nodes (7): Configuration, Errors, GET `/api/v1/admin/sms/templates`, GET `/api/v1/sms/template/{uuid}`, Response `200`, Response `200`, SMS API
@@ -2797,10 +2812,6 @@ Nodes (5): RepresentationController, JsonResponse, Representation, Request, User
Cohesion: 0.67
Nodes (3): بک‌اند, فرانت‌اند, وضعیت فعلی کد (مهم — قبل از تغییر بخوان)
### Community 618 - "Community 618"
Cohesion: 0.16
Nodes (5): ServiceItemDeleteCleanupTest, ServiceCoverageNPlusOneTest, TenantServiceCoverageRepository, ManagerRegistry, TenantServiceCoverage
### Community 625 - "Community 625"
Cohesion: 0.40
Nodes (5): 31. 🟡 `PATCH` patch, Request Body, مثال Request, هدرهای اضافی, پاسخ‌ها
@@ -2810,8 +2821,8 @@ Cohesion: 0.23
Nodes (7): Money, BillingCalculator, InvoiceService, CoverageRule, ShareBreakdown, Invoice, PatientSession
### Community 631 - "Community 631"
Cohesion: 0.40
Nodes (5): Errors, GET `/api/v1/admin/clinic/{uuid}/invitations`, Path Parameters, Query Parameters, Response `200`
Cohesion: 0.48
Nodes (3): DoctorInsuranceRepository, DoctorInsurance, ManagerRegistry
### Community 632 - "Community 632"
Cohesion: 0.40
@@ -3109,22 +3120,38 @@ Nodes (3): 42. 🔴 `DELETE` delete, هدرهای اضافی, پاسخ‌ها
Cohesion: 0.67
Nodes (3): 43. 🟢 `GET` get, هدرهای اضافی, پاسخ‌ها
### Community 743 - "Community 743"
Cohesion: 0.53
Nodes (3): SessionServiceRepository, ManagerRegistry, SessionService
### Community 745 - "Community 745"
Cohesion: 0.40
Nodes (5): 61. 🔵 `POST` post, Request Body, مثال Request, هدرهای اضافی, پاسخ‌ها
### Community 746 - "Community 746"
Cohesion: 0.50
Nodes (4): GET `/api/v1/admin/pre-registrations`, POST `/api/v1/admin/pre-registrations/{uuid}/approve`, POST `/api/v1/admin/pre-registrations/{uuid}/reject`, Pre-Registration Management
### Community 747 - "Community 747"
Cohesion: 0.50
Nodes (4): extra, symfony, allow-contrib, require
## Knowledge Gaps
- **4067 isolated node(s):** `ALLOWED_ROLES`, `Pricing`, `ImageCropModalProps`, `TenantInsurance`, `CoverageRow` (+4062 more)
- **4087 isolated node(s):** `ALLOWED_ROLES`, `Pricing`, `ImageCropModalProps`, `TenantInsurance`, `CoverageRow` (+4082 more)
These have ≤1 connection - possible missing edges or undocumented components.
- **144 thin communities (<3 nodes) omitted from report** — run `graphify query` to explore isolated nodes.
- **146 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 `Altcha` connect `Community 367` to `Community 0`, `Community 485`?**
_High betweenness centrality (0.067) - this node is a cross-community bridge._
- **Why does `BaseController` connect `Community 108` to `Community 6`, `Community 7`, `Community 138`, `Community 139`, `Community 14`, `Community 654`, `Community 655`, `Community 15`, `Community 20`, `Community 22`, `Community 26`, `Community 164`, `Community 295`, `Community 424`, `Community 300`, `Community 433`, `Community 435`, `Community 308`, `Community 59`, `Community 318`, `Community 64`, `Community 577`, `Community 75`, `Community 77`, `Community 607`, `Community 480`, `Community 230`, `Community 104`, `Community 107`, `Community 109`, `Community 499`, `Community 380`, `Community 121`, `Community 122`, `Community 252`?**
_High betweenness centrality (0.046) - this node is a cross-community bridge._
- **Why does `ApiTestCase` connect `Community 86` to `Community 640`, `Community 397`, `Community 22`, `Community 535`, `Community 534`, `Community 541`, `Community 169`, `Community 562`, `Community 565`, `Community 439`, `Community 574`, `Community 575`, `Community 594`, `Community 82`, `Community 609`, `Community 484`, `Community 618`, `Community 497`, `Community 371`?**
_High betweenness centrality (0.027) - this node is a cross-community bridge._
_High betweenness centrality (0.070) - this node is a cross-community bridge._
- **Why does `BaseController` connect `Community 108` to `Community 6`, `Community 138`, `Community 139`, `Community 14`, `Community 654`, `Community 655`, `Community 15`, `Community 20`, `Community 22`, `Community 26`, `Community 164`, `Community 295`, `Community 424`, `Community 300`, `Community 433`, `Community 435`, `Community 308`, `Community 59`, `Community 318`, `Community 576`, `Community 577`, `Community 64`, `Community 75`, `Community 77`, `Community 607`, `Community 480`, `Community 230`, `Community 104`, `Community 490`, `Community 107`, `Community 109`, `Community 499`, `Community 380`, `Community 121`, `Community 122`, `Community 252`?**
_High betweenness centrality (0.044) - this node is a cross-community bridge._
- **Why does `ApiTestCase` connect `Community 86` to `Community 640`, `Community 397`, `Community 534`, `Community 535`, `Community 541`, `Community 543`, `Community 562`, `Community 565`, `Community 574`, `Community 575`, `Community 594`, `Community 82`, `Community 609`, `Community 484`, `Community 744`, `Community 618`, `Community 748`, `Community 497`, `Community 371`?**
_High betweenness centrality (0.030) - this node is a cross-community bridge._
- **What connects `ALLOWED_ROLES`, `Pricing`, `ImageCropModalProps` to the rest of the system?**
_4067 weakly-connected nodes found - possible documentation gaps or missing edges._
_4087 weakly-connected nodes found - possible documentation gaps or missing edges._
- **Should `Community 0` be split into smaller, more focused modules?**
_Cohesion score 0.048087431693989074 - nodes in this community are weakly interconnected._
- **Should `Community 1` be split into smaller, more focused modules?**
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
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
+1392 -581
View File
File diff suppressed because it is too large Load Diff
+23 -8
View File
@@ -1095,8 +1095,8 @@
"semantic_hash": ""
},
"src/ClinicInvitation/Entity/ClinicDoctorInvitation.php": {
"mtime": 1783747224.0887392,
"ast_hash": "3a35b6a87bac457ae3e399004e248956",
"mtime": 1783748111.7775414,
"ast_hash": "b161ff311395c06ba7dd27b3ba8b5538",
"semantic_hash": ""
},
"src/ClinicInvitation/Repository/ClinicDoctorInvitationRepository.php": {
@@ -1105,8 +1105,8 @@
"semantic_hash": ""
},
"src/ClinicInvitation/Service/ClinicInvitationService.php": {
"mtime": 1783237201.6128454,
"ast_hash": "3e70bceb24b5bbefbf9a06013ce64cf3",
"mtime": 1783748222.119808,
"ast_hash": "d5f3f6d411ad16516444aab0505f5038",
"semantic_hash": ""
},
"src/ClinicService/Controller/ClinicServiceController.php": {
@@ -2300,8 +2300,8 @@
"semantic_hash": ""
},
"config/services.yaml": {
"mtime": 1783670869.0603309,
"ast_hash": "4446e5510d383465e1d3b4bbaaf5eb8c",
"mtime": 1783748202.290563,
"ast_hash": "cb30b75b2aa9eb8bd44ee757491d35d9",
"semantic_hash": ""
},
"docs/Architecture_Audit.md": {
@@ -2370,8 +2370,8 @@
"semantic_hash": ""
},
"docs/api/clinic-invitation.md": {
"mtime": 1781360269.6587257,
"ast_hash": "795a1bbbfdc94fd633859cd624aa8b4c",
"mtime": 1783748636.4692373,
"ast_hash": "738e66fe7f98cf1e81ee1191f35f5f67",
"semantic_hash": ""
},
"docs/api/clinic-services.md": {
@@ -3868,5 +3868,20 @@
"mtime": 1783692850.4494128,
"ast_hash": "94803933a144c487e107395efcf6d764",
"semantic_hash": ""
},
"src/ClinicInvitation/Controller/ClinicInvitationWebController.php": {
"mtime": 1783748162.455786,
"ast_hash": "9ce6fa5810f45daefdb68716aa985beb",
"semantic_hash": ""
},
".claude/prompt/clinic-invitation-twig-page.md": {
"mtime": 1783747981.2542648,
"ast_hash": "393cb8566381485ff03063f514e1bc8b",
"semantic_hash": ""
},
"docs/api/doctor-import.md": {
"mtime": 1783747979.9276593,
"ast_hash": "0a30a6d1d4fd4f2584d6315ccf94ef35",
"semantic_hash": ""
}
}
@@ -0,0 +1,96 @@
<?php
namespace App\ClinicInvitation\Controller;
use App\ClinicInvitation\Entity\ClinicDoctorInvitation;
use App\ClinicInvitation\Repository\ClinicDoctorInvitationRepository;
use App\ClinicInvitation\Service\ClinicInvitationService;
use App\Shared\Controller\BaseController;
use App\Shared\Exception\AppException;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
/**
* صفحات HTML عمومی دعوت پزشک لینک پیامک اینجا باز می‌شود (بدون JWT).
* جدا از ClinicInvitationController که نسخهٔ JSON (/api/v1/...) را برای کلاینت React/اپ سِرو می‌کند.
*/
class ClinicInvitationWebController extends BaseController
{
public function __construct(
private readonly ClinicDoctorInvitationRepository $invRepo,
private readonly ClinicInvitationService $invitationService,
private readonly string $appUrl,
) {}
#[Route('/clinic-invitation/{token}', methods: ['GET'], name: 'invitation_web_view')]
#[Route('/i/{token}', methods: ['GET'], name: 'invitation_web_view_short')]
public function view(string $token): Response
{
$inv = $this->invRepo->findByToken($token);
if ($inv === null) {
return $this->render('invitation/result.html.twig', ['state' => 'notfound'])->setStatusCode(404);
}
if (!$inv->isUsable()) {
return $this->render('invitation/result.html.twig', [
'state' => $this->stateFromStatus($inv),
'admin_url' => $this->adminUrl(),
]);
}
return $this->render('invitation/view.html.twig', [
'token' => $token,
'clinic' => $inv->getClinic()->getName() ?: 'کلینیک',
'doctor' => $inv->getInvitedName(),
'expires_at' => $inv->getExpiresAt(),
]);
}
#[Route('/clinic-invitation/{token}/respond', methods: ['POST'], name: 'invitation_web_respond')]
public function respond(string $token, Request $request): Response
{
$inv = $this->invRepo->findByToken($token);
if ($inv === null) {
return $this->render('invitation/result.html.twig', ['state' => 'notfound'])->setStatusCode(404);
}
if (!$this->isCsrfTokenValid('invitation_' . $token, (string) $request->request->get('_token'))) {
return $this->render('invitation/result.html.twig', ['state' => 'expired'])->setStatusCode(403);
}
$action = (string) $request->request->get('action', '');
try {
if ($action === 'accept') {
$this->invitationService->accept($inv);
return $this->render('invitation/result.html.twig', [
'state' => 'accepted',
'admin_url' => $this->adminUrl(),
]);
}
if ($action === 'reject') {
$this->invitationService->reject($inv);
return $this->render('invitation/result.html.twig', ['state' => 'rejected']);
}
} catch (AppException) {
return $this->render('invitation/result.html.twig', ['state' => 'expired']);
}
return $this->render('invitation/result.html.twig', ['state' => 'expired'])->setStatusCode(422);
}
private function stateFromStatus(ClinicDoctorInvitation $inv): string
{
return match ($inv->getStatus()) {
ClinicDoctorInvitation::STATUS_ACCEPTED => 'accepted',
ClinicDoctorInvitation::STATUS_REJECTED => 'rejected',
default => 'expired',
};
}
private function adminUrl(): string
{
return rtrim($this->appUrl, '/') . '/admin';
}
}
@@ -74,7 +74,7 @@ class ClinicDoctorInvitation
$this->clinic = $clinic;
$this->invitedBy = $invitedBy;
$this->mobile = $mobile;
$this->token = bin2hex(random_bytes(16));
$this->token = bin2hex(random_bytes(6));
$this->invitedAt = time();
$this->expiresAt = $this->invitedAt + 72 * 3600;
}
@@ -94,7 +94,7 @@ class ClinicDoctorInvitation
public function refresh(): void
{
$this->token = bin2hex(random_bytes(16));
$this->token = bin2hex(random_bytes(6));
$this->tokenUsed = false;
$this->invitedAt = time();
$this->expiresAt = $this->invitedAt + 72 * 3600;
@@ -110,7 +110,7 @@ class ClinicInvitationService
private function sendSms(ClinicDoctorInvitation $inv, Clinic $clinic): void
{
$clinicName = $clinic->getName() ?? 'کلینیک';
$link = rtrim($this->appUrl, '/') . '/clinic-invitation/' . $inv->getToken();
$link = rtrim($this->appUrl, '/') . '/i/' . $inv->getToken();
$this->smsService->dispatchTemplate(\App\Sms\Entity\SmsLog::TAG_CLINIC_INVITATION, $inv->getMobile(), [
'clinic' => $clinicName,
+109
View File
@@ -0,0 +1,109 @@
<!DOCTYPE html>
<html lang="fa" dir="rtl">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex,nofollow">
<title>دعوت همکاری کلینیک</title>
<link rel="preconnect" href="https://cdn.jsdelivr.net" crossorigin>
<style>
@font-face {
font-family: 'Vazirmatn';
src: url('https://cdn.jsdelivr.net/gh/rastikerdar/vazirmatn@v33.003/fonts/webfonts/Vazirmatn-Regular.woff2') format('woff2');
font-weight: 400; font-display: swap;
}
@font-face {
font-family: 'Vazirmatn';
src: url('https://cdn.jsdelivr.net/gh/rastikerdar/vazirmatn@v33.003/fonts/webfonts/Vazirmatn-Bold.woff2') format('woff2');
font-weight: 700; font-display: swap;
}
:root {
--ok:#15a35a; --ok-soft:#e6f6ed;
--err:#e0394a; --err-soft:#fdebed;
--warn:#d98a09; --warn-soft:#fcf2df;
--ink:#0f1b2e; --muted:#56657c; --line:#e4e9f1;
--primary:#5457dd; --primary-600:#464ac9;
--shadow-lg:0 12px 32px rgba(15,27,46,.12), 0 4px 10px rgba(15,27,46,.06);
--ease:cubic-bezier(.22,.61,.36,1);
}
* { box-sizing: border-box; }
html, body { height: 100%; }
body {
margin:0; font-family:'Vazirmatn', ui-sans-serif, system-ui, sans-serif; color:var(--ink);
background: radial-gradient(1200px 600px at 50% -10%, #e9eefb 0%, #eef2f8 45%, #e6ecf4 100%);
min-height:100vh; display:flex; align-items:center; justify-content:center; padding:20px;
}
.card {
position:relative; overflow:hidden; background:#fff; border:1px solid var(--line);
border-radius:24px; box-shadow:var(--shadow-lg); width:100%; max-width:420px;
padding:40px 28px 30px; text-align:center; animation:rise .45s var(--ease) both;
}
@keyframes rise { from { opacity:0; transform:translateY(14px) scale(.98); } }
.accent { position:absolute; inset:0 0 auto 0; height:6px; }
.accent.ok { background:linear-gradient(90deg,#15a35a,#3fca7d); }
.accent.err { background:linear-gradient(90deg,#e0394a,#f07a86); }
.accent.warn { background:linear-gradient(90deg,#d98a09,#f2b545); }
.badge { width:96px; height:96px; margin:6px auto 20px; position:relative; }
.badge .ring { position:absolute; inset:0; border-radius:50%; animation:pop .5s cubic-bezier(.2,.8,.2,1.2) .05s both; }
.badge.ok .ring { background:var(--ok-soft); }
.badge.err .ring { background:var(--err-soft); }
.badge.warn .ring { background:var(--warn-soft); }
.badge svg { position:absolute; inset:0; width:96px; height:96px; }
@keyframes pop { from { transform:scale(.4); opacity:0; } }
.draw { fill:none; stroke-width:6; stroke-linecap:round; stroke-linejoin:round;
stroke-dasharray:80; stroke-dashoffset:80; animation:draw .6s ease .35s forwards; }
.badge.ok .draw { stroke:var(--ok); }
.badge.err .draw { stroke:var(--err); }
.badge.warn .draw { stroke:var(--warn); }
@keyframes draw { to { stroke-dashoffset:0; } }
h1 { font-size:21px; font-weight:700; margin:0 0 8px; }
p.msg { color:var(--muted); font-size:14px; margin:0 auto 22px; line-height:2; max-width:320px; }
.btn { display:block; width:100%; padding:13px; border-radius:12px; text-decoration:none;
font-weight:700; font-size:15px; background:var(--primary); color:#fff; border:none; cursor:pointer;
transition:filter .15s, transform .05s; }
.btn:hover { background:var(--primary-600); }
.btn:active { transform:translateY(1px); }
.hint { color:#9aa1ad; font-size:12.5px; margin:6px 0 0; }
.brand { margin-top:20px; font-size:11.5px; color:#aeb4c0; letter-spacing:.2px; }
</style>
</head>
<body>
{% set kind = state == 'accepted' ? 'ok' : (state == 'rejected' ? 'warn' : 'err') %}
<div class="card">
<div class="accent {{ kind }}"></div>
<div class="badge {{ kind }}">
<div class="ring"></div>
<svg viewBox="0 0 96 96" aria-hidden="true">
{% if state == 'accepted' %}
<path class="draw" d="M30 50 L44 63 L68 35"/>
{% elseif state == 'rejected' %}
<path class="draw" d="M48 30 L48 50 L62 58"/>
{% else %}
<path class="draw" d="M36 36 L60 60"/>
<path class="draw" d="M60 36 L36 60"/>
{% endif %}
</svg>
</div>
{% if state == 'accepted' %}
<h1>درخواست شما تایید شد</h1>
<p class="msg">می‌توانید وارد پنل ادمین شوید.</p>
<a class="btn" href="{{ admin_url }}">ورود به پنل ادمین</a>
{% elseif state == 'rejected' %}
<h1>دعوت رد شد</h1>
<p class="msg">درخواست همکاری این کلینیک رد شد.</p>
{% elseif state == 'notfound' %}
<h1>دعوتنامه یافت نشد</h1>
<p class="msg">این لینک معتبر نیست.</p>
{% else %}
<h1>دعوت منقضی شده است</h1>
<p class="msg">این دعوت منقضی شده یا قبلاً پاسخ داده شده است.</p>
{% endif %}
<div class="brand">کلینیک پرو</div>
</div>
</body>
</html>
+79
View File
@@ -0,0 +1,79 @@
<!DOCTYPE html>
<html lang="fa" dir="rtl">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex,nofollow">
<title>دعوت همکاری کلینیک</title>
<link rel="preconnect" href="https://cdn.jsdelivr.net" crossorigin>
<style>
@font-face {
font-family: 'Vazirmatn';
src: url('https://cdn.jsdelivr.net/gh/rastikerdar/vazirmatn@v33.003/fonts/webfonts/Vazirmatn-Regular.woff2') format('woff2');
font-weight: 400; font-display: swap;
}
@font-face {
font-family: 'Vazirmatn';
src: url('https://cdn.jsdelivr.net/gh/rastikerdar/vazirmatn@v33.003/fonts/webfonts/Vazirmatn-Bold.woff2') format('woff2');
font-weight: 700; font-display: swap;
}
:root {
--ok:#15a35a; --ok-600:#12904f;
--err:#e0394a; --err-600:#c62f3f;
--ink:#0f1b2e; --muted:#56657c; --line:#e4e9f1;
--primary:#5457dd;
--shadow-lg:0 12px 32px rgba(15,27,46,.12), 0 4px 10px rgba(15,27,46,.06);
--ease:cubic-bezier(.22,.61,.36,1);
}
* { box-sizing: border-box; }
html, body { height: 100%; }
body {
margin:0; font-family:'Vazirmatn', ui-sans-serif, system-ui, sans-serif; color:var(--ink);
background: radial-gradient(1200px 600px at 50% -10%, #e9eefb 0%, #eef2f8 45%, #e6ecf4 100%);
min-height:100vh; display:flex; align-items:center; justify-content:center; padding:20px;
}
.card {
position:relative; overflow:hidden; background:#fff; border:1px solid var(--line);
border-radius:24px; box-shadow:var(--shadow-lg); width:100%; max-width:420px;
padding:40px 28px 30px; text-align:center; animation:rise .45s var(--ease) both;
}
@keyframes rise { from { opacity:0; transform:translateY(14px) scale(.98); } }
.accent { position:absolute; inset:0 0 auto 0; height:6px; background:linear-gradient(90deg,#5457dd,#8a8cf0); }
.logo { width:70px; height:70px; margin:6px auto 20px; border-radius:20px; background:#eef0fe;
display:flex; align-items:center; justify-content:center; font-size:34px; }
h1 { font-size:21px; font-weight:700; margin:0 0 12px; }
p.msg { color:var(--muted); font-size:14.5px; margin:0 auto 10px; line-height:2.1; max-width:330px; }
p.msg b { color:var(--ink); }
.hint { color:#9aa1ad; font-size:12.5px; margin:4px 0 24px; }
.btn { display:block; width:100%; padding:13px; border-radius:12px; text-decoration:none;
font-weight:700; font-size:15px; border:none; cursor:pointer; color:#fff;
transition:filter .15s, transform .05s; }
.btn + .btn { margin-top:12px; }
.btn:active { transform:translateY(1px); }
.btn-ok { background:var(--ok); }
.btn-ok:hover { background:var(--ok-600); }
.btn-err { background:#fff; color:var(--err); border:1px solid var(--err); }
.btn-err:hover { background:#fdebed; }
.brand { margin-top:20px; font-size:11.5px; color:#aeb4c0; letter-spacing:.2px; }
</style>
</head>
<body>
<div class="card">
<div class="accent"></div>
<div class="logo">🩺</div>
<h1>دعوت به همکاری</h1>
{% if doctor %}<p class="msg">{{ doctor }} عزیز،</p>{% endif %}
<p class="msg">کلینیک <b>{{ clinic }}</b> شما را برای همکاری دعوت کرده است.</p>
<p class="hint">این دعوت تا ۷۲ ساعت معتبر است.</p>
<form method="post" action="{{ path('invitation_web_respond', {token: token}) }}">
<input type="hidden" name="_token" value="{{ csrf_token('invitation_' ~ token) }}">
<button type="submit" name="action" value="accept" class="btn btn-ok">تایید و پذیرش دعوت</button>
<button type="submit" name="action" value="reject" class="btn btn-err">رد دعوت</button>
</form>
<div class="brand">کلینیک پرو</div>
</div>
</body>
</html>