- Removed the "دکتر" prefix from doctor names in various components and API responses to ensure consistency and clarity. - Updated the AppointmentDetailPage, CommentsPage, DashboardPage, RatingsPage, SecretariesPage, and other relevant files to reflect the changes in doctor name formatting. - Adjusted API documentation to align with the new naming conventions. - Implemented validation to prevent the creation of clinics without a name and restricted users to a single clinic. - Added tests to verify that doctor names are stored without titles and that clinic creation adheres to the new validation rules.
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
|
|
```
|