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>
462 lines
14 KiB
Markdown
462 lines
14 KiB
Markdown
# 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
|
|
```
|