buildAvailableContexts used ClinicRepository::findByUser(), which is a findOneBy — so a user who owns two clinics only ever saw the first one. switchContext validates its input against that same list, so the second clinic could not be selected at all. Before tenant isolation this was merely annoying. Since phase 4 it is a blocker: an environment that cannot be selected is an environment TenantFilter hides from its own owner. Found by running the suite against an imported production database, where one account owns two clinics and its second clinic had become unreachable. findByUser() stays for the fallbacks that only need "some clinic"; the context list now uses findAllByUser(). The other 20 findByUser() call sites are single-clinic fallbacks used when no context is chosen, and keep their current behaviour — once the owner can switch, UserActiveContext decides. Removing the fix turns 3 of the 4 new tests red. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
726 lines
23 KiB
Markdown
726 lines
23 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` \| `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` |
|
||
|
||
نکتهٔ مهم برای کلاینت: **نبودِ `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 انتخابشده
|
||
|
||
> **سمت سرور:** جدول `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) |
|