- Implemented TourProgressController to handle API endpoints for tracking guided tours seen by users. - Created UserTourProgress entity to store the highest version of tours seen by each user. - Developed UserTourProgressRepository for database interactions related to user tour progress. - Introduced TourProgressService to manage business logic for marking tours as seen and retrieving seen maps. - Added comprehensive tests for API endpoints and entity behavior to ensure functionality and data integrity.
265 lines
9.9 KiB
Markdown
265 lines
9.9 KiB
Markdown
# User Profile API
|
|
|
|
> **Prefix:** `/api/v1/user-profile` (plus the per-user tour endpoints under `/api/v1/my/tours`)
|
|
|
|
User medical profiles store health information that can be shared with doctors.
|
|
|
|
---
|
|
|
|
## POST `/api/v1/user-profile`
|
|
|
|
Create a medical profile for the authenticated user.
|
|
|
|
**Permission:** `AUTH`
|
|
|
|
### Request Body (`application/json`)
|
|
```json
|
|
{
|
|
"name": "علی",
|
|
"family": "احمدی",
|
|
"fathers_name": "محمد",
|
|
"national_code": "0012345678",
|
|
"gender": "male",
|
|
"blood_type": "A+",
|
|
"marital_status": "married",
|
|
"education": "لیسانس",
|
|
"job": "مهندس",
|
|
"address": "تهران، خیابان ولیعصر",
|
|
"home_phone": "02112345678",
|
|
"work_phone": "02198765432",
|
|
"insurance_id": 1,
|
|
"description": "توضیحات بیشتر",
|
|
"sharing_with_user": true,
|
|
"birthday": "1370-06-15",
|
|
"date_of_birth": 648172800,
|
|
"basic_insurance": "بیمه تأمین اجتماعی",
|
|
"supplementary_insurance": "بیمه ایران",
|
|
"other": {}
|
|
}
|
|
```
|
|
|
|
| Field | Type | Required | Description |
|
|
|-------|------|----------|-------------|
|
|
| `name` | string | ❌ | First name |
|
|
| `family` | string | ❌ | Last name |
|
|
| `fathers_name` | string | ❌ | Father's name |
|
|
| `national_code` | string | ❌ | National ID (10 digits) |
|
|
| `gender` | string | ❌ | `"male"` or `"female"` |
|
|
| `blood_type` | string | ❌ | `"A+"`, `"A-"`, `"B+"`, `"B-"`, `"AB+"`, `"AB-"`, `"O+"`, `"O-"` |
|
|
| `marital_status` | string | ❌ | `"single"` or `"married"` |
|
|
| `education` | string | ❌ | Education level |
|
|
| `job` | string | ❌ | Occupation |
|
|
| `address` | string | ❌ | Home address |
|
|
| `home_phone` | string | ❌ | Home phone number |
|
|
| `work_phone` | string | ❌ | Work phone number |
|
|
| `insurance_id` | integer | ❌ | Insurance ID |
|
|
| `description` | string | ❌ | Additional notes |
|
|
| `sharing_with_user` | boolean | ❌ | Allow doctors to view this profile |
|
|
| `birthday` | string | ❌ | Birthday in Jalali format `YYYY-MM-DD` |
|
|
| `date_of_birth` | integer | ❌ | Birthday as Unix timestamp |
|
|
| `basic_insurance` | string | ❌ | Basic insurance name |
|
|
| `supplementary_insurance` | string | ❌ | Supplementary insurance name |
|
|
| `other` | object | ❌ | Extra metadata |
|
|
|
|
### Response `201`
|
|
```json
|
|
{
|
|
"success": true,
|
|
"data": {
|
|
"uuid": "profile-uuid-...",
|
|
"name": "علی",
|
|
"family": "احمدی",
|
|
"gender": "male",
|
|
"blood_type": "A+",
|
|
"national_code": "0012345678",
|
|
"sharing_with_user": true,
|
|
"created_at": 1717000000
|
|
}
|
|
}
|
|
```
|
|
|
|
### Errors
|
|
| Code | HTTP | Description |
|
|
|------|------|-------------|
|
|
| `ERR_AUTH_001` | 401 | Missing token |
|
|
| `ERR_VALIDATION_001` | 422 | `national_code` نامعتبر (رقم کنترلی/طول غلط) (`field: national_code`) |
|
|
| `ERR_PROFILE_001` | 409 | کد ملی قبلاً برای پروفایل کاربر دیگری ثبت شده (`field: national_code`) |
|
|
| `ERR_CONFLICT_001` | 409 | Profile already exists for this user |
|
|
|
|
---
|
|
|
|
## GET `/api/v1/user-profile/{uuid}`
|
|
|
|
Get a user profile.
|
|
|
|
**Permission:** `AUTH` — must be the profile owner, or a doctor if `sharing_with_user: true`, or `ROLE_ADMIN`
|
|
|
|
### Path Parameters
|
|
| Param | Type | Description |
|
|
|-------|------|-------------|
|
|
| `uuid` | string (UUID) | **Profile UUID or the owning User UUID** — both are accepted |
|
|
|
|
> **Auto-resolve & lazy-create:** `{uuid}` is first looked up as a profile uuid, then as a user uuid. If it is the **current user's own** uuid and they have **no profile yet**, an empty profile is created and returned (HTTP 200) — so a freshly registered user always gets an editable profile instead of a 404. An **admin** reading **another** user's missing profile gets `404` (no profile is created as a side effect of reading). The response includes both the profile `uuid` and `user_uuid`; keep the profile `uuid` for subsequent PATCHes.
|
|
|
|
### Response `200`
|
|
Full profile object including all fields (all `null` for a newly created empty profile). Response is double-nested: extract with `data.data`.
|
|
|
|
### Errors
|
|
| Code | HTTP | Description |
|
|
|------|------|-------------|
|
|
| `ERR_AUTH_001` | 401 | Missing token |
|
|
| `ERR_FORBIDDEN_001` | 403 | Not authorized to view this profile |
|
|
| `ERR_VALIDATION_002` | 404 | No profile or user matches the uuid |
|
|
|
|
---
|
|
|
|
## PATCH `/api/v1/user-profile/{uuid}`
|
|
|
|
Update a user profile.
|
|
|
|
**Permission:** `AUTH` — must be the profile owner
|
|
|
|
### Path Parameters
|
|
| Param | Type | Description |
|
|
|-------|------|-------------|
|
|
| `uuid` | string (UUID) | **Profile UUID or the owning User UUID** — both accepted; the profile is created if the current user has none yet |
|
|
|
|
### Request Body
|
|
Same fields as POST — all optional.
|
|
|
|
> **`national_code` server-side validation:** اگر `national_code` ارسال شود و **غیرخالی** باشد، با الگوریتم رقم کنترلیِ کد ملی ایران اعتبارسنجی میشود (ارقام فارسی/عربی به انگلیسی نرمال و بهصورت لاتین ذخیره میشوند). مقدارِ نامعتبر با `422` رد میشود. ارسال `null` یا رشتهی خالی مجاز است (کد ملی اختیاری) و فیلد را پاک میکند.
|
|
>
|
|
> **یکتایی کد ملی:** کد ملی در کل سیستم یکتاست (یک کد ملی = یک بیمار). اگر کد ملی ارسالی قبلاً به پروفایل **کاربر دیگری** تعلق داشته باشد، با `409` و کد `ERR_PROFILE_001` رد میشود (`field: national_code`). پیام خطا شامل **شماره موبایلِ ماسکشدهی** صاحب کد است (مثلاً «این کد ملی قبلاً با شماره 0912****56 ثبت شده است»). در سطح دیتابیس هم با `UNIQUE INDEX uniq_profiles_national_code` تضمین شده (مقادیر `NULL` آزادند). همین قید روی مسیر ساخت بیمار توسط منشی (`POST /api/v1/patient`) نیز اعمال میشود.
|
|
|
|
### Response `200`
|
|
Updated profile object.
|
|
|
|
### Errors
|
|
| Code | HTTP | Description |
|
|
|------|------|-------------|
|
|
| `ERR_AUTH_001` | 401 | Missing token |
|
|
| `ERR_VALIDATION_001` | 422 | `national_code` نامعتبر (رقم کنترلی/طول غلط) (`field: national_code`) |
|
|
| `ERR_PROFILE_001` | 409 | کد ملی قبلاً برای پروفایل کاربر دیگری ثبت شده (`field: national_code`) |
|
|
| `ERR_FORBIDDEN_001` | 403 | Not the profile owner |
|
|
| `ERR_NOT_FOUND_001` | 404 | Profile not found |
|
|
|
|
---
|
|
|
|
## DELETE `/api/v1/user-profile/{uuid}`
|
|
|
|
Delete a user profile.
|
|
|
|
**Permission:** `ROLE_ADMIN`
|
|
|
|
### 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 | Profile not found |
|
|
|
|
---
|
|
|
|
## POST `/api/v1/user-profile/avatar`
|
|
|
|
Upload the current user's profile avatar. Creates the profile if it doesn't exist yet.
|
|
|
|
**Permission:** `IS_AUTHENTICATED_FULLY`
|
|
|
|
### Request
|
|
Raw file body with `Content-Disposition: filename="..."` and `Content-Type: application/octet-stream`.
|
|
|
|
### Response `200`
|
|
```json
|
|
{ "success": true, "data": { "avatar": "/uploads/avatars/2026-06/xxx.jpg", "url": "/uploads/avatars/2026-06/xxx.jpg" } }
|
|
```
|
|
|
|
The stored path is also returned as `avatar` in the profile GET/PATCH responses.
|
|
|
|
### Errors
|
|
| Code | HTTP | Description |
|
|
|------|------|-------------|
|
|
| `ERR_VALIDATION_001` | 422 | Missing or invalid file |
|
|
| `ERR_AUTH_001` | 401 | Missing token |
|
|
|
|
---
|
|
|
|
# Guided tours (admin panel)
|
|
|
|
The admin SPA ships a step-by-step tour per page. A tour runs automatically the first time a
|
|
user opens that page, and is available afterwards from the `?` button in the page header.
|
|
Which tours a user has already been through is stored server-side, so it follows the account
|
|
across browsers and devices.
|
|
|
|
Each tour carries a `version` in the frontend registry (`assets/admin/lib/tour/tours/*.ts`).
|
|
The tour auto-runs again when its version is higher than the stored one, which is how a rewritten
|
|
tour reaches users who already saw the old text.
|
|
|
|
## GET `/api/v1/my/tours`
|
|
|
|
Tours the current user has already been through.
|
|
|
|
**Permission:** `IS_AUTHENTICATED_FULLY`
|
|
|
|
### Response `200`
|
|
```json
|
|
{ "success": true, "data": { "seen": { "appointments": 1 } } }
|
|
```
|
|
|
|
`seen` is always an object; it is `{}` for a user who has not finished any tour yet.
|
|
|
|
| Field | Type | Description |
|
|
|-------|------|-------------|
|
|
| `seen` | object | tour id → highest version the user has seen |
|
|
|
|
### Errors
|
|
| Code | HTTP | Description |
|
|
|------|------|-------------|
|
|
| `ERR_AUTH_001` | 401 | Missing token |
|
|
|
|
---
|
|
|
|
## POST `/api/v1/my/tours/{tourId}/seen`
|
|
|
|
Record that the current user has been through a tour. Closing a tour part-way counts as seen —
|
|
the client sends this on tour close as well as on completion.
|
|
|
|
**Permission:** `IS_AUTHENTICATED_FULLY`
|
|
|
|
| Param | Type | Description |
|
|
|-------|------|-------------|
|
|
| `tourId` | string | Route-constrained to `[a-z0-9-]{1,64}`; anything else is a 404 |
|
|
|
|
### Request Body (`application/json`)
|
|
```json
|
|
{ "version": 1 }
|
|
```
|
|
|
|
| Field | Type | Required | Description |
|
|
|-------|------|----------|-------------|
|
|
| `version` | integer ≥ 1 | ✅ | Version of the tour definition that was shown |
|
|
|
|
### Response `200`
|
|
```json
|
|
{ "success": true, "data": { "tourId": "appointments", "version": 1 } }
|
|
```
|
|
|
|
Repeat calls update the same row. The stored version only ever moves forward, so replaying an
|
|
old tour from the `?` button cannot re-trigger a newer one.
|
|
|
|
### Errors
|
|
```json
|
|
{ "success": false, "data": null, "errors": [ { "code": "ERR_VALIDATION_001", "message": "نسخهٔ راهنما باید عددی بزرگتر از صفر باشد", "field": "version" } ] }
|
|
```
|
|
|
|
| Code | HTTP | Description |
|
|
|------|------|-------------|
|
|
| `ERR_VALIDATION_001` | 422 | `version` missing, not an integer, or below 1 |
|
|
| `ERR_AUTH_001` | 401 | Missing token |
|
|
| — | 404 | `tourId` does not match the route constraint |
|