Files
clinicpro/.claude/prompt/clinic-invitation-twig-page.md
hamed 744a40c0f6 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.
2026-07-11 09:33:55 +03:30

17 KiB
Raw Permalink Blame History

صفحه 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
docs/api/clinic-invitation.md باید route‌های وب جدید مستند شوند

وضعیت فعلی

توکن (خط ۷۷ و ۹۷ در Entity):

$this->token = bin2hex(random_bytes(16));   // ۳۲ کاراکتر

ساخت لینک پیامک (ClinicInvitationService.php:113):

$link = rtrim($this->appUrl, '/') . '/clinic-invitation/' . $inv->getToken();

سرویس accept (ClinicInvitationService.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/اپ از آن‌ها استفاده می‌کند):

#[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):

return $this->render('payment/redirect.html.twig', ['action' => $action, 'params' => $params]);

وظایف

۱. کوتاه‌کردن توکن دعوت

در ClinicDoctorInvitation.php خط ۷۷ (constructor) و ۹۷ (refresh()) توکن را کوتاه‌تر کن. یک توکن تک‌مصرفِ ۷۲ ساعته نیازی به ۳۲ کاراکتر ندارد — ۱۲ کاراکتر hex (۴۸ بیت آنتروپی) کافی و امن است:

$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 پرداخت):

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 کوتاه اضافه کردی:

$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"):
<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 «دعوتنامه یافت نشد.» بدون دکمه

مثال بلوک:

{% 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.
  • تست دستی:
    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>