- Implemented SidebarStaff component tests to ensure staff users see only their dashboard and services. - Created StaffMyServicesPage to display assigned services for staff users. - Added migration to link clinic staff rows to user accounts for ROLE_STAFF access. - Defined StaffPermissions class for static permissions related to staff role. - Introduced StaffRouteGuardSubscriber to restrict API access for staff users. - Developed StaffAccountService for managing staff user accounts and linking them to clinic staff. - Added comprehensive tests for StaffAccountService to validate user creation, mobile number handling, and account attachment. - Implemented tests for staff dashboard access to ensure proper permissions and access control. - Created tests for staff login context to verify correct environment visibility based on user roles.
739 lines
25 KiB
Markdown
739 lines
25 KiB
Markdown
# 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` \| `staff` \| `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 | همه محیطهای کاری قابل انتخاب |
|
||
|
||
> **مالکِ چند کلینیک:** اگر یک کاربر مالک بیش از یک کلینیک باشد، **همهٔ** آنها در این فهرست میآیند و به هرکدام میشود سوییچ کرد. تا پیش از این فقط اولی میآمد و کلینیک دوم اصلاً قابل انتخاب نبود — که با جداسازی محیط یعنی دادهٔ آن کلینیک برای خودِ مالکش هم نامرئی میماند. (`MultiClinicOwnerContextTest`)
|
||
|
||
**فیلد `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` |
|
||
| پرسنل (`role: staff`) | envelope ثابت `{"version":1,"resources":{"services":{"view":true},"appointments":{"view":true}}}` — قابل ویرایش نیست |
|
||
|
||
نکتهٔ مهم برای کلاینت: **نبودِ `permissions` (یا `null`) یعنی «بدون محدودیت»، نه «بدون دسترسی».** ساختار و کلیدهای مجوز پزشکِ عضو کلینیک در `docs/api/clinic.md` → بخش *Clinic Doctor Permissions* آمده است.
|
||
|
||
**قانون `primary_role`** (اولویتبندی):
|
||
- `ROLE_ADMIN` → `"admin"`
|
||
- `ROLE_CLINIC` → `"clinic"`
|
||
- `ROLE_DOCTOR` → `"doctor"`
|
||
- `ROLE_SECRETARY` → `"secretary"`
|
||
- `ROLE_STAFF` → `"staff"` (پرسنل کلینیک/مطب؛ عمداً بعد از منشی: کاربری که هر دو نقش را دارد منشی میماند)
|
||
- `ROLE_REPRESENTATION` → `"representation"` (نماینده؛ دسترسی محدود به پنل ادمین: افزودن پزشک/کلینیک، نوبتهای پزشکانِ زیرمجموعه، داشبورد نماینده)
|
||
- بقیه → `"user"`
|
||
|
||
**نقش `staff`** — کاربری که از `POST /api/v1/staff` با `has_account: true` ساخته شده
|
||
(رجوع به [staff.md](staff.md)):
|
||
|
||
- هر ردیف **فعالِ** `clinic_staff` که به این کاربر وصل است، یک context با `role: "staff"` و
|
||
`scope` برابر `doctor` یا `clinic` میسازد. پرسنلِ غیرفعال هیچ context نمیگیرد.
|
||
- ورود با رمز مجاز است (`User::isStaff()` شامل `ROLE_STAFF` است).
|
||
- دسترسی API این کاربر **پیشفرض بسته** است: فقط `GET /api/v1/dashboard/staff`،
|
||
`POST /api/v1/auth/switch-context`، `POST /api/v1/user/change-password` و مسیرهای `/oauth/*`؛
|
||
بقیهٔ `/api/v1/*` با `ERR_FORBIDDEN_001` و ۴۰۳ رد میشود (`StaffRouteGuardSubscriber`).
|
||
|
||
**قانون `context.role`** — نقشی که در آن محیط کاری فعال است:
|
||
- context مطب شخصی دکتر: `"doctor"`
|
||
- context کلینیک که دکتر **عضو** آن است (مالک نیست): `"doctor"` + `"scope": "clinic"` — پزشک میماند و فقط نوبتهای خودش در آن کلینیک را میبیند؛ دسترسی مدیریتی پنل کلینیک ندارد
|
||
- context کلینیک که دکتر **صاحب** آن است: `"clinic"` (دسترسی کامل مالک)
|
||
- context منشی: `"secretary"`
|
||
- context پرسنل: `"staff"` + `scope` برابر نوع محیط (`doctor` یا `clinic`)
|
||
|
||
> **نکته 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 انتخابشده
|
||
|
||
> **سمت سرور:** جدول `user_active_context` علاوه بر `db_uuid` ستون `db_type` (`doctor` یا `clinic`) هم دارد که از `available_contexts[].type` پر میشود. بدون آن، هر بار حلکردن محیط دو کوئری میخواست: اول کلینیک با آن uuid، بعد پزشک. این ستون در پاسخ API ظاهر نمیشود و قرارداد frontend را عوض نمیکند.
|
||
|
||
### 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) |
|