# Authentication API

> **Prefix:** `/api/v1/user` and `/oauth`  
> **Permission:** All endpoints in this module are **PUBLIC** (no JWT required) except `userinfo` and `logout`

> **🛡️ ALTCHA captcha:** وقتی `ALTCHA_ENABLED=true` است (در prod)، endpointهای `send-code`، `register`، `otp-login` و `reset-password` علاوه بر بدنه‌ی خود، فیلد `altcha` (payload حل‌شده‌ی widget) را الزامی می‌کنند؛ در غیر این صورت `422` با کد `ERR_CAPTCHA_001` برمی‌گردد. جزئیات و مسیر challenge در [captcha.md](captcha.md).

---

## POST `/api/v1/user/send-code`

Send OTP code to mobile number.

**Permission:** `PUBLIC`

### Request Body
```json
{
  "mobile": "09123456789",
  "domain": "yasuj-nobat.ir"
}
```

| Field | Type | Required | Validation |
|-------|------|----------|------------|
| `mobile` | string | ✅ | Format: `09XXXXXXXXX` (11 digits) |
| `domain` | string | ⬜ | دامنه‌ی شهرِ درخواست‌کننده (multi-domain). اگر ارسال شود، `site_name` شهرِ متناظر در جدول `cities` پیدا شده و به‌عنوان متغیر `{site}` در متن پیامک OTP قابل استفاده است (شخصی‌سازی متن). اگر نیامد یا شهر پیدا نشد → مقدار پیش‌فرض «کلینیک پرو». حداکثر ۲۵۳ کاراکتر. |

### Response `200`
```json
{
  "success": true,
  "data": {
    "uuid": "550e8400-e29b-41d4-a716-446655440000",
    "message": "کد تأیید ارسال شد"
  }
}
```

### Errors
| Code | HTTP | Description |
|------|------|-------------|
| `ERR_VALIDATION_001` | 422 | Invalid mobile format |
| `ERR_AUTH_004` | 429 | OTP rate limit exceeded |
| `ERR_RATE_LIMIT_001` | 429 | بیش از حد مجاز — هم per-IP (۵ در ۶۰ دقیقه) و هم **per-mobile** (۵ در ۶۰ دقیقه؛ ضد flood از IPهای چرخشی) |

---

## POST `/api/v1/user/verify-code`

Verify OTP code. Returns whether this is a new or existing user.

**Permission:** `PUBLIC`

### Request Body
```json
{
  "uuid": "550e8400-e29b-41d4-a716-446655440000",
  "code": "123456"
}
```

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `uuid` | string | ✅ | UUID returned from `send-code` |
| `code` | string | ✅ | 6-digit OTP |

### Response `200`
```json
{
  "success": true,
  "data": {
    "message": "کد با موفقیت تایید شد.",
    "grant": "ddf8a5994d6a2768203f15606621e3fa0e968438765e22061721a03d336d4039",
    "is_new_user": false
  }
}
```

> `grant` یک توکنِ **یک‌بارمصرف و کوتاه‌عمر (۱۲۰ ثانیه)** است که در مرحله‌ی بعد (`register` / `oauth/token` / `otp-login` / `reset-password`) مصرف می‌شود. خودِ OTP (`uuid`) پس از تأیید موفق حذف می‌شود. هر grant فقط یک‌بار قابل‌استفاده است؛ هر action که grant می‌خواهد یک grant جداگانه لازم دارد (یعنی برای new-user که هم `register` و هم login لازم است، باید دوبار verify انجام شود یا مستقیماً `oauth/token` صدا زده شود که خودش کاربر را می‌سازد).

### Errors
| Code | HTTP | Description |
|------|------|-------------|
| `ERR_AUTH_002` | 400 | Invalid OTP code |
| `ERR_AUTH_003` | 400 | OTP expired |
| `ERR_VALIDATION_002` | 422 | Missing required field |
| `ERR_RATE_LIMIT_001` | 429 | بیش از ۱۰ تلاش در ۱۵ دقیقه (per-IP) |

---

## POST `/api/v1/user/register`

Complete registration for new users (called only when `is_new_user: true`).

**Permission:** `PUBLIC`

### Request Body
```json
{
  "grant": "ddf8a5994d6a2768203f15606621e3fa0e968438765e22061721a03d336d4039",
  "real_name": "علی احمدی"
}
```

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `grant` | string | ✅ | grant یک‌بارمصرف از `verify-code` |
| `real_name` | string | ❌ | User's full name |

### Response `201`
```json
{
  "success": true,
  "data": {
    "message": "ثبت‌نام با موفقیت انجام شد",
    "uuid": "550e8400-e29b-41d4-a716-446655440000"
  }
}
```

### Errors
| Code | HTTP | Description |
|------|------|-------------|
| `ERR_VALIDATION_002` | 422 | Missing grant |
| `ERR_AUTH_002` | 400 | grant نامعتبر یا منقضی/مصرف‌شده |

---

## POST `/api/v1/user/login`

Login with mobile number and password (for users who set a password).

**Permission:** `PUBLIC`

### Request Body
```json
{
  "mobile_number": "09123456789",
  "password": "mypassword"
}
```

| Field | Type | Required |
|-------|------|----------|
| `mobile_number` | string | ✅ |
| `password` | string | ✅ |

### Response `200`
```json
{
  "access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9...",
  "refresh_token": "def50200...",
  "token_type": "Bearer",
  "expires_in": 900,
  "refresh_token_expires_in": 2592000
}
```

> فقط برای کاربران staff (`ROLE_ADMIN/DOCTOR/CLINIC/SECRETARY`). Access token TTL: **۹۰۰ ثانیه (۱۵ دقیقه)**.

### Errors
| Code | HTTP | Description |
|------|------|-------------|
| `ERR_AUTH_005` | 401 | Wrong credentials |
| `ERR_AUTH_006` | 403 | کاربر staff نیست |
| `429` | 429 | Too many attempts (۱۰ در دقیقه per-IP) |

---

## POST `/oauth/token`

Exchange a one-time `grant` (from `verify-code`) for a JWT access token. اگر کاربری با آن موبایل وجود نداشته باشد، ساخته می‌شود.

**Permission:** `PUBLIC`

### Request Body
```json
{
  "grant_type": "mobile",
  "grant": "ddf8a5994d6a2768203f15606621e3fa0e968438765e22061721a03d336d4039"
}
```

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `grant_type` | string | ✅ | Must be `"mobile"` |
| `grant` | string | ✅ | grant یک‌بارمصرف از `verify-code` (عمر ۱۲۰ ثانیه) |

### Response `200`
```json
{
  "access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9...",
  "refresh_token": "def50200...",
  "token_type": "Bearer",
  "expires_in": 900,
  "refresh_token_expires_in": 2592000
}
```

> JWT payload: `{ username: mobile_number, roles: [...], iat, exp }`  
> Access token TTL: **۹۰۰ ثانیه (۱۵ دقیقه)** | Refresh token TTL: **۳۰ روز** (در cache، هش‌شده و rotate-on-use)

### Errors
| Code | HTTP | Description |
|------|------|-------------|
| `ERR_VALIDATION_001` | 400 | `grant_type` نامعتبر |
| `ERR_VALIDATION_002` | 422 | `grant` ارسال نشده |
| `ERR_AUTH_002` | 400 | grant نامعتبر، منقضی یا قبلاً مصرف‌شده |
| `ERR_RATE_LIMIT_001` | 429 | بیش از ۱۰ درخواست در ۵ دقیقه (per-IP) |

---

## POST `/oauth/token/refresh`

Refresh expired JWT using refresh token.

**Permission:** `PUBLIC`

### Request Body
```json
{
  "refresh_token": "def50200..."
}
```

> **قابل استفاده‌ی مجدد:** refresh token تا انقضای TTL خود معتبر است و در پاسخ **بدون تغییر** برگردانده می‌شود (یک‌بارمصرف/چرخشی **نیست** — سایت عمومی در هر render سمت‌سرور refresh می‌زند و نمی‌تواند توکن چرخش‌یافته را ذخیره کند). کاربر **معلق** (`status != 1`) نمی‌تواند refresh کند (`401`).

### Response `200`
```json
{
  "access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9...",
  "refresh_token": "<همان توکن ارسالی — بدون تغییر>",
  "token_type": "Bearer",
  "expires_in": 900,
  "refresh_token_expires_in": 2592000
}
```

### Errors
| Code | HTTP | Description |
|------|------|-------------|
| `ERR_AUTH_001` | 401 | Invalid/expired refresh token, or suspended user (`status != 1`) |

---

## GET `/oauth/userinfo`

Get authenticated user info — extended with multi-context support.

**Permission:** `AUTH` — requires valid JWT

### Headers
```
Authorization: Bearer <token>
```

### Response `200`
```json
{
  "success": true,
  "data": {
    "id": 4766,
    "uuid": "550e8400-e29b-41d4-a716-446655440000",
    "mobile_number": "09123456789",
    "realName": "دکتر وحید درویشی",
    "status": 1,
    "roles": ["ROLE_USER", "ROLE_DOCTOR"],
    "primary_role": "doctor",
    "db_uuid": "a6ef5d29-38b8-4e69-b1ef-27a304696966",
    "db_key": "hmac-sha256-hash...",
    "doctor_uuid": "a6ef5d29-38b8-4e69-b1ef-27a304696966",
    "context": {
      "type": "doctor",
      "db_uuid": "a6ef5d29-38b8-4e69-b1ef-27a304696966",
      "name": "مطب شخصی دکتر وحید درویشی",
      "role": "doctor"
    },
    "available_contexts": [
      {
        "type": "doctor",
        "db_uuid": "a6ef5d29-38b8-4e69-b1ef-27a304696966",
        "name": "مطب شخصی دکتر وحید درویشی",
        "role": "doctor"
      },
      {
        "type": "clinic",
        "db_uuid": "clinic-uuid-...",
        "name": "کلینیک سلامت",
        "role": "doctor",
        "scope": "clinic",
        "doctor_uuid": "a6ef5d29-38b8-4e69-b1ef-27a304696966",
        "permissions": {
          "version": 1,
          "resources": {
            "appointments": { "view": true, "create": true, "cancel": true, "update_status": true },
            "appointment_settings": { "view": true, "update": true },
            "patients": { "view": true, "create": true, "update": true, "delete": false },
            "payments": { "view": true, "create": false, "update": false, "delete": false },
            "services": { "view": true, "update": false },
            "clinic_info": { "view": true, "update": false }
          }
        }
      }
    ]
  }
}
```

| فیلد | نوع | توضیح |
|------|-----|-------|
| `primary_role` | string | نقش اصلی: `admin` \| `clinic` \| `doctor` \| `secretary` \| `representation` \| `user` |
| `db_uuid` | string\|null | UUID موجودیت فعال (null = هنوز context انتخاب نشده) |
| `db_key` | string\|null | `HMAC-SHA256(db_uuid, APP_SECRET)` برای اعتبارسنجی |
| `doctor_uuid` | string\|null | UUID دکتر — ثابت است حتی در context کلینیک که `db_uuid` برابر UUID کلینیک است. برای کاربران غیر دکتر: `null` |
| `context` | object\|null | context فعال انتخاب‌شده |
| `available_contexts` | array | همه محیط‌های کاری قابل انتخاب |

**فیلد `permissions` در هر context:**

| حالت context | مقدار `permissions` |
|---|---|
| مطب شخصی پزشک (`type: doctor`، `role: doctor`) | `null` — محیط خودش، محدودیتی ندارد |
| مالک کلینیک (`role: clinic`) | `null` — مالک هرگز محدود نمی‌شود |
| پزشکِ عضو کلینیک (`role: doctor`، `scope: clinic`) | envelope کامل `{version, resources}` از `clinic_doctor_permissions` |
| پزشکِ عضوی که دسترسی‌اش غیرفعال شده | `{version: 1, resources: {}}` — یعنی هیچ دسترسی |
| منشی (`role: secretary`) | envelope کامل از `doctor_secretaries` |

نکتهٔ مهم برای کلاینت: **نبودِ `permissions` (یا `null`) یعنی «بدون محدودیت»، نه «بدون دسترسی».** ساختار و کلیدهای مجوز پزشکِ عضو کلینیک در `docs/api/clinic.md` → بخش *Clinic Doctor Permissions* آمده است.

**قانون `primary_role`** (اولویت‌بندی):
- `ROLE_ADMIN` → `"admin"`
- `ROLE_CLINIC` → `"clinic"`
- `ROLE_DOCTOR` → `"doctor"`
- `ROLE_SECRETARY` → `"secretary"`
- `ROLE_REPRESENTATION` → `"representation"` (نماینده؛ دسترسی محدود به پنل ادمین: افزودن پزشک/کلینیک، نوبت‌های پزشکانِ زیرمجموعه، داشبورد نماینده)
- بقیه → `"user"`

**قانون `context.role`** — نقشی که در آن محیط کاری فعال است:
- context مطب شخصی دکتر: `"doctor"`
- context کلینیک که دکتر **عضو** آن است (مالک نیست): `"doctor"` + `"scope": "clinic"` — پزشک می‌ماند و فقط نوبت‌های خودش در آن کلینیک را می‌بیند؛ دسترسی مدیریتی پنل کلینیک ندارد
- context کلینیک که دکتر **صاحب** آن است: `"clinic"` (دسترسی کامل مالک)
- context منشی: `"secretary"`

> **نکته frontend:** پس از `switchContext`، `primaryRole` در store از `context.role` و `scope` از `context.scope` آپدیت می‌شود. وقتی `role:"doctor"` و `scope:"clinic"` است (پزشکِ مهمان)، Sidebar فقط «داشبورد» و «نوبت‌ها» را نشان می‌دهد و مسیرهای مدیریتی (`staff`, `clinic-services`, `subscription`, `my-secretaries`, `my-patients`, `profile`) به داشبورد ریدایرکت می‌شوند. در سمت backend هم endpointهای مدیریتی برای پزشک فقط scope **شخصیِ** خودش را برمی‌گردانند (نه کلینیک) و endpointهای ویرایش کلینیک مالکیت را چک می‌کنند (۴۰۳).

**قانون `db_uuid`**:
- اگر یک context وجود دارد: خودکار فعال می‌شود
- اگر چند context وجود دارد و کاربر هنوز انتخاب نکرده: `null` — frontend باید صفحه انتخاب نشان دهد
- پس از `POST /api/v1/auth/switch-context`: برابر context انتخاب‌شده

### Errors
| Code | HTTP | Description |
|------|------|-------------|
| `ERR_AUTH_001` | 401 | Invalid or missing token |

---

## POST `/api/v1/auth/switch-context`

تغییر محیط کاری فعال — باید از لیست `available_contexts` انتخاب شود.

**Permission:** `AUTH`

### Request Body
```json
{
  "db_uuid": "clinic-uuid-..."
}
```

| فیلد | نوع | Required | توضیح |
|------|-----|----------|-------|
| `db_uuid` | string (UUID) | ✅ | UUID محیط کاری از لیست `available_contexts` |

### Response `200`
```json
{
  "success": true,
  "data": {
    "db_uuid": "clinic-uuid-...",
    "db_key": "new-hmac-hash...",
    "context": {
      "type": "clinic",
      "db_uuid": "clinic-uuid-...",
      "name": "کلینیک سلامت",
      "role": "clinic",
      "doctor_uuid": "a6ef5d29-38b8-4e69-b1ef-27a304696966"
    }
  }
}
```

### Errors
| Code | HTTP | Description |
|------|------|-------------|
| `ERR_AUTH_001` | 401 | Missing or invalid token |
| `ERR_AUTH_006` | 403 | `db_uuid` در لیست context های این کاربر نیست |
| `ERR_VALIDATION_001` | 422 | `db_uuid` ارسال نشده |

---

## Notification Mobile (OTP)

Endpoints for setting/verifying a separate SMS notification number for doctors and clinics. This number receives appointment SMS notifications instead of the account login mobile.

> **Permission:** All 4 endpoints require `IS_AUTHENTICATED_FULLY` (JWT).

---

## POST `/api/v1/notification-mobile/request-otp`

Request an OTP code to verify a new notification mobile number.

**Permission:** `AUTH`

### Request Body
```json
{
  "target": "doctor",
  "new_mobile": "09123456789"
}
```

| Field | Type | Required | Validation |
|-------|------|----------|------------|
| `target` | string | ✅ | `doctor` or `clinic` |
| `new_mobile` | string | ✅ | Format: `09XXXXXXXXX` |

### Response `200`
```json
{
  "success": true,
  "data": {
    "message": "کد تأیید ارسال شد",
    "expires_in": 300
  }
}
```

### Errors
| Code | HTTP | Description |
|------|------|-------------|
| `ERR_VALIDATION_001` | 422 | Invalid `target` or mobile format |
| `ERR_NOT_FOUND_001` | 404 | No doctor/clinic profile found for this user |

### Notes
- Previous unused OTPs for the entity are deleted before creating a new one
- OTP is 6-digit, expires in 5 minutes
- Sends via SMS asynchronously

---

## POST `/api/v1/notification-mobile/verify`

Verify the OTP and save the notification mobile number.

**Permission:** `AUTH`

### Request Body
```json
{
  "target": "doctor",
  "otp_code": "123456"
}
```

| Field | Type | Required |
|-------|------|----------|
| `target` | string | ✅ `doctor` or `clinic` |
| `otp_code` | string | ✅ 6-digit code |

### Response `200`
```json
{
  "success": true,
  "data": {
    "notification_mobile": "09123456789",
    "message": "شماره اعلان با موفقیت ذخیره شد"
  }
}
```

### Errors
| Code | HTTP | Description |
|------|------|-------------|
| `ERR_VALIDATION_001` | 422 | Invalid target / OTP expired / wrong code |
| `ERR_NOT_FOUND_001` | 404 | No OTP request found or profile not found |

---

## GET `/api/v1/notification-mobile/{target}`

Get the current notification mobile for the authenticated user.

**Permission:** `AUTH`  
**Path param:** `target` — `doctor` or `clinic`

### Response `200`
```json
{
  "success": true,
  "data": {
    "notification_mobile": "09123456789"
  }
}
```

- Returns `null` for `notification_mobile` if not set

### Errors
| Code | HTTP | Description |
|------|------|-------------|
| `ERR_NOT_FOUND_001` | 404 | No profile found for this user |

---

## DELETE `/api/v1/notification-mobile/{target}`

Remove the notification mobile number.

**Permission:** `AUTH`  
**Path param:** `target` — `doctor` or `clinic`

### Response `200`
```json
{
  "success": true,
  "data": {
    "message": "شماره اعلان حذف شد"
  }
}
```

### Errors
| Code | HTTP | Description |
|------|------|-------------|
| `ERR_NOT_FOUND_001` | 404 | No profile found for this user |

---

## POST `/oauth/logout`

Invalidate the refresh token (stored in Redis).

**Permission:** `AUTH`

### Request Body
```json
{
  "refresh_token": "def50200..."
}
```

| Field | Type | Required |
|-------|------|----------|
| `refresh_token` | string | ❌ |

### Response `200`
```json
{
  "success": true,
  "data": {
    "message": "با موفقیت خارج شدید"
  }
}
```

---

## POST `/api/v1/pre-registration`

Submit a pre-registration request (doctor or clinic). Public endpoint — no auth required.

**Permission:** Public

> **🛡️ ALTCHA:** وقتی `ALTCHA_ENABLED=true` است، فیلد `altcha` (payload حل‌شده‌ی widget) الزامی است؛ در غیر این صورت `422` با `ERR_CAPTCHA_001`. رجوع به [captcha.md](captcha.md).

### Request Body
```json
{
  "type": "independent_doctor",
  "name": "دکتر علی احمدی",
  "mobile": "09121234567",
  "info": "متخصص داخلی، ۱۰ سال سابقه"
}
```

| Field | Type | Required | Notes |
|-------|------|----------|-------|
| `type` | string | ✅ | `independent_doctor` \| `doctor_with_clinic` \| `clinic_manager` |
| `name` | string | ✅ | min 2 chars |
| `mobile` | string | ✅ | 10–15 chars |
| `info` | string | ❌ | specialty, address, etc. |

**Type meanings:**

| Value | Roles granted on approval | Entities created |
|-------|--------------------------|-----------------|
| `independent_doctor` | `ROLE_DOCTOR` | Doctor |
| `doctor_with_clinic` | `ROLE_DOCTOR` + `ROLE_CLINIC` | Doctor + Clinic |
| `clinic_manager` | `ROLE_CLINIC` | Clinic |

### Response `200`
```json
{
  "success": true,
  "data": { "uuid": "...", "status": "pending" }
}
```

### Error Codes
| Code | HTTP | Meaning |
|------|------|---------|
| `VALIDATION_ERROR` | 422 | Invalid type / mobile / name |
| `DUPLICATE_REQUEST` | 409 | Pending request already exists for this mobile |

---

## POST `/api/v1/user/otp-login`

ورود با کد OTP تأییدشده — **فقط کاربران موجود**، کاربر جدید ایجاد نمی‌شود.

**Permission:** `PUBLIC`

### Request Body
```json
{ "grant": "ddf8a5994d6a2768203f15606621e3fa0e968438765e22061721a03d336d4039" }
```

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `grant` | string | ✅ | grant یک‌بارمصرف از `verify-code` |

### Response `200`
```json
{
  "access_token": "eyJ...",
  "refresh_token": "...",
  "token_type": "Bearer",
  "expires_in": 900,
  "refresh_token_expires_in": 2592000
}
```

### Error Codes
| Code | HTTP | Description |
|------|------|-------------|
| `ERR_VALIDATION_002` | 422 | grant ارسال نشده |
| `ERR_AUTH_002` | 400 | grant نامعتبر، منقضی یا مصرف‌شده |
| `ERR_AUTH_005` | 401 | کاربری با این شماره یافت نشد |
| `ERR_RATE_LIMIT_001` | 429 | بیش از ۱۰ درخواست در ۵ دقیقه (per-IP) |

---

## POST `/api/v1/user/change-password`

تغییر رمز عبور توسط کاربرِ احرازشده (بدون OTP). رمز فعلی راستی‌آزمایی می‌شود.

**Permission:** `IS_AUTHENTICATED_FULLY`

### Request Body
```json
{
  "current_password": "oldpass1234",
  "new_password": "newpass1234"
}
```

| Field | Type | Required | Validation |
|-------|------|----------|------------|
| `current_password` | string | ✅ | باید با رمز فعلی مطابقت کند |
| `new_password` | string | ✅ | حداقل ۸ کاراکتر و متفاوت با رمز فعلی |

### Response `200`
```json
{ "success": true, "data": { "message": "رمز عبور با موفقیت تغییر یافت" } }
```

### Errors
| HTTP | Code | field | Description |
|------|------|-------|-------------|
| 422 | `ERR_VALIDATION_001` | `new_password` | رمز جدید کوتاه یا برابر رمز فعلی |
| 422 | `ERR_VALIDATION_001` | `current_password` | رمز فعلی نادرست |

---

## POST `/api/v1/user/reset-password`

تغییر رمز عبور با تأیید هویت از طریق OTP.

**Permission:** `PUBLIC`

### Request Body
```json
{
  "grant": "ddf8a5994d6a2768203f15606621e3fa0e968438765e22061721a03d336d4039",
  "new_password": "newpass1234"
}
```

| Field | Type | Required | Validation |
|-------|------|----------|------------|
| `grant` | string | ✅ | grant یک‌بارمصرف از `verify-code` |
| `new_password` | string | ✅ | حداقل ۸ کاراکتر |

### Response `200`
```json
{
  "success": true,
  "data": { "message": "رمز عبور با موفقیت تغییر یافت" }
}
```

### Error Codes
| Code | HTTP | Description |
|------|------|-------------|
| `ERR_VALIDATION_001` | 422 | grant ارسال نشده یا رمز کمتر از ۸ کاراکتر |
| `ERR_AUTH_002` | 400 | grant نامعتبر، منقضی یا مصرف‌شده |
| `ERR_NOT_FOUND_001` | 404 | کاربری با این شماره یافت نشد |
| `ERR_RATE_LIMIT_001` | 429 | بیش از ۵ درخواست در ۶۰ دقیقه (per-IP) |
