Files
clinicpro/docs/api/clinic-invitation.md
T
hamedandClaude Opus 4.8 3a23aa242e 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>
2026-07-18 09:14:13 +03:30

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": "کلینیک الوند" }
  }
}

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

{
  "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 already accepted or rejected cannot be returned to pending.

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 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

{
  "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

{
  "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

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 invitation200, 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 / used200, result.html.twig in the matching state.
  • Token not found404, 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.

ddev exec php bin/console app:invitations:repair --dry-run   # report only
ddev exec php bin/console app:invitations:repair             # apply