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
+40 -3
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).
---
@@ -51,7 +51,8 @@ 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این لینک تا ۷۲ ساعت معتبر است."`
> 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.