# صفحه 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>
  ```
