Adds a platform-wide maintenance switch controlled from the admin panel. A single kernel.request subscriber (priority 6, after the firewall listener) short-circuits every request with 503, so no controller has to check it and all API clients — the admin SPA, nobat724_front and clinic-pro-tauri — are covered at once. - SiteConfig gains five maintenance_* keys; no entity change, no migration - MaintenanceService caches the state in Redis for 30s and is fail-open: a Redis or database failure never takes the site down by itself - API responses reuse the BaseController::error() envelope with code MAINTENANCE_MODE plus a Retry-After header; browsers get a self-contained Twig page (inline CSS, noindex) that renders even mid-deploy - Whitelist keeps /oauth/*, the login endpoints and /api/v1/admin/settings reachable, otherwise an admin could neither sign in nor switch it back off - Admin bypass falls back to decoding the Authorization JWT, because several admin-panel endpoints sit in the public_endpoints firewall (security: false) where no token is ever resolved and isGranted always returns false - A kernel.exception handler at priority 20 covers routing 404/405 and firewall 401, which are thrown before the request listener runs - app:maintenance on|off|status is the escape hatch when the panel is down Also removes a stray `APP_SECRET = ...` line from .env.dev: the spaces around `=` are rejected by Symfony Dotenv, which made every console command and the whole app fatal. The secret already lives in .env.local, as the comment above that line instructs. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
47 KiB
Admin API
Prefix:
/api/v1/admin
Permission: ALL endpoints in this file requireROLE_ADMIN
Headers:Authorization: Bearer <admin_jwt_token>
Dashboard
GET /api/v1/admin/dashboard/stats
Get key performance indicators (KPIs) for the dashboard.
Permission: ROLE_ADMIN
Response 200
{
"success": true,
"data": {
"total_users": 1200,
"active_doctors": 85,
"total_doctors": 92,
"total_clinics": 34,
"today_appointments": 47,
"total_appointments": 8540,
"today_payments_count": 30,
"today_payments_amount": 15000000,
"total_payments_amount": 425000000,
"pending_comments": 12,
"pending_settlements": 5,
"this_month_revenue": 52000000,
"this_month_appointments": 620
}
}
GET /api/v1/admin/dashboard/charts
Get chart data for the last 30 days.
Permission: ROLE_ADMIN
Response 200
{
"success": true,
"data": {
"appointments_30d": [
{ "date": "2024-06-01", "count": 42 }
],
"revenue_30d": [
{ "date": "2024-06-01", "amount_rials": 21000000 }
],
"appointment_status": {
"confirmed": 350,
"completed": 180,
"cancelled": 45,
"pending": 20,
"no_show": 25
},
"top_specialties": [
{ "name": "قلب و عروق", "count": 120 }
]
}
}
GET /api/v1/admin/dashboard/recent
Get recent activity (last 10 of each type).
Permission: ROLE_ADMIN
Response 200
{
"success": true,
"data": {
"appointments": [
{
"uuid": "...",
"doctor_title": "دکتر علی احمدی",
"patient_name": "محمد رضایی",
"slot_start": 1718438400,
"status": "confirmed"
}
],
"payments": [
{
"uuid": "...",
"amount_rials": 500000,
"gateway": "mellat",
"status": "paid",
"created_at": 1717000000
}
],
"users": [
{
"uuid": "...",
"real_name": "محمد رضایی",
"mobile_number": "09...",
"roles": ["ROLE_USER"],
"created_at": 1717000000
}
]
}
}
User Management
GET /api/v1/admin/users
List all users with pagination and filters.
Permission: ROLE_ADMIN
Query Parameters
| Param | Type | Required | Description |
|---|---|---|---|
page |
integer | ❌ | Default: 1 |
limit |
integer | ❌ | Default: 20 |
search |
string | ❌ | Search by name or mobile |
role |
string | ❌ | فیلتر نقش: admin | doctor | secretary | clinic | representation (کاربران دارای ROLE_REPRESENTATION) | patient |
status |
string | ❌ | "active" or "inactive" |
sort |
string | ❌ | "created_at" (default desc) |
Response 200
{
"success": true,
"data": [
{
"uuid": "...",
"real_name": "علی احمدی",
"mobile_number": "09123456789",
"roles": ["ROLE_USER"],
"status": "active",
"created_at": 1717000000
}
],
"meta": { "totalRecords": 1200, "totalPages": 60, "currentPage": 1 }
}
GET /api/v1/admin/users/{uuid}
Get detailed user info.
Permission: ROLE_ADMIN
Response 200
{
"success": true,
"data": {
"uuid": "...",
"real_name": "علی احمدی",
"mobile_number": "09123456789",
"roles": ["ROLE_USER"],
"status": "active",
"wallet_balance_rials": 500000,
"appointments_count": 5,
"created_at": 1717000000
}
}
Errors
| Code | HTTP | Description |
|---|---|---|
ERR_NOT_FOUND_001 |
404 | User not found |
GET /api/v1/admin/users/stats
Get user statistics.
Permission: ROLE_ADMIN
Response 200
{
"success": true,
"data": {
"total": 1200,
"active": 1150,
"inactive": 50,
"admins": 3,
"doctors": 92,
"patients": 1100
}
}
PUT /api/v1/admin/users/{uuid}
Update user info (name, email, password).
Permission: ROLE_ADMIN
Request Body (application/json)
{
"real_name": "علی احمدی جدید",
"password": "newPassword123"
}
Response 200
Updated user object.
PUT /api/v1/admin/users/{uuid}/role
Change a user's role.
Permission: ROLE_ADMIN
Request Body (application/json)
{
"role": "ROLE_DOCTOR"
}
| Field | Type | Required | Allowed Values |
|---|---|---|---|
role |
string | ✅ | ROLE_USER, ROLE_DOCTOR, ROLE_CLINIC, ROLE_SECRETARY, ROLE_ADMIN |
Response 200
{ "success": true, "data": { "message": "نقش کاربر تغییر کرد", "roles": ["ROLE_DOCTOR"] } }
POST /api/v1/admin/users/{uuid}/status
Toggle user active/inactive status.
Permission: ROLE_ADMIN
Response 200
{ "success": true, "data": { "status": "inactive" } }
DELETE /api/v1/admin/users/{uuid}
Delete a user.
Permission: ROLE_ADMIN
Response 200
{ "success": true, "data": { "message": "کاربر حذف شد" } }
Errors
| Code | HTTP | Description |
|---|---|---|
ERR_NOT_FOUND_001 |
404 | User not found |
Doctor Management
نماینده (ROLE_REPRESENTATION): افزودن پزشک و کلینیک برای نماینده از طریق endpointهای جدا انجام میشود —
POST /api/v1/representation/doctorوPOST /api/v1/representation/clinic(بهdocs/api/representation.mdمراجعه کنید). در نسخهی نماینده،representation_idپزشک خودکار روی نمایندهی کاربر جاری ست میشود. endpointهای/api/v1/admin/*همچنان فقطROLE_ADMINهستند.
POST /api/v1/admin/doctors
ساخت پزشک جدید (در صورت نبودِ کاربر با این موبایل، یک User هم ساخته میشود).
Permission: ROLE_ADMIN
Request Body (application/json)
| Field | Type | Required | Description |
|---|---|---|---|
mobile |
string | ✅ | موبایل ورود؛ باید فرمت معتبر موبایل ایران داشته باشد (^09\d{9}$) — ارقام فارسی/عربی به انگلیسی نرمال میشوند |
name |
string | ✅ | نام پزشک |
gender / degree / medical_system_code / info |
string | ❌ | اطلاعات حرفهای |
activity_time |
integer | ❌ | Unix timestamp (ثانیه) تاریخ شروع فعالیت؛ مبنای محاسبهٔ سال تجربه |
specialties |
integer[] | ❌ | آرایهٔ IDهای تخصص. هر شناسهٔ فرزند سمت سرور با تمام والدهایش تا ریشهٔ درخت گسترش مییابد؛ شناسههای ناموجود نادیده گرفته میشوند. |
Errors
| Code | HTTP | Description |
|---|---|---|
VALIDATION |
422 | mobile یا name خالی |
VALIDATION |
422 | mobile فرمت معتبر موبایل ایران ندارد (field: mobile) |
POST /api/v1/admin/clinic
ساخت کلینیک جدید (در صورت نبودِ کاربرِ صاحب با این موبایل، یک User هم ساخته میشود).
Permission: ROLE_ADMIN
Request Body (application/json)
| Field | Type | Required | Description |
|---|---|---|---|
owner_mobile |
string | ✅ | موبایل صاحب کلینیک؛ باید فرمت معتبر موبایل ایران داشته باشد (^09\d{9}$) |
name |
string | ✅ | نام کلینیک |
Errors
| Code | HTTP | Description |
|---|---|---|
VALIDATION |
422 | owner_mobile خالی |
VALIDATION |
422 | owner_mobile فرمت معتبر موبایل ایران ندارد (field: owner_mobile) |
VALIDATION |
422 | name کلینیک خالی |
GET /api/v1/admin/doctors
List all doctors with pagination.
Permission: ROLE_ADMIN
Query Parameters
| Param | Type | Required | Description |
|---|---|---|---|
page |
integer | ❌ | Default: 1 |
limit |
integer | ❌ | Default: 20 |
search |
string | ❌ | Search in title |
status |
string | ❌ | "active" or "inactive" |
gender |
string | ❌ | "male" or "female" |
specialty_id |
integer | ❌ | Filter by specialty |
owner_status |
string | ❌ | claimed | unclaimed | pending_transfer — پروفایلهای ایمپورت IRIMC (خروجی هم owner_status و source دارد) |
unassigned |
string | ❌ | 1 → فقط پزشکانِ بدون نماینده (representation_id IS NULL) — برای انتخاب و اتصال به نماینده |
city_id |
integer | ❌ | فیلتر بر اساس شهرِ آدرسِ خودِ پزشک (doctor_addresses.city_id) |
state_id |
integer | ❌ | فیلتر بر اساس استانِ آدرسِ خودِ پزشک (doctor_addresses.province_id) |
sort |
string | ❌ | Sort field |
Response 200
{
"success": true,
"data": [
{
"uuid": "...",
"title": "علی احمدی",
"degree": "متخصص",
"gender": "male",
"doctor_rate": 4.5,
"active_doctor_appointment": true,
"representation_id": 12,
"representation_uuid": "9c1...",
"representation_name": "علی محمدی"
}
],
"meta": { "totalRecords": 92, "totalPages": 5, "currentPage": 1 }
}
representation_id/representation_uuid/representation_nameنمایندهی مالکِ پزشکاند؛ برای پزشکانِ بدون نماینده (مثل ایمپورتهای IRIMC) هر سهnull.
GET /api/v1/admin/doctors/stats
Get doctor statistics.
Permission: ROLE_ADMIN
Response 200
{
"success": true,
"data": {
"total": 92,
"active": 85,
"inactive": 7,
"male": 60,
"female": 32,
"top_specialty": "قلب و عروق"
}
}
POST /api/v1/admin/doctors/{uuid}/status
Toggle doctor active status.
Permission: ROLE_ADMIN
Response 200
{ "success": true, "data": { "active": false } }
PUT /api/v1/admin/doctors/{uuid}/representation
ست کردن یا حذف نمایندهی یک پزشک (از صفحهی پروفایل پزشک در پنل ادمین).
Permission: ROLE_ADMIN
Request Body
{ "representation_id": 12 }
representation_id: null(یا حذفشده/خالی) → نمایندهی پزشک حذف میشود (representation_id = NULL). بر خلافPOST /representations/{uuid}/doctors، این endpoint اجازهی تغییر نمایندهی پزشکی که از قبل نماینده دارد را هم میدهد.
Response 200
{
"success": true,
"data": {
"message": "نماینده ثبت شد",
"representation": { "id": 12, "uuid": "9c1...", "full_name": "علی محمدی" }
}
}
برای حذف،
representationبرابرnullبرمیگردد.
Errors
| Code | HTTP | Description |
|---|---|---|
DOCTOR_NOT_FOUND |
404 | پزشک یافت نشد |
ERR_NOT_FOUND_001 |
404 | نماینده یافت نشد |
Clinic Management
GET /api/v1/admin/clinics
List all clinics with pagination.
Permission: ROLE_ADMIN
Query Parameters
| Param | Type | Required | Description |
|---|---|---|---|
page |
integer | ❌ | Default: 1 |
limit |
integer | ❌ | Default: 20 |
search |
string | ❌ | Search by clinic name |
status |
string | ❌ | "active" or "inactive" |
Response 200
{
"success": true,
"data": [
{
"uuid": "...",
"name": "کلینیک الوند",
"city": "تهران",
"telephone": "02112345678",
"is_active": true,
"created_at": 1717000000
}
],
"meta": { "totalRecords": 34, "totalPages": 2, "currentPage": 1 }
}
PATCH /api/v1/admin/clinic/{uuid}/status
Toggle clinic active/inactive.
Permission: ROLE_ADMIN
Response 200
{ "success": true, "data": { "is_active": false } }
Errors
| Code | HTTP | Description |
|---|---|---|
ERR_NOT_FOUND_001 |
404 | Clinic not found |
DELETE /api/v1/admin/clinic/{uuid}
Delete a clinic.
Permission: ROLE_ADMIN
Side effect: the clinic's insurance configuration (
tenant_insurances,entity_insurance_pricing, and theirtenant_service_coverages) is purged in the same request — polymorphicentity_id, cleaned up at the application level.
Response 200
{ "success": true, "data": { "message": "کلینیک حذف شد" } }
Appointment Management
GET /api/v1/admin/appointments/today-stats
Get appointment statistics for a specific date (defaults to today).
Permission: ROLE_ADMIN
Query Parameters
| Param | Type | Required | Description |
|---|---|---|---|
date |
string (YYYY-MM-DD) | ❌ | Default: today |
Response 200
{
"success": true,
"data": {
"total": 47,
"completed": 20,
"waiting": 18,
"cancelled": 9
}
}
GET /api/v1/admin/appointments
List appointments filtered by date and/or doctor. Sorted by slot_start ASC.
Permission: ROLE_ADMIN
Query Parameters
| Param | Type | Required | Description |
|---|---|---|---|
page |
integer | ❌ | Default: 1 |
limit |
integer | ❌ | Default: 15, max: 500 |
search |
string | ❌ | Search by patient name/mobile or doctor name |
status |
string | ❌ | Filter by status |
date |
string (YYYY-MM-DD) | ❌ | Filter by slot date |
doctor_uuid |
string | ❌ | Filter by doctor UUID |
Response 200
{
"success": true,
"data": [
{
"uuid": "appt-uuid",
"patient_name": "محمد رضایی",
"patient_mobile": "09123456789",
"doctor_uuid": "doctor-uuid",
"doctor_name": "علی احمدی",
"slot_start": 1718438400,
"slot_end": 1718439600,
"appointment_date": "2025-06-15",
"appointment_time": "09:00",
"end_time": "09:20",
"status": "confirmed",
"version": 1,
"created_at": "2025-06-14T10:30:00+03:30"
}
],
"meta": { "totalRecords": 47, "totalPages": 1, "currentPage": 1 }
}
Status values: pending | confirmed | completed | cancelled_by_doctor | cancelled_by_user | no_show | expired
POST /api/v1/admin/appointment
Create a new appointment for a patient. If no user exists with the given mobile, a new user account is created automatically.
Permission: ROLE_ADMIN
Request Body
{
"doctor_uuid": "doctor-uuid",
"slot_start": 1718438400,
"slot_end": 1718439600,
"patient_mobile": "09123456789",
"patient_name": "علی محمدی",
"patient_national_code": "0012345678",
"service_item_uuids": ["service-uuid-1", "service-uuid-2"],
"duration_from_services": false,
"service_durations": { "service-uuid-1": 75 },
"visit_price_rials": 3000000,
"note": "optional note"
}
patient_mobile،patient_nameوpatient_national_codeهر سه اجباری هستند. کد ملی باید ۱۰ رقم معتبر باشد و روی پروفایل بیمار ذخیره میشود (profiles.national_code، یکتا). بیمار اول با کد ملیِ پروفایل و سپس با موبایل resolve میشود، تا پرونده برای هر کد ملی یکتا بماند (یک شخص میتواند چند موبایل داشته باشد). اگر بیماری یافت نشود، کاربر جدید (ROLE_USER) بههمراه پروفایلِ حاملِ همان کد ملی ساخته میشود.
service_item_uuids[](اختیاری): یک یا چند سرویس که به نوبت پیوست میشوند؛ اولین سرویس = سرویسِ اصلی و همه درservice_itemsپاسخ برمیگردند. UUID ناموجود ⇒422. باduration_from_services: trueمدت نوبت از مجموعduration_minutesسرویسها محاسبه وslot_endبازنویسی میشود (سرویسِ غیرbookable/بدون مدت ⇒422)؛ بدون آن ساعت پایانِ دستی حفظ میماند.service_durations({ "<uuid>": <minutes> }، فقط باduration_from_services=true): override مدتِ هر سرویس برای همان نوبت؛ پیشفرضِ سرویس در تنظیمات تغییر نمیکند.
visit_price_rials(اختیاری/شرطی): هزینه ویزیت (ریال) که روی نوبت ذخیره میشود. اگر فلگrequire_visit_priceدر insurance-pricing برای پزشک (یا کلینیکِ واحد او در نبود ردیف پزشک) فعال باشد، مقدار> 0الزامی است.
Response 201
{
"success": true,
"data": {
"uuid": "appt-uuid",
"slot_start": 1718438400,
"slot_end": 1718439600,
"status": "pending"
}
}
Error Responses
| Code | HTTP | Description |
|---|---|---|
VALIDATION |
422 | Missing required fields (doctor_uuid, slot_start, slot_end, patient_mobile, patient_name), or missing/invalid patient_national_code (field: patient_national_code), or required visit_price_rials <= 0 when require_visit_price is on (field: visit_price_rials) |
ERR_PROFILE_MOBILE_TAKEN |
422 | این شماره موبایل با کد ملی دیگری ثبت شده است (field: patient_mobile) |
DOCTOR_NOT_FOUND |
404 | Doctor UUID not found |
SLOT_TAKEN |
409 | Slot already booked |
Payment Management
GET /api/v1/admin/payments
List all payments.
Permission: ROLE_ADMIN
Query Parameters
| Param | Type | Required | Description |
|---|---|---|---|
page |
integer | ❌ | Default: 1 |
limit |
integer | ❌ | Default: 20 |
status |
string | ❌ | "pending", "paid", "failed", "cancelled" |
Query Parameters (تکمیل)
| Param | Type | Required | Description |
|---|---|---|---|
search |
string | ❌ | جستجو در موبایل کاربر، reference_id یا order_id |
Response 200
{
"success": true,
"data": [
{
"uuid": "...",
"amount": 500000,
"status": "success",
"gateway": "mellat",
"ref_id": "1234567",
"patient_mobile": "0912...",
"paid_at": "2026-07-02T09:00:00+03:30",
"created_at": "2026-07-02T08:55:00+03:30"
}
],
"meta": { "totalRecords": 7800, "totalPages": 390, "currentPage": 1 }
}
amountبر حسب ریال،ref_idهمانreference_idدرگاه،paid_atفقط برای پرداختsuccess(بر اساسupdated_at) و در غیر اینصورتnull. تاریخها ISO-8601.
GET /api/v1/admin/payments/{uuid}
جزئیات یک پرداخت. پاسخ تخت است (data مستقیم آبجکت پرداخت، نه nested).
Permission: ROLE_ADMIN
Response 200
{
"success": true,
"data": {
"uuid": "...",
"order_id": "ORD-XXXX",
"amount": 500000,
"status": "success",
"gateway": "mellat",
"type": "appointment",
"ref_id": "1234567",
"card_pan": "502229******2928",
"patient_mobile": "0912...",
"patient_name": "علی احمدی",
"appointment_uuid": "...",
"paid_at": "2026-07-02T09:00:00+03:30",
"created_at": "2026-07-02T08:55:00+03:30"
}
}
card_panشمارهٔ کارت ماسکشدهٔ پرداختکننده (۶ رقم اول + ۴ رقم آخر) است که درگاه در callback برمیگرداند (ملت:CardHolderPan) و درmetadata.card_panپرداخت ذخیره میشود؛ اگر درگاه آن را نفرستدnull.refunds[]تاریخچهٔ استردادها (amountریال،refشماره پیگیری،atunix).
POST /api/v1/admin/payments/{uuid}/refund
استرداد وجه یک پرداخت موفق (کل یا جزئی). فقط ROLE_ADMIN. فقط درگاه ملت پشتیبانی میشود (سپ خطا میدهد).
Request body:
| فیلد | نوع | توضیح |
|---|---|---|
amount |
integer? | مبلغ استرداد به ریال. اگر ندهی = کل باقیماندهٔ قابل استرداد. |
استرداد جزئی چندباره مجاز است تا سقف مبلغ خرید. استرداد کامل (رسیدن جمع به مبلغ کل) وضعیت را refunded میکند.
Response 200:
{ "success": true, "data": { "status": "refunded", "refund_ref": "183800538958" } }
کد
0درگاه ملت فقط «پذیرش اولیهٔ درخواست استرداد» است؛ عودت نهایی به کارت ممکن است چند روز طول بکشد.
POST /api/v1/admin/payments/{uuid}/reverse
برگشت وجه یک پرداخت موفقِ settleنشده (بدون body). فقط ROLE_ADMIN. در موفقیت وضعیت refunded.
Response 200: { "success": true, "data": { "status": "refunded" } }
Errors (payment refund/reverse/detail)
| Code | HTTP | Description |
|---|---|---|
ERR_NOT_FOUND_001 |
404 | پرداخت یافت نشد |
ERR_PAYMENT_002 |
422 | مبلغ نامعتبر / پرداخت غیرقابل استرداد / درگاه پشتیبانی نمیکند |
Settlement Management
GET /api/v1/admin/settlements
List all settlement requests.
Permission: ROLE_ADMIN
Query Parameters
| Param | Type | Required | Description |
|---|---|---|---|
page |
integer | ❌ | Default: 1 |
limit |
integer | ❌ | Default: 20 |
status |
string | ❌ | "pending", "approved", "rejected" |
Response 200
{
"success": true,
"data": [
{
"uuid": "...",
"user": { "uuid": "...", "real_name": "علی احمدی" },
"amount_rials": 1000000,
"status": "pending",
"bank_account": { "bank_name": "بانک ملت", "owner_name": "..." },
"created_at": 1717000000
}
],
"meta": { "totalRecords": 45, "totalPages": 3, "currentPage": 1 }
}
To approve or reject, use the Settlement API:
POST /api/v1/settlement/{uuid}/approveor/reject
Representation Management
GET /api/v1/admin/representations
List all representations.
Permission: ROLE_ADMIN
Query Parameters
| Param | Type | Required | Description |
|---|---|---|---|
page |
integer | ❌ | Default: 1 |
limit |
integer | ❌ | Default: 15 |
search |
string | ❌ | Search by name or mobile (representation's or linked user's) |
city_id |
integer | ❌ | Filter by city (عضویت در شهرهای چندگانهی نماینده — representation_cities) |
Response 200
Paginated representation list. Each item:
{
"id": 3,
"uuid": "...",
"full_name": "حامد حسینی",
"mobile_number": "09120671756",
"domain": "x-nobat.ir",
"is_global": true,
"city_id": 132,
"city_ids": [132, 108],
"cities": [{ "id": 132, "name": "یزد" }, { "id": 108, "name": "تهران" }],
"city": "یزد، تهران",
"commission_percent": 10.0,
"wallet_balance": 0,
"doctor_count": 14,
"appointment_count": 231,
"is_active": true,
"created_at": "2026-06-18T..."
}
city_id= اولین شهر (BC)؛city= نام شهرها با «،».is_global=trueیعنی نماینده سراسری (badge در پنل).
doctor_count= تعداد پزشکانِrepresentation_id = r.id؛appointment_count= تعداد نوبتهای آن پزشکان. هر دو با کوئری گروهی محاسبه میشوند (بدون N+1).
mobile_numberfalls back to the linked user's mobile when the representation's ownmobile_numbercolumn is empty.
Deactivation, not deletion: the admin panel deactivates a representation via
PATCH /api/v1/representation/{uuid}with{ "active": false }rather than callingDELETE.
GET /api/v1/admin/representations/{uuid}/doctors
پزشکان زیرمجموعهی یک نماینده (paginated).
Permission: ROLE_ADMIN
Query Parameters
| Param | Type | Required | Description |
|---|---|---|---|
page |
integer | ❌ | Default: 1 |
limit |
integer | ❌ | Default: 15 (max 100) |
Response 200
Paginated. Each item:
{
"uuid": "...",
"id": 45,
"name": "علی احمدی",
"gender": "man",
"medical_code": "12345",
"is_active": true,
"owner_status": "claimed",
"past_count": 12,
"upcoming_count": 5,
"created_at": "2026-06-18T..."
}
past_count= نوبتهایslot_start < now؛upcoming_count=slot_start >= now.
Errors
| Code | HTTP | Description |
|---|---|---|
ERR_NOT_FOUND_001 |
404 | نماینده یافت نشد |
POST /api/v1/admin/representations/{uuid}/doctors
اتصال یک پزشکِ موجودِ بدون نماینده به این نماینده (representation_id ست میشود).
Permission: ROLE_ADMIN
Request Body
{ "doctor_uuid": "550e8400-..." }
Response 200
{ "success": true, "data": { "message": "پزشک به نماینده متصل شد", "doctor_uuid": "550e8400-...", "representation_id": 12 } }
Errors
| Code | HTTP | Description |
|---|---|---|
ERR_VALIDATION_001 |
422 | doctor_uuid ارسال نشده |
ERR_NOT_FOUND_001 |
404 | نماینده یا پزشک یافت نشد |
ERR_CONFLICT_001 |
409 | پزشک از قبل به یک نماینده متصل است |
GET /api/v1/admin/representations/{uuid}/appointments
نوبتهای پزشکانِ زیرمجموعهی یک نماینده (paginated).
Permission: ROLE_ADMIN
Query Parameters
| Param | Type | Required | Description |
|---|---|---|---|
scope |
string | ❌ | upcoming (پیشفرض، slot_start >= now، صعودی) یا past (slot_start < now، نزولی) |
page |
integer | ❌ | Default: 1 |
limit |
integer | ❌ | Default: 15 (max 100) |
Response 200
Paginated. Each item:
{
"uuid": "...",
"slot_start": 1750000000,
"slot_end": 1750001800,
"status": "confirmed",
"patient_name": "علی رضایی",
"doctor_uuid": "...",
"doctor_name": "علی احمدی"
}
slot_start/slot_endUnix timestamp (ثانیه).
Errors
| Code | HTTP | Description |
|---|---|---|
ERR_NOT_FOUND_001 |
404 | نماینده یافت نشد |
Secretary Management
GET /api/v1/admin/secretaries
List all secretaries.
Permission: ROLE_ADMIN
Query Parameters
| Param | Type | Required | Description |
|---|---|---|---|
page |
integer | ❌ | Default: 1 |
limit |
integer | ❌ | Default: 20 |
search |
string | ❌ | Search by mobile |
Response 200
Paginated secretary list with linked doctor info.
Rating & Comment Management
GET /api/v1/admin/rates
List all ratings.
Permission: ROLE_ADMIN
Query Parameters
| Param | Type | Required | Description |
|---|---|---|---|
page |
integer | ❌ | Default: 1 |
limit |
integer | ❌ | Default: 20 |
search |
string | ❌ | Search by doctor/patient |
Ratings are multi-dimensional (five 0–100 dimensions). Each row's
overallis the mean of the five dimensions (0–100) andscoreis that mean on a 0–5 scale (overall / 20).
GET /api/v1/admin/comments
List all comments (all statuses).
Permission: ROLE_ADMIN
Query Parameters
| Param | Type | Required | Description |
|---|---|---|---|
page |
integer | ❌ | Default: 1 |
limit |
integer | ❌ | Default: 20 |
search |
string | ❌ | Search in body |
status |
string | ❌ | "pending", "approved", "rejected" |
To approve/reject comments, use the Rating API:
POST /api/v1/admin/comment/{uuid}/approveor/reject
SMS Management (Admin)
GET /api/v1/admin/sms/logs
List SMS send logs.
Permission: ROLE_ADMIN
Query Parameters
| Param | Type | Required | Description |
|---|---|---|---|
page |
integer | ❌ | Default: 1 |
limit |
integer | ❌ | Default: 15, max 100 |
tag |
string | ❌ | فیلتر بر اساس تگ: global | otp | payment | clinic_invitation | pre_registration | notification_mobile | user_template |
Response 200
{
"success": true,
"data": [
{
"uuid": "...",
"recipient": "09123456789",
"message": "کد تأیید: 123456",
"status": "sent",
"provider": "kavenegar",
"tag": "otp",
"template": "clinicpro-otp",
"sent_at": "2026-06-19T...",
"created_at": "2026-06-19T..."
}
],
"meta": { "totalRecords": 5000, "totalPages": 334, "currentPage": 1 }
}
tagنوع پیامک را مشخص میکند؛ پیشفرض پیامکهای سیستمیِ بیبرچسبglobalاست.templateنام الگوی VerifyLookup کاوهنگار است که پیامک با آن ارسال شده (مثلاًclinicpro-otp)؛nullاگر ارسال نشده باشد (مثلاً رد بهدلیل نبود الگو در سیاست lookup-only).
GET /api/v1/admin/sms/templates
List all SMS templates.
Permission: ROLE_ADMIN
Response 200
{
"success": true,
"data": [
{
"uuid": "...",
"name": "تأیید نوبت",
"status": "approved",
"provider_code": "verify_appointment",
"created_at": 1717000000
}
]
}
To create/approve/reject templates, see sms.md
Clinic Invitation Management
See clinic-invitation.md for full endpoint details.
| Endpoint | Description |
|---|---|
POST /api/v1/admin/clinic/{uuid}/invite-doctor |
Send invitation |
GET /api/v1/admin/clinic/{uuid}/invitations |
List invitations |
POST /api/v1/admin/clinic/invitation/{invUuid}/resend |
Resend SMS |
PATCH /api/v1/admin/clinic/invitation/{invUuid}/status |
Change status |
DELETE /api/v1/admin/clinic/invitation/{invUuid} |
Delete |
Settings
GET /api/v1/admin/settings
Returns all site configuration values.
Response 200
{
"success": true,
"data": {
"commission_enabled": "0",
"commission_percent": "0",
"site_name": "ClinicPro",
"support_phone": "",
"max_cancel_hours_before": "24",
"appointment_reminder_hours": "2",
"log_retention_days": "90",
"payment_test_mode": "0",
"mellat_enabled": "1",
"mellat_terminal_id": "",
"mellat_username": "",
"mellat_password": "",
"sep_enabled": "1",
"sep_terminal_id": "",
"sms_provider": "kavenegar",
"kavenegar_api_key": "",
"kavenegar_sender": "",
"sms_price_rials": "500"
}
}
All values are strings. Missing keys return their default values.
PATCH /api/v1/admin/settings
Update one or more settings. Unknown keys are silently ignored.
Request body (partial update — send only keys to change):
{
"commission_enabled": "1",
"commission_percent": "5",
"site_name": "کلینیکپرو",
"payment_test_mode": "1",
"mellat_terminal_id": "12345678",
"mellat_username": "user",
"mellat_password": "pass",
"sep_terminal_id": "87654321",
"sms_provider": "kavenegar",
"kavenegar_api_key": "your-api-key",
"kavenegar_sender": "10008664",
"sms_price_rials": "500"
}
Response 200 — same shape as GET, returns all settings after save.
Commission rules:
commission_enabled—"1"= active,"0"= inactivecommission_percent— integer string,0–100- Commission applies only to regular users (
booked_by = user); secretaries are exempt
Payment gateway rules:
payment_test_mode—"1"= all payments use MockGateway (no real bank calls),"0"= real gatewaysmellat_enabled/sep_enabled—"1"= gateway selectable,"0"= gateway disabled (hidden from the payment selection list and rejected atinitiate). Unset = enabled (default).payment_allowed_frontend_hosts— comma-separated hosts allowed as a paymentfrontend_address(origin site to return to). Falls back to theALLOWED_FRONTEND_HOSTSenv var when empty. Add a consumer site's host here to permit its payments.- Gateway credentials (mellat/sep) read from DB first, fallback to env vars if DB value is empty
- MockGateway callback: same URL pattern +
&mock=1&ResCode=0&RefId=MOCK-{orderId}(add&cancel=1to simulate a user cancellation →canceled)
SMS provider rules:
sms_provider—"kavenegar"or"rangineh"- Kavenegar API key and sender read from DB first, fallback to env vars
KAVENEGAR_API_KEY,KAVENEGAR_SENDER
Maintenance Mode
A single switch that takes the whole platform offline — the public site, the admin SPA's data calls, every /api/v1/* endpoint, and therefore nobat724_front and clinic-pro-tauri too. Enforced centrally by App\Shared\EventSubscriber\MaintenanceSubscriber; no controller checks it itself.
Settings keys
Managed through the same GET/PATCH /api/v1/admin/settings endpoints (whitelisted in SiteConfigController::ALLOWED_KEYS). Admin panel: /admin/settings → بخش «حالت تعمیرات».
| Key | Default | Description |
|---|---|---|
maintenance_enabled |
"0" |
"1"/"true"/"on"/"yes" = maintenance active |
maintenance_title |
در حال بهروزرسانی سیستم |
Heading of the HTML maintenance page |
maintenance_message |
سامانه موقتاً ... |
Shown both on the HTML page and as the API error message |
maintenance_retry_after |
"600" |
Seconds; sent as the Retry-After response header |
maintenance_allowed_ips |
"" |
Comma-separated IPs that bypass maintenance without logging in |
Changing any maintenance_* key invalidates the 30-second MaintenanceService cache immediately, so a toggle takes effect on the next request.
Behaviour while enabled
API requests (path starts with /api/, or Accept: application/json, or X-Requested-With: XMLHttpRequest):
HTTP/1.1 503 Service Unavailable
Retry-After: 600
{
"success": false,
"data": null,
"errors": [
{ "code": "MAINTENANCE_MODE", "message": "<maintenance_message>" }
]
}
The envelope is identical to BaseController::error(), so existing clients parse it unchanged. Clients should detect maintenance by both status === 503 and errors[0].code === "MAINTENANCE_MODE" — a bare 503 may come from a reverse proxy.
Browser requests — templates/maintenance.html.twig rendered with HTTP 503, same Retry-After header, noindex, nofollow.
Both the kernel.request (priority 6) and kernel.exception (priority 20) paths are covered, so routing 404/405 and firewall 401 responses also return maintenance rather than leaking their normal errors.
Who gets through
- Whitelisted paths — never blocked, in this order of importance:
/oauth/*,/api/v1/user/{login,send-code,verify-code,otp-login},/session/token(admins must still be able to sign in),/api/v1/admin/settings(the only way to turn maintenance back off),/health,/admin*(the SPA shell HTML — its data calls are still guarded),/build/*,/favicon.ico,/_wdt,/_profiler. maintenance_allowed_ips— exact client-IP match.ROLE_ADMIN— resolved from the firewall token. Several admin-panel endpoints (/api/v1/doctors,/api/v1/categorys/*, …) live in thepublic_endpointsfirewall withsecurity: false, where no token is ever resolved; for those the subscriber falls back to decoding theAuthorization: BearerJWT and checking itsrolesclaim. An invalid or forged token does not bypass.
Every other role — ROLE_DOCTOR, ROLE_CLINIC, ROLE_SECRETARY, ROLE_REPRESENTATION — is blocked.
Console escape hatch
If the admin panel is unreachable:
ddev exec php bin/console app:maintenance status
ddev exec php bin/console app:maintenance on
ddev exec php bin/console app:maintenance off
Failure behaviour
MaintenanceService is fail-open: if Redis is unavailable it reads straight from the database, and if the database also fails it reports maintenance as disabled. This layer must never become the cause of an outage.
Pre-Registration Management
GET /api/v1/admin/pre-registrations
List pre-registration requests. Permission: ROLE_ADMIN
Query params:
| Param | Default | Notes |
|---|---|---|
page |
1 | |
limit |
20 | max 50 |
status |
pending |
pending | approved | rejected | all |
Response 200 (paginated):
{
"success": true,
"data": [
{
"uuid": "...",
"type": "independent_doctor",
"name": "احمدی",
"mobile": "09121234567",
"info": "متخصص داخلی",
"status": "pending",
"admin_note": null,
"created_at": 1718000000
}
],
"meta": { "totalRecords": 5, "totalPages": 1, "currentPage": 1 }
}
POST /api/v1/admin/pre-registrations/{uuid}/approve
Approve a pending request. Creates User + Doctor/Clinic entity based on type, resets password, sends SMS. Permission: ROLE_ADMIN
Response 200:
{ "success": true, "data": { "message": "تأیید شد و اطلاعات ورود ارسال گردید" } }
Error Codes:
| Code | HTTP | Meaning |
|---|---|---|
NOT_FOUND |
404 | UUID not found |
ALREADY_PROCESSED |
409 | Status is not pending |
POST /api/v1/admin/pre-registrations/{uuid}/reject
Reject a pending request. Permission: ROLE_ADMIN
Request body (optional):
{ "note": "مدارک ناقص است" }
Response 200:
{ "success": true, "data": { "message": "درخواست رد شد" } }
موتور مالی نمایندگی
تنظیمات مالی از طریق GET/PATCH /api/v1/admin/settings کنترل میشوند (کلیدها در whitelist SiteConfigController::ALLOWED_KEYS):
| کلید | پیشفرض | شرح |
|---|---|---|
appointment_commission_enabled |
0 |
فعالسازی پورسانت نوبت (درصد از Representation.commission_percent هر نماینده) |
upgrade_commission_enabled |
0 |
فعالسازی پورسانت ارتقاء اشتراک |
upgrade_commission_percent |
20 |
درصد پورسانت ارتقاء (سراسری) |
tax_enabled |
0 |
فعالسازی مالیات بر ارزش افزوده |
tax_percent |
10 |
درصد مالیات |
sms_panel_fee_rials |
1500000 |
هزینه ثابت پنل پیامک به ریال (از نوبت و اشتراک کسر میشود) |
sms_price_rials |
500 |
هزینه هر پیامک ارسالی به ریال؛ مبنای محاسبهٔ تعداد پیامک از موجودی کیفپول (GET /api/v1/sms/wallet/balance). قابل ویرایش در /admin/settings → بخش پیامک |
appointment_fee_rials |
150000 |
مبلغ هر نوبت به ریال؛ مبلغی که بیمار هنگام رزرو آنلاین پرداخت میکند. backend از همین کلید میخواند و در GET /api/v1/payment/config expose میشود |
log_retention_days |
90 |
مدت نگهداری لاگها (روز)؛ کاماند روزانه app:prune-logs لاگهای قدیمیتر را حذف میکند. 0 = نگهداری نامحدود |
altcha_enabled |
(از env) | فعال/غیرفعال کپچای ALTCHA (1/0). اگر ست شود بر ALTCHA_ENABLED env مقدم است؛ اگر ست نشده باشد GET مقدار مؤثرِ env را برمیگرداند. رجوع به captcha.md |
ترتیب محاسبه (در CommissionService): ۱) کسر sms_panel_fee_rials ۲) مالیاتِ استخراجی afterSms × tax/(100+tax) ۳) پورسانت = netAfterTax × percent/100. سهم نماینده به کیفپولش (WalletTransaction credit) واریز و یک ردیف FinancialBreakdown ثبت میشود (idempotent بر اساس payment_id).
هر تغییر
tax_percent/tax_enabledدر یک ردیفTaxRateHistoryثبت و ازGET /api/v1/admin/settings/tax-historyقابل مشاهده است.
GET /api/v1/admin/financial-breakdowns
لیست تفکیک مالی تراکنشها (paginated). Permission: ROLE_ADMIN
Query Parameters:
| Param | Type | Description |
|---|---|---|
page |
integer | پیشفرض 1 |
limit |
integer | پیشفرض 15، حداکثر 100 |
representation_id |
integer | فیلتر نماینده |
source |
string | appointment یا subscription |
from |
integer | Unix timestamp شروع بازه |
to |
integer | Unix timestamp پایان بازه |
Response 200 (paginated):
{
"success": true,
"data": [
{
"uuid": "…", "order_id": "ORD-…", "source": "appointment",
"gross_rials": 2000000, "sms_fee_rials": 1500000,
"tax_percent": 10, "tax_rials": 45455, "net_after_tax_rials": 454545,
"commission_percent": 20, "representation_share_rials": 90909,
"system_share_rials": 363636,
"representation_id": 3, "representation_name": "نماینده یزد",
"doctor_id": 12, "clinic_id": null, "created_at": "2026-06-24T…"
}
],
"meta": { "totalRecords": 1, "totalPages": 1, "currentPage": 1 }
}
GET /api/v1/admin/financial-summary
جمع کل مبالغ. Permission: ROLE_ADMIN
Response 200:
{
"success": true,
"data": {
"total_gross": 2000000,
"total_representation_income": 90909,
"total_tax_collected": 45455,
"total_sms_fee": 1500000,
"total_system_share": 363636
}
}
GET /api/v1/admin/settings/tax-history
تاریخچهی تغییرات مالیات بر ارزش افزوده (۵۰ ردیف آخر، نزولی). هر بار که tax_percent یا tax_enabled از طریق PATCH /api/v1/admin/settings تغییر کند، یک ردیف با کاربرِ تغییردهنده ثبت میشود. Permission: ROLE_ADMIN
Response 200:
{
"success": true,
"data": [
{ "tax_percent": 10, "enabled": true, "changed_by_name": "مدیر سیستم", "changed_at": 1782800000 }
]
}
GET /api/v1/admin/settlement/{uuid}
جزئیات یک درخواست تسویه (برای صفحهی /admin/settlements/{uuid}). Permission: ROLE_ADMIN
Response 200:
{
"success": true,
"data": {
"uuid": "...",
"representation_name": "نماینده یزد",
"representation_mobile": "09390036732",
"amount": 500000,
"status": "pending",
"bank_card": "6037...", "bank_name": "ملت", "bank_iban": "IR...", "bank_owner": "...",
"reject_reason": null,
"requested_at": "2026-06-24T...", "processed_at": null
}
}
تأیید/رد از طریق POST /api/v1/settlement/{uuid}/approve|reject (در docs/api/settlement.md).
Errors: NOT_FOUND (404) — درخواست یافت نشد.
Application Logs
Persisted application logs (warning level and above). Written by the DbLogger
decorator over the logger service into the app_log table — every
LoggerInterface::warning()/error()/critical()/... call across the backend lands
here, while info/debug go to stderr only.
GET /api/v1/admin/logs
List persisted logs with pagination and filters.
Permission: ROLE_ADMIN
Query Parameters
| Param | Type | Required | Description |
|---|---|---|---|
page |
integer | ❌ | Default: 1 |
limit |
integer | ❌ | Default: 25 (max 100) |
level |
string | ❌ | Exact PSR level: warning, error, critical, alert, emergency |
search |
string | ❌ | Substring match on the message |
from |
integer | ❌ | Unix timestamp lower bound (created_at >=) |
to |
integer | ❌ | Unix timestamp upper bound (created_at <=) |
Ordered by newest first (id DESC).
Response 200
{
"success": true,
"data": [
{
"id": 4213,
"level": "error",
"message": "Unhandled exception: RuntimeException: boom @ /var/www/html/src/Foo.php:42 [path=/oauth/userinfo]",
"context": "{\"exception\":\"RuntimeException: boom @ /var/www/html/src/Foo.php:42\"}",
"channel": "app",
"path": "/oauth/userinfo",
"created_at": 1717000000
}
],
"meta": { "totalRecords": 137, "totalPages": 6, "currentPage": 1 }
}
Notes:
contextis a JSON string (ornull); aThrowablein the context is stored as a compactClass: message @ file:linestring, never the raw object.created_atis a Unix timestamp (integer).
GET /api/v1/admin/logs/export
Export all matching logs as a CSV file (no pagination). Respects the same level, search, from, to filters as the list endpoint. Ordered newest first (id DESC).
Permission: ROLE_ADMIN
Query Parameters
| Param | Type | Required | Description |
|---|---|---|---|
level |
string | ❌ | Exact PSR level: warning, error, critical, alert, emergency |
search |
string | ❌ | Substring match on the message |
from |
integer | ❌ | Unix timestamp lower bound (created_at >=) |
to |
integer | ❌ | Unix timestamp upper bound (created_at <=) |
Response 200
Content-Type: text/csv; charset=UTF-8Content-Disposition: attachment; filename="logs-YYYYMMDD-HHMMSS.csv"- Streamed CSV with a UTF-8 BOM (Excel-friendly for Persian). Columns:
id, level, message, context, channel, path, created_at.created_atis formatted asY-m-d H:i:s.
DELETE /api/v1/admin/logs
Delete all persisted logs (truncate the app_log table). Irreversible.
Permission: ROLE_ADMIN
Response 200
{ "success": true, "data": { "deleted": 137 } }
deleted— number of rows removed.
Log Retention
Logs are pruned automatically based on the log_retention_days setting (see Site Settings — GET/PATCH /api/v1/admin/settings, whitelisted key log_retention_days, default 90, 0 = keep forever).
- A daily scheduled task (
App\Shared\Logging\Message\PruneLogsMessage, registered insrc/Schedule.php, routed toscheduler_default) deletes logs older thanlog_retention_days. - Manual prune:
php bin/console app:prune-logs(reads the same setting, deletes older-than-retention rows, prints the count). - Requires the scheduler worker:
php bin/console messenger:consume scheduler_default.