fix(clinic-invitation): provision doctor accounts and repair panel actions

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>
This commit is contained in:
hamed
2026-07-18 09:14:13 +03:30
co-authored by Claude Opus 4.8
parent 1779e0d6de
commit 3a23aa242e
9 changed files with 689 additions and 33 deletions
+43 -6
View File
@@ -4,6 +4,19 @@
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`
@@ -45,11 +58,14 @@ Send an invitation to a doctor (by mobile number) to join a clinic.
"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`).
@@ -175,6 +191,8 @@ Change the status of an invitation (e.g., suspend or remove).
|-------|------|----------|----------------|
| `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
{
@@ -189,6 +207,7 @@ Change the status of an invitation (e.g., suspend or remove).
| `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 |
---
@@ -204,8 +223,15 @@ Delete an invitation.
|-------|------|-------------|
| `invUuid` | string (UUID) | Invitation UUID |
### Response `204`
Empty body.
### 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 |
@@ -264,14 +290,14 @@ View invitation details by token (used on the doctor-facing landing page).
Doctor accepts the invitation via SMS link.
**Side-effect:** If a doctor profile exists for this mobile, they are added to `clinic_doctors`. If the invitation's doctor FK was null (doctor registered after invite), the match is resolved at accept time using the mobile number.
**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 | 96-char hex token |
| `token` | string | 12-char hex token |
### Response `200`
```json
@@ -357,7 +383,7 @@ Doctor accepts or rejects an invitation from their panel (no SMS token needed).
**Permission:** `ROLE_DOCTOR`
**Side-effect on accept:** Doctor is added to `clinic_doctors`. If doctor FK was null at invite time, it is resolved via mobile number at respond time.
**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 |
@@ -421,4 +447,15 @@ Processes the doctor's choice. **POST only** — accept/reject never happens on
| 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.
> `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
```