The invitation flow never created an account for the invitee. accept() only looked up an existing doctor by mobile, so for a brand-new invitee it marked the invitation accepted and burned the token while leaving doctor_id NULL — no login, no clinic link, and every doctor-facing endpoint 404ing afterwards. - invite/accept now provision the users + doctors pair, claim the profile on accept, link it to the clinic, and SMS generated credentials when the user has no password. Existing passwords are never overwritten. - accept runs in one transaction so an invitation can no longer be marked accepted without its doctor profile and clinic link. - changeStatus accepts `pending`, refreshing the token and re-sending the SMS so reactivating a suspended invitation yields a link that actually works. Answered invitations are rejected with 409. - DELETE returns 200 with the standard envelope instead of a bodyless 204, which made the admin panel show a false error toast; api.ts also stops calling res.json() on empty responses. - The clinic-doctors settings page sent the active context uuid as the clinic uuid, so users holding both a doctor and a clinic context got 404 on every invitation action. It now always resolves the clinic context. - Adds app:invitations:repair to fix invitations already left orphaned. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
14 KiB
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)
{
"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
{
"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": "کلینیک الوند" }
}
}
doctoris 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 Kavenegar431).
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
{
"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
{
"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
{
"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 alreadyacceptedorrejectedcannot be returned topending.
Response 200
{
"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
{
"success": true,
"data": { "message": "دعوتنامه حذف شد" }
}
Hard delete. Returns
200with the standard envelope rather than a bodyless204, 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
{
"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: falsewhen: token already used, expired, or status is notpending
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
{
"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
{
"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
{
"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
{ "action": "accept" }
| Field | Type | Values |
|---|---|---|
action |
string | accept | reject |
Response 200
{
"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.twigin the matching state. - Token not found →
404,result.html.twigstatenotfound.
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 |
acceptalways ends with a usable account: theusers+doctorspair 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.
ddev exec php bin/console app:invitations:repair --dry-run # report only
ddev exec php bin/console app:invitations:repair # apply