# 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).

---

## 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,
    "clinic": { "uuid": "...", "name": "کلینیک الوند" }
  }
}
```

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

### 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_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 `204`
Empty body.

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

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

---

## 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:** Doctor is added to `clinic_doctors`. If doctor FK was null at invite time, it is resolved via mobile number at respond time.

### 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` 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.
