# Admin API > **Prefix:** `/api/v1/admin` > **Permission:** ALL endpoints in this file require `ROLE_ADMIN` > **Headers:** `Authorization: Bearer ` --- ## Dashboard ### GET `/api/v1/admin/dashboard/stats` Get key performance indicators (KPIs) for the dashboard. **Permission:** `ROLE_ADMIN` ### Response `200` ```json { "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` ```json { "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` ```json { "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` ```json { "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` ```json { "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` ```json { "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`) ```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`) ```json { "role": "ROLE_DOCTOR" } ``` | Field | Type | Required | Allowed Values | |-------|------|----------|----------------| | `role` | string | ✅ | `ROLE_USER`, `ROLE_DOCTOR`, `ROLE_CLINIC`, `ROLE_SECRETARY`, `ROLE_ADMIN` | ### Response `200` ```json { "success": true, "data": { "message": "نقش کاربر تغییر کرد", "roles": ["ROLE_DOCTOR"] } } ``` --- ### POST `/api/v1/admin/users/{uuid}/status` Toggle user active/inactive status. **Permission:** `ROLE_ADMIN` ### Response `200` ```json { "success": true, "data": { "status": "inactive" } } ``` --- ### DELETE `/api/v1/admin/users/{uuid}` Delete a user. **Permission:** `ROLE_ADMIN` ### Response `200` ```json { "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` ```json { "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` ```json { "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` ```json { "success": true, "data": { "active": false } } ``` --- ### PUT `/api/v1/admin/doctors/{uuid}/representation` ست کردن یا حذف نماینده‌ی یک پزشک (از صفحه‌ی پروفایل پزشک در پنل ادمین). **Permission:** `ROLE_ADMIN` ### Request Body ```json { "representation_id": 12 } ``` > `representation_id: null` (یا حذف‌شده/خالی) → نماینده‌ی پزشک حذف می‌شود (`representation_id = NULL`). بر خلاف `POST /representations/{uuid}/doctors`، این endpoint اجازه‌ی **تغییر** نماینده‌ی پزشکی که از قبل نماینده دارد را هم می‌دهد. ### Response `200` ```json { "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` ```json { "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` ```json { "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 their `tenant_service_coverages`) is purged in the same request — polymorphic `entity_id`, cleaned up at the application level. ### Response `200` ```json { "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` ```json { "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` ```json { "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 ```json { "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` (`{ "": }`، فقط با `duration_from_services=true`): override مدتِ هر سرویس برای همان نوبت؛ پیش‌فرضِ سرویس در تنظیمات تغییر نمی‌کند. > > `visit_price_rials` (اختیاری/شرطی): هزینه ویزیت (ریال) که روی نوبت ذخیره می‌شود. اگر فلگ `require_visit_price` در [insurance-pricing](insurance.md) برای پزشک (یا کلینیکِ واحد او در نبود ردیف پزشک) فعال باشد، مقدار `> 0` الزامی است. ### Response `201` ```json { "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` ```json { "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` ```json { "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` شماره پیگیری، `at` unix). ### POST `/api/v1/admin/payments/{uuid}/refund` استرداد وجه یک پرداخت **موفق** (کل یا جزئی). فقط `ROLE_ADMIN`. فقط درگاه ملت پشتیبانی می‌شود (سپ خطا می‌دهد). **Request body:** | فیلد | نوع | توضیح | |------|-----|-------| | `amount` | integer? | مبلغ استرداد به **ریال**. اگر ندهی = کل باقی‌ماندهٔ قابل استرداد. | استرداد جزئی چندباره مجاز است تا سقف مبلغ خرید. استرداد کامل (رسیدن جمع به مبلغ کل) وضعیت را `refunded` می‌کند. **Response 200:** ```json { "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` ```json { "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}/approve` or `/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: ```json { "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_number` falls back to the linked user's mobile when the representation's own `mobile_number` column is empty. > **Deactivation, not deletion:** the admin panel deactivates a representation via `PATCH /api/v1/representation/{uuid}` with `{ "active": false }` rather than calling `DELETE`. --- ### 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: ```json { "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 ```json { "doctor_uuid": "550e8400-..." } ``` ### Response `200` ```json { "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: ```json { "uuid": "...", "slot_start": 1750000000, "slot_end": 1750001800, "status": "confirmed", "patient_name": "علی رضایی", "doctor_uuid": "...", "doctor_name": "علی احمدی" } ``` > `slot_start`/`slot_end` Unix 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. هر ردیف علاوه بر مجوزها، `online_share_enabled` و `online_share_percent` (سهم منشی از نوبت‌های آنلاین) را هم دارد. --- ### PATCH `/api/v1/admin/doctors/{uuid}/mobile` · PATCH `/api/v1/admin/clinic/{uuid}/mobile` تغییر شمارهٔ **ورود** حساب پزشک یا کلینیک توسط مدیر کل. **Permission:** `ROLE_ADMIN` #### Request Body (`application/json`) ```json { "mobile_number": "09123456789" } ``` | Field | Type | Required | Description | |-------|------|----------|-------------| | `mobile_number` | string | ✅ | `09` + ۱۱ رقم؛ ارقام فارسی/عربی هم پذیرفته و نرمال‌سازی می‌شوند | #### Response `200` ```json { "success": true, "data": { "data": { "mobile_number": "09123456789", "previous_mobile_number": "09120000000" } } } ``` - شماره **هویت ورود** کاربر است: بعد از تغییر، ورود فقط با شمارهٔ جدید ممکن است. - برای پزشک، شمارهٔ نمایشیِ پروفایل (`Doctor.mobile_number`) هم اگر با شمارهٔ ورود یکی بوده (یا خالی است) با آن هم‌گام می‌شود تا دو مقدار واگرا نشوند. #### Errors | Code | HTTP | Description | |------|------|-------------| | `ERR_AUTH_006` | 403 | Not admin | | `ERR_DOCTOR_NOT_FOUND` / `ERR_NOT_FOUND_001` | 404 | پزشک/کلینیک یافت نشد | | `ERR_CONFLICT_001` | 409 | این شماره قبلاً برای کاربر دیگری ثبت شده (`field: mobile_number`) | | `ERR_VALIDATION_001` | 422 | قالب شماره نامعتبر (`field: mobile_number`) | --- ### GET `/api/v1/admin/secretary/{uuid}` جزئیات یک **رابطهٔ** منشی–پزشک/کلینیک (`uuid` = `DoctorSecretary.uuid`، همان uuid لیست بالا) به‌همراه تنظیمات سهم و خلاصهٔ درآمد. **Permission:** `ROLE_ADMIN` #### Response `200` ```json { "success": true, "data": { "data": { "uuid": "rel-uuid-…", "secretary_uuid": "user-uuid-…", "user_name": "زهرا رضایی", "mobile_number": "0912…", "doctor_name": "دکتر احمدی", "doctor_uuid": "doc-uuid-…", "owner_type": "doctor", "clinic_uuid": null, "clinic_name": null, "is_active": true, "online_share_enabled": true, "online_share_percent": 5, "permissions": { "…": {} }, "created_at": 1700000000, "earnings": { "total_rials": 4500000, "this_month_rials": 1500000, "appointments_count": 9 } } } } ``` `earnings` روی **کاربرِ منشی** جمع می‌شود (نه فقط این رابطه): مجموع همهٔ سهم‌های ثبت‌شده در `secretary_earnings`. `this_month_rials` = ۳۰ روز گذشته. #### Errors | Code | HTTP | Description | |------|------|-------------| | `ERR_AUTH_001` | 401 | Missing token | | `ERR_AUTH_006` | 403 | Not admin | | `ERR_NOT_FOUND_001` | 404 | منشی یافت نشد | --- ### PUT `/api/v1/admin/secretary/{uuid}/online-share` فعال/غیرفعال‌کردن محاسبهٔ درآمد منشی از نوبت‌های آنلاین و تعیین درصد سهم. تنظیم **per-relation** است: یک منشی می‌تواند برای یک پزشک سهم داشته باشد و برای دیگری نه. **Permission:** `ROLE_ADMIN` #### Request Body (`application/json`) ```json { "enabled": true, "percent": 5 } ``` | Field | Type | Required | Description | |-------|------|----------|-------------| | `enabled` | boolean | ✅ | محاسبهٔ سهم برای این رابطه فعال باشد؟ | | `percent` | number | ✅ | درصد سهم از **مبلغ خالص** نوبت (۰ تا ۱۰۰) | #### Response `200` همان شکل رابطه (`DoctorSecretary::toArray()`) پس از ذخیره. #### Errors | Code | HTTP | Description | |------|------|-------------| | `ERR_AUTH_006` | 403 | Not admin | | `ERR_NOT_FOUND_001` | 404 | منشی یافت نشد | | `ERR_VALIDATION_001` | 422 | `percent` خارج از ۰–۱۰۰ (`field: percent`) | | `ERR_VALIDATION_001` | 422 | `enabled=true` با `percent=0` (`field: percent`) | > **مبنای محاسبه:** سهم منشی مثل پورسانت نماینده از «خالصِ پس از مالیات» گرفته می‌شود — ابتدا هزینهٔ پنل پیامک، بعد مالیات، بعد سهم‌ها. تنها نوبت‌هایی که **آنلاین** پرداخت می‌شوند سهم می‌سازند (نوبت ثبت‌شده در پنل از مسیر تقسیم مالی عبور نمی‌کند). جزئیات: [settlement.md](settlement.md) و [secretary.md](secretary.md). --- ## 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 `overall` is the mean of the five dimensions (`0–100`) and `score` is 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}/approve` or `/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` ```json { "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` ```json { "success": true, "data": [ { "uuid": "...", "name": "تأیید نوبت", "status": "approved", "provider_code": "verify_appointment", "created_at": 1717000000 } ] } ``` > To create/approve/reject templates, see [sms.md](sms.md) --- ## Clinic Invitation Management > See [clinic-invitation.md](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`** ```json { "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): ```json { "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"` = inactive - `commission_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 gateways - `mellat_enabled` / `sep_enabled` — `"1"` = gateway selectable, `"0"` = gateway disabled (hidden from the payment selection list and rejected at `initiate`). Unset = enabled (default). - `payment_allowed_frontend_hosts` — comma-separated hosts allowed as a payment `frontend_address` (origin site to return to). Falls back to the `ALLOWED_FRONTEND_HOSTS` env 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=1` to 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": "" } ] } ``` 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 1. **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`. 2. **`maintenance_allowed_ips`** — exact client-IP match. 3. **`ROLE_ADMIN`** — resolved from the firewall token. Several admin-panel endpoints (`/api/v1/doctors`, `/api/v1/categorys/*`, …) live in the `public_endpoints` firewall with `security: false`, where no token is ever resolved; for those the subscriber falls back to decoding the `Authorization: Bearer` JWT and checking its `roles` claim. 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: ```bash 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): ```json { "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`:** ```json { "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): ```json { "note": "مدارک ناقص است" } ``` **Response `200`:** ```json { "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](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):** ```json { "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`:** ```json { "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`:** ```json { "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`:** ```json { "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` ```json { "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: - `context` is a JSON string (or `null`); a `Throwable` in the context is stored as a compact `Class: message @ file:line` string, never the raw object. - `created_at` is 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-8` - `Content-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_at` is formatted as `Y-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` ```json { "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 in `src/Schedule.php`, routed to `scheduler_default`) deletes logs older than `log_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`.