# Clinic Doctor Invitation API > **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 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). ### Account provisioning The invitation flow creates the invitee's account for them — no prior registration is required: | Stage | `users` row | `doctors` row | Password | `clinic_doctors` link | |---|---|---|---|---| | **Invite** | created if missing, gains `ROLE_DOCTOR` | created if missing, `owner_status = unclaimed` | not set | ❌ | | **Accept** | reused | `owner_status → claimed`, `claimed_at` set | generated **only if the user has none**, then SMS'd | ✅ | Accept runs inside a single transaction, so an invitation is never marked `accepted` without its doctor profile and clinic link. An existing user's password is **never** overwritten — someone who already has an account simply gets linked to the clinic. Credentials are sent with the `pre_registration` SMS template: `به کلینیک پرو خوش آمدید! شماره‌کاربری: {username} | رمز عبور: {password} | لینک ورود: {link}`. --- ## POST `/api/v1/admin/clinic/{uuid}/invite-doctor` Send an invitation to a doctor (by mobile number) to join a clinic. **Permission:** `ROLE_ADMIN` ### Path Parameters | Param | Type | Description | |-------|------|-------------| | `uuid` | string (UUID) | Clinic UUID | ### Request Body (`application/json`) ```json { "mobile": "09123456789", "name": "علی احمدی", "specialty": "قلب و عروق" } ``` | Field | Type | Required | Validation | |-------|------|----------|------------| | `mobile` | string | ✅ | Format: `09XXXXXXXXX` | | `name` | string | ❌ | Doctor's display name | | `specialty` | string | ❌ | Specialty label for SMS | ### Response `201` ```json { "success": true, "data": { "uuid": "inv-uuid-...", "mobile": "09123456789", "invited_name": "دکتر علی احمدی", "invited_specialty": "قلب و عروق", "status": "pending", "invited_at": 1717000000, "expires_at": 1717259200, "token_used": false, "doctor": { "uuid": "...", "name": "علی احمدی" }, "clinic": { "uuid": "...", "name": "کلینیک الوند" } } } ``` > `doctor` is always populated — the profile is provisioned at invite time (see **Account provisioning**), so the invitee is visible in the panel before they respond. > 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 | |------|------|-------------| | `ERR_AUTH_001` | 401 | Missing or invalid token | | `ERR_AUTH_006` | 403 | Not admin | | `ERR_NOT_FOUND_001` | 404 | Clinic not found | | `ERR_VALIDATION_001` | 422 | Invalid mobile format | --- ## GET `/api/v1/admin/clinic/{uuid}/invitations` List all invitations for a clinic. **Permission:** `ROLE_ADMIN` ### Path Parameters | Param | Type | Description | |-------|------|-------------| | `uuid` | string (UUID) | Clinic UUID | ### Query Parameters | Param | Type | Required | Default | |-------|------|----------|---------| | `page` | integer | ❌ | 1 | | `limit` | integer | ❌ | 50 | ### Response `200` ```json { "success": true, "data": [ { "uuid": "...", "mobile": "09123456789", "invited_name": "دکتر علی احمدی", "invited_specialty": "قلب و عروق", "status": "pending", "invited_at": 1717000000, "expires_at": 1717259200, "responded_at": null, "token_used": false, "doctor": null } ], "meta": { "totalRecords": 5, "totalPages": 1, "currentPage": 1 } } ``` **Invitation Status Values:** | Value | Description | |-------|-------------| | `pending` | Sent, awaiting response | | `accepted` | Doctor accepted | | `rejected` | Doctor rejected | | `suspended` | Suspended by admin | | `removed` | Removed | ### Errors | Code | HTTP | Description | |------|------|-------------| | `ERR_AUTH_001` | 401 | Missing token | | `ERR_AUTH_006` | 403 | Not admin | | `ERR_NOT_FOUND_001` | 404 | Clinic not found | --- ## POST `/api/v1/admin/clinic/invitation/{invUuid}/resend` Resend the invitation SMS with a fresh token and reset expiry to +72 hours. **Permission:** `ROLE_ADMIN` ### Path Parameters | Param | Type | Description | |-------|------|-------------| | `invUuid` | string (UUID) | Invitation UUID | ### Response `200` ```json { "success": true, "data": { "message": "دعوتنامه مجدداً ارسال شد" } } ``` ### Errors | Code | HTTP | Description | |------|------|-------------| | `ERR_AUTH_001` | 401 | Missing token | | `ERR_AUTH_006` | 403 | Not admin | | `ERR_NOT_FOUND_001` | 404 | Invitation not found | --- ## PATCH `/api/v1/admin/clinic/invitation/{invUuid}/status` Change the status of an invitation (e.g., suspend or remove). **Permission:** `ROLE_ADMIN` ### Path Parameters | Param | Type | Description | |-------|------|-------------| | `invUuid` | string (UUID) | Invitation UUID | ### Request Body ```json { "status": "suspended" } ``` | Field | Type | Required | Allowed Values | |-------|------|----------|----------------| | `status` | string | ✅ | `pending`, `suspended`, `removed` | > Setting `status: "pending"` **reactivates** a suspended invitation: the token is refreshed (the old link stops working) and the invitation SMS is re-sent, so the doctor gets a link that actually works. An invitation that was already `accepted` or `rejected` cannot be returned to `pending`. ### Response `200` ```json { "success": true, "data": { "status": "suspended" } } ``` ### Errors | Code | HTTP | Description | |------|------|-------------| | `ERR_AUTH_001` | 401 | Missing token | | `ERR_AUTH_006` | 403 | Not admin | | `ERR_NOT_FOUND_001` | 404 | Invitation not found | | `ERR_CONFLICT_001` | 409 | Cannot return an already-answered invitation to `pending` | | `ERR_VALIDATION_001` | 422 | Invalid status value | --- ## DELETE `/api/v1/admin/clinic/invitation/{invUuid}` Delete an invitation. **Permission:** `ROLE_ADMIN` ### Path Parameters | Param | Type | Description | |-------|------|-------------| | `invUuid` | string (UUID) | Invitation UUID | ### Response `200` ```json { "success": true, "data": { "message": "دعوتنامه حذف شد" } } ``` > Hard delete. Returns `200` with the standard envelope rather than a bodyless `204`, so clients can parse every successful response the same way. ### Errors | Code | HTTP | Description | |------|------|-------------| | `ERR_AUTH_001` | 401 | Missing token | | `ERR_AUTH_006` | 403 | Not admin | | `ERR_NOT_FOUND_001` | 404 | Invitation not found | --- ## GET `/api/v1/clinic-invitation/{token}` View invitation details by token (used on the doctor-facing landing page). **Permission:** `PUBLIC` ### Path Parameters | Param | Type | Description | |-------|------|-------------| | `token` | string | 96-char hex token from SMS link | ### Response `200` ```json { "success": true, "data": { "invitation": { "uuid": "...", "mobile": "09123456789", "invited_name": "دکتر علی احمدی", "invited_specialty": "قلب و عروق", "status": "pending", "expires_at": 1717259200 }, "clinic": { "uuid": "...", "name": "کلینیک الوند", "city": "تهران", "clinic_logo": "https://..." }, "is_usable": true } } ``` > `is_usable: false` when: token already used, expired, or status is not `pending` ### Errors | Code | HTTP | Description | |------|------|-------------| | `ERR_NOT_FOUND_001` | 404 | Token not found | --- ## POST `/api/v1/clinic-invitation/{token}/accept` Doctor accepts the invitation via SMS link. **Side-effects (single transaction):** resolves — or creates — the `users` + `doctors` pair for the invited mobile, marks the profile `claimed`, adds it to `clinic_doctors`, and sets the invitation to `accepted`. If the user had no password, one is generated and SMS'd so they can log in immediately. See **Account provisioning** at the top. **Permission:** `PUBLIC` ### Path Parameters | Param | Type | Description | |-------|------|-------------| | `token` | string | 12-char hex token | ### Response `200` ```json { "success": true, "data": { "message": "دعوتنامه پذیرفته شد" } } ``` ### Errors | Code | HTTP | Description | |------|------|-------------| | `ERR_NOT_FOUND_001` | 404 | Token not found | | `ERR_NOT_FOUND_001` | 410 | Token expired or already used | --- ## POST `/api/v1/clinic-invitation/{token}/reject` Doctor rejects the invitation. **Permission:** `PUBLIC` ### Path Parameters | Param | Type | Description | |-------|------|-------------| | `token` | string | 96-char hex token | ### Response `200` ```json { "success": true, "data": { "message": "دعوتنامه رد شد" } } ``` ### Errors | Code | HTTP | Description | |------|------|-------------| | `ERR_NOT_FOUND_001` | 404 | Token not found | | `ERR_NOT_FOUND_001` | 410 | Token expired or already used | --- ## GET `/api/v1/doctor/invitations` Returns all pending invitations for the authenticated doctor. **Permission:** `ROLE_DOCTOR` ### Response `200` ```json { "success": true, "data": [ { "uuid": "...", "mobile": "09xxxxxxxxx", "invited_name": "دکتر علی", "invited_specialty": "قلب", "status": "pending", "token_used": false, "invited_at": 1718000000, "expires_at": 1718259200, "responded_at": null, "doctor": { "uuid": "...", "name": "علی احمدی" }, "clinic": { "uuid": "...", "name": "کلینیک نور", "logo": null } } ] } ``` ### Errors | Code | HTTP | Description | |------|------|-------------| | `ERR_NOT_FOUND_001` | 404 | Doctor profile not found for user | --- ## POST `/api/v1/doctor/invitation/{invUuid}/respond` Doctor accepts or rejects an invitation from their panel (no SMS token needed). **Permission:** `ROLE_DOCTOR` **Side-effect on accept:** identical to the public accept endpoint — the doctor profile is claimed and linked to `clinic_doctors` in one transaction. ### Path Parameters | Param | Type | Description | |-------|------|-------------| | `invUuid` | string | UUID of the invitation | ### Request Body ```json { "action": "accept" } ``` | Field | Type | Values | |-------|------|--------| | `action` | string | `accept` \| `reject` | ### Response `200` ```json { "success": true, "data": { "message": "دعوتنامه پذیرفته شد" } } ``` ### Errors | Code | HTTP | Description | |------|------|-------------| | `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` always ends with a usable account: the `users` + `doctors` pair is created when missing, the doctor is linked to the clinic, and login credentials are SMS'd if the user had no password (see **Account provisioning**). --- ## Console: `app:invitations:repair` Repairs invitations left `accepted` with a null `doctor_id` by the pre-fix `accept()` — creates the missing user/doctor and links them to the clinic without touching the invitation's status. ```bash ddev exec php bin/console app:invitations:repair --dry-run # report only ddev exec php bin/console app:invitations:repair # apply ```