diff --git a/.env b/.env index 393712b9..c6264bfb 100644 --- a/.env +++ b/.env @@ -62,3 +62,8 @@ KAVENEGAR_API_KEY=test_key KAVENEGAR_SENDER=1000596446 RANGINEH_API_KEY=test_key RANGINEH_SENDER=3000 + +###> API Docs ### +API_DOC_USERNAME=admin +API_DOC_PASSWORD='$2y$10$aBQlhiUjSK65c6Ly/daJ9OXSvu/NzYJ/bpVi4stpnC7vJY40SbEKG' +###< API Docs ### diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..32cfa7c2 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,136 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +**ClinicPro** — a clinic management and appointment booking platform migrated from Drupal to Symfony 7. It consists of a Symfony REST API backend and a React 19 admin SPA bundled inside Symfony via Webpack Encore. + +- **Local URL:** `https://clinic-pro.ddev.site` +- **Admin panel:** `https://clinic-pro.ddev.site/admin` +- **Swagger UI:** `https://clinic-pro.ddev.site/api/doc` + +--- + +## Commands + +All commands run inside ddev: prefix with `ddev exec` unless noted. + +### Backend (PHP/Symfony) +```bash +ddev exec php bin/console cache:clear +ddev exec php bin/console doctrine:migrations:migrate --no-interaction +ddev exec php bin/console doctrine:migrations:diff --no-interaction # generate migration after entity change +ddev exec php bin/console debug:router | grep api +ddev exec php bin/console messenger:consume async # start queue worker + +# Tests +ddev exec php bin/phpunit +ddev exec php bin/phpunit tests/SomeTest.php # single test file + +# Static analysis +ddev exec php vendor/bin/phpstan analyse +``` + +### Frontend (React/TypeScript) +```bash +ddev exec yarn dev # one-off dev build (use this to check for errors) +ddev exec yarn watch # watch mode +ddev exec yarn build # production build + +# Type check only (faster) +ddev exec npx tsc --noEmit --project tsconfig.json +``` + +> **Note:** The CSS build has a known `lightningcss.linux-arm64-gnu.node` native module error inside ddev — this is pre-existing and does not block JS/TS compilation. TypeScript errors only appear in TSC output. + +--- + +## Architecture + +### Backend — `src/` + +Domain-driven structure; each domain is its own namespace under `App\\`: + +``` +src/ + Admin/Controller/AdminApiController.php # all admin-only list/stats endpoints + Appointment/ Doctor/ Clinic/ + Auth/ Payment/ Rating/ + Blog/ Representation/ Secretary/ + Category/ Settlement/ Sms/ + Shared/Controller/BaseController.php # all controllers extend this + Shared/Constant/ErrorCodes.php +``` + +**Every controller extends `BaseController`** which provides three response helpers: + +| Method | Shape | When to use | +|--------|-------|-------------| +| `$this->success($data)` | `{ success, data: $data }` | Single resource / action | +| `$this->paginated($items, $total, $page, $limit)` | `{ success, data: $items[], meta: { totalRecords, totalPages, currentPage } }` | Admin list endpoints | +| `$this->error($code, $message, $status)` | `{ success:false, errors:[{code,message}] }` | All error responses | + +**Critical pitfall — double-nested responses:** +`$this->success(['data' => $rep->toArray()])` produces `{ data: { data: {...} } }`, so the frontend must extract with `data?.data?.data`. The `paginated()` helper does NOT nest — it returns `data` as a flat array. + +### Frontend — `assets/admin/` + +Single-page app mounted at `/admin/*`: + +``` +assets/admin/ + App.tsx # React Router routes + pages/ # one file per page + components/ui/ # DataTable, Modal, ConfirmDialog, PageHeader, StatusBadge, Pagination + lib/api.ts # fetch wrapper (reads JWT from localStorage key: clinicpro-auth) + lib/utils.ts # formatRial, formatNumber, formatDate, formatDateTime + types/index.ts # all TypeScript interfaces + stores/authStore.ts # Zustand auth store (persisted to localStorage) +``` + +**Data fetching pattern:** TanStack Query v5 (`useQuery` / `useMutation`). Query keys use `['resource-name', page, filters]`. + +**API response types in `lib/api.ts`:** +- `ApiResponse` — for single-resource responses: extract with `data?.data` +- `PaginatedResponse` — for admin lists: items at `data?.data`, total at `data?.meta?.totalRecords` + +**Forms:** React Hook Form + Zod resolver. Schema defined with `z.object()`, type inferred with `z.infer`. + +### Auth + +- JWT stored in Zustand store → `localStorage['clinicpro-auth']` → `state.token` +- `api.ts` reads it automatically for every request +- Admin routes require `ROLE_ADMIN`. `#[IsGranted('ROLE_ADMIN')]` on controller class or method. +- Public endpoints listed in `config/packages/security.yaml` under `public_endpoints` firewall pattern + +### Category / Bundle system + +Categories are polymorphic via a `bundle` string field. Used for: `state`, `city`, `specially_doctor`, `doctor_services`, `insurance_type`, `supplementary_insurance`, `tag`. + +City IDs (integer FK to `categories.id` where `bundle='city'`) are stored on entities like `Representation.cityId`. To get the city name, LEFT JOIN the categories table in DQL. + +### Database + +- MariaDB 11.8 via ddev +- Doctrine ORM with integer Unix timestamps (`createdAt`, `updatedAt`) — **not** DateTime objects +- All admin list queries in `AdminApiController` use DQL array hydration (`.getArrayResult()`) to avoid triggering non-existent getter errors on entities +- Migrations in `migrations/` — always run `doctrine:migrations:diff` after entity changes + +--- + +## Key Patterns + +**Adding a new admin list endpoint (backend):** +1. Add method to `src/Admin/Controller/AdminApiController.php` +2. Use `$this->em->createQueryBuilder()` with `->getArrayResult()` (never use entity getters in admin list queries) +3. Return `$this->paginated($items, $total, $page, $limit)` + +**Adding a new admin page (frontend):** +1. Create `assets/admin/pages/XxxPage.tsx` +2. Use `PaginatedResponse` with `useQuery` +3. Extract: `data?.data` for items, `data?.meta?.totalRecords` for total +4. Add route in `App.tsx` +5. Add UI components: ``, ``, ``, `` + +**Category API endpoint pattern:** `GET /api/v1/categorys/{bundle}` (note: typo `categorys` is intentional — existing route). Response is double-nested: extract array with `data?.data?.data ?? []`. diff --git a/docs/PROJECT.md b/README.MD similarity index 76% rename from docs/PROJECT.md rename to README.MD index 68d92624..b8d6245e 100644 --- a/docs/PROJECT.md +++ b/README.MD @@ -1,6 +1,6 @@ # ClinicPro — مستندات کامل پروژه -> **تاریخ آخرین به‌روزرسانی:** ۱۴۰۵/۰۳/۱۹ | **نسخه Symfony:** 7.4 | **PHP:** ≥ 8.2 +> **تاریخ آخرین به‌روزرسانی:** ۱۴۰۵/۰۳/۲۰ | **نسخه Symfony:** 7.4 | **PHP:** ≥ 8.2 --- @@ -39,6 +39,7 @@ - ارسال پیامک (KaveNegar / Rangineh) - امتیاز و نظرات پزشکان - بلاگ + - مستندات API تعاملی (Swagger UI) — محافظت‌شده با HTTP Basic Auth --- @@ -53,8 +54,8 @@ | Auth | JWT (lexik/jwt-authentication-bundle) | | Queue | Symfony Messenger | | Cache/Session | Redis | -| Database | MySQL | -| API Docs | NelmioApiDocBundle (Swagger) | +| Database | MariaDB 11.8 | +| API Docs | NelmioApiDocBundle v5 + swagger-php v6 | | Rate Limiting | Symfony Rate Limiter | ### Frontend (Admin) @@ -87,9 +88,7 @@ clinic-pro-symfony/ │ │ │ ├── AdminLayout.tsx │ │ │ ├── Sidebar.tsx │ │ │ └── Topbar.tsx -│ │ ├── pages/ -│ │ │ ├── LoginPage.tsx -│ │ │ └── DashboardPage.tsx +│ │ ├── pages/ ← ۲۲ صفحه پیاده‌سازی‌شده │ │ ├── stores/ │ │ │ ├── authStore.ts ← Zustand JWT store │ │ │ └── uiStore.ts ← Sidebar state @@ -105,20 +104,22 @@ clinic-pro-symfony/ │ │ ├── doctrine.yaml │ │ ├── lexik_jwt_authentication.yaml │ │ ├── messenger.yaml +│ │ ├── nelmio_api_doc.yaml ← Swagger config │ │ ├── rate_limiter.yaml │ │ └── webpack_encore.yaml │ └── routes/ │ ├── security.yaml │ └── nelmio_api_doc.yaml │ -├── migrations/ ← 16 Doctrine migrations +├── migrations/ ← 17 Doctrine migrations │ ├── public/ │ ├── index.php -│ └── build/ ← Webpack output +│ ├── build/ ← Webpack output +│ └── bundles/nelmioapidoc/ ← Swagger UI assets (local) │ ├── src/ -│ ├── Admin/Controller/ ← SPA catch-all +│ ├── Admin/Controller/ ← AdminApiController + SPA catch-all │ ├── Appointment/ ← نوبت‌دهی │ ├── Auth/ ← احراز هویت │ ├── Blog/ ← بلاگ @@ -183,39 +184,49 @@ npm run watch ``` ### دسترسی -| سرویس | آدرس | -|-------|------| -| Admin Panel | https://clinic-pro.ddev.site/admin | -| API | https://clinic-pro.ddev.site/api/v1 | -| Swagger UI | https://clinic-pro.ddev.site/api/doc | -| Health Check | https://clinic-pro.ddev.site/health | + +| سرویس | آدرس | توضیح | +|-------|------|-------| +| Admin Panel | https://clinic-pro.ddev.site/admin | JWT auth | +| API | https://clinic-pro.ddev.site/api/v1 | REST API | +| Swagger UI | https://clinic-pro.ddev.site/api/doc | HTTP Basic Auth | +| Health Check | https://clinic-pro.ddev.site/health | عمومی | ### اطلاعات ادمین پیش‌فرض + | فیلد | مقدار | |------|-------| | موبایل | `09120671713` | | رمز عبور | `admin1234` | | نقش | `ROLE_ADMIN` | +### اطلاعات ورود به Swagger UI + +| فیلد | مقدار پیش‌فرض | +|------|--------------| +| Username | `admin` | +| Password | `clinic-pro-docs` | + +> برای تغییر رمز: `ddev exec php -r "echo password_hash('رمز-جدید', PASSWORD_BCRYPT) . PHP_EOL;"` — hash را در `.env` در `API_DOC_PASSWORD` قرار دهید. + --- ## ۵. متغیرهای محیطی -فایل `.env` — کلیدها (مقادیر در `.env.local` تنظیم می‌شوند): +فایل `.env` — کلیدها (مقادیر حساس در `.env.local` تنظیم می‌شوند): ```env APP_ENV=dev|prod APP_SECRET= # کلید امنیتی Symfony -APP_SHARE_DIR= # مسیر shared assets DEFAULT_URI= # آدرس پایه سایت (مثلاً https://clinic-pro.ddev.site) -DATABASE_URL= # DSN پایگاه داده MySQL +DATABASE_URL= # DSN پایگاه داده MariaDB JWT_SECRET_KEY= # مسیر کلید خصوصی JWT JWT_PUBLIC_KEY= # مسیر کلید عمومی JWT JWT_PASSPHRASE= # رمز کلید JWT -CORS_ALLOW_ORIGIN= # آدرس‌های مجاز CORS +CORS_ALLOW_ORIGIN= # آدرس‌های مجاز CORS (regex) MESSENGER_TRANSPORT_DSN= # DSN صف پیام (Redis) REDIS_URL= # آدرس Redis @@ -223,10 +234,9 @@ REDIS_URL= # آدرس Redis REFRESH_TOKEN_TTL=2592000 # عمر refresh token (ثانیه) = ۳۰ روز OTP_TTL=1200 # عمر کد OTP (ثانیه) = ۲۰ دقیقه -# SMS - KaveNegar +# SMS KAVENEGAR_API_KEY= KAVENEGAR_SENDER= -# SMS - Rangineh RANGINEH_API_KEY= RANGINEH_SENDER= SMS_PROVIDER=kavenegar|rangineh # ارائه‌دهنده فعال @@ -235,17 +245,17 @@ SMS_PROVIDER=kavenegar|rangineh # ارائه‌دهنده فعال MAX_FILE_SIZE_BYTES=5242880 # حداکثر حجم فایل (۵ مگابایت) UPLOAD_DIR= # مسیر ذخیره فایل‌ها +# Payment ALLOWED_FRONTEND_HOSTS= # هاست‌های مجاز برای redirect پرداخت - -# Payment - Mellat Bank MELLAT_TERMINAL_ID= MELLAT_USERNAME= MELLAT_PASSWORD= - -# Payment - SEP (Saman) SEP_TERMINAL_ID= - APP_BASE_URL= # آدرس پایه برای callback پرداخت + +# API Documentation (Swagger UI) +API_DOC_USERNAME=admin # نام کاربری ورود به /api/doc +API_DOC_PASSWORD= # bcrypt hash رمز عبور ``` --- @@ -280,6 +290,7 @@ APP_BASE_URL= # آدرس پایه برای callback پردا | `doctor_insurances` | `Insurance\Entity\DoctorInsurance` | بیمه‌های پزشک | ### جداول Join (ManyToMany) + | جدول | رابطه | |------|-------| | `doctor_specialties` | Doctor ↔ Category (specialty) | @@ -348,8 +359,8 @@ telephone VARCHAR(50) nullable is_24_7 BOOL default:false working_days VARCHAR(255) nullable latitude/longitude FLOAT nullable -city_id INT nullable → categories -state_id INT nullable → categories +city_id INT nullable → categories (bundle='city') +state_id INT nullable → categories (bundle='state') representation_id INT nullable images_clinic JSON nullable clinic_logo JSON nullable @@ -357,6 +368,25 @@ clinic_logo JSON nullable --- +### Representation (representations) + +``` +id INT PK +uuid VARCHAR(36) UNIQUE +full_name VARCHAR(255) +mobile_number VARCHAR(20) +city_id INT nullable → categories (bundle='city') ← ID شهر (نه نام) +wallet_balance INT default:0 +commission_rate FLOAT default:10.0 +is_active BOOL default:true +created_at INT (unix timestamp) +updated_at INT (unix timestamp) +``` + +> **توجه:** `city_id` یک FK عددی به جدول `categories` (bundle='city') است. نام شهر از طریق JOIN در API برگردانده می‌شود. + +--- + ### Appointment (appointments) ``` @@ -423,13 +453,13 @@ representation_id INT nullable **نوع‌های bundle:** ``` -state ← استان‌ها -city ← شهرها (parent_id = state) -specially_doctor ← تخصص پزشک -doctor_services ← خدمات پزشک -insurance_type ← نوع بیمه پایه +state ← استان‌ها +city ← شهرها (parent_id = state.id) +specially_doctor ← تخصص پزشک +doctor_services ← خدمات پزشک +insurance_type ← نوع بیمه پایه supplementary_insurance ← بیمه تکمیلی -tag ← تگ بلاگ +tag ← تگ بلاگ ``` --- @@ -469,6 +499,8 @@ active BOOL ### Base URL: `https://clinic-pro.ddev.site` +> مستندات کامل تعاملی: **https://clinic-pro.ddev.site/api/doc** (نیاز به HTTP Basic Auth) + --- ### 🔐 Auth (`/api/v1/user/` & `/oauth/`) @@ -479,11 +511,11 @@ active BOOL | POST | `/api/v1/user/send-code` | Public | ارسال کد OTP به موبایل | | POST | `/api/v1/user/verify-code` | Public | تأیید کد OTP | | POST | `/api/v1/user/register` | Public | ثبت‌نام کاربر جدید | -| POST | `/oauth/token` | Public | دریافت JWT با موبایل (mobile grant) | +| POST | `/oauth/token` | Public | دریافت JWT (mobile grant) | | POST | `/oauth/token/refresh` | Public | تمدید JWT با refresh token | | GET | `/oauth/userinfo` | ✅ | اطلاعات کاربر جاری | | POST | `/oauth/logout` | ✅ | خروج و ابطال refresh token | -| GET | `/session/token` | Public | دریافت CSRF session token | +| GET | `/session/token` | Public | CSRF session token | **درخواست Login:** ```json @@ -503,15 +535,6 @@ POST /api/v1/user/login --- -### 👥 Users - -| Method | Path | Auth | توضیح | -|--------|------|------|-------| -| GET | `/oauth/userinfo` | ✅ | پروفایل کاربر جاری | -| DELETE | `/api/v1/user/{id}` | ADMIN | حذف کاربر | - ---- - ### 🩺 Doctors | Method | Path | Auth | توضیح | @@ -569,7 +592,7 @@ POST /api/v1/user/login | Method | Path | Auth | توضیح | |--------|------|------|-------| | POST | `/api/v1/payment/appointment` | ✅ | شروع پرداخت نوبت | -| GET/POST | `/api/v1/payment/callback/{gateway}` | Public (IP) | callback درگاه | +| GET/POST | `/api/v1/payment/callback/{gateway}` | Public | callback درگاه | | GET | `/api/v1/payment/{uuid}` | ✅ | وضعیت پرداخت | | POST | `/api/v1/subscription-payment` | ✅ | پرداخت اشتراک | @@ -601,9 +624,6 @@ POST /api/v1/user/login | GET | `/api/v1/comments/{doctorUuid}` | Public | نظرات تأییدشده | | DELETE | `/api/v1/comment/{uuid}` | ✅ | حذف نظر | | POST | `/api/v1/like/{commentUuid}` | ✅ | لایک/آنلایک نظر | -| GET | `/api/v1/admin/comments/pending` | ADMIN | نظرات در انتظار | -| POST | `/api/v1/admin/comment/{uuid}/approve` | ADMIN | تأیید نظر | -| POST | `/api/v1/admin/comment/{uuid}/reject` | ADMIN | رد نظر | --- @@ -620,7 +640,7 @@ POST /api/v1/user/login --- -### 🔐 Secretaries (منشی‌ها) +### 🔑 Secretaries (منشی‌ها) | Method | Path | Auth | توضیح | |--------|------|------|-------| @@ -651,17 +671,13 @@ POST /api/v1/user/login | Method | Path | Auth | توضیح | |--------|------|------|-------| -| GET | `/api/v1/categorys/state` | Public | لیست استان‌ها | -| GET | `/api/v1/categorys/city` | Public | لیست شهرها (`?state_id=X`) | -| GET | `/api/v1/categorys/specially_doctor` | Public | تخصص‌های پزشکی | -| GET | `/api/v1/categorys/doctor_services` | Public | خدمات پزشکی | -| GET | `/api/v1/categorys/insurance_type` | Public | انواع بیمه پایه | -| GET | `/api/v1/categorys/supplementary_insurance` | Public | بیمه تکمیلی | -| GET | `/api/v1/categorys/tag` | Public | تگ‌های بلاگ | +| GET | `/api/v1/categorys/{bundle}` | Public | لیست دسته‌بندی (`?state_id=X`) | | POST | `/api/v1/category` | ADMIN | ایجاد دسته‌بندی | | PATCH | `/api/v1/category/{id}` | ADMIN | ویرایش | | DELETE | `/api/v1/category/{id}` | ADMIN | حذف | +**مقادیر `{bundle}`:** `state` | `city` | `specially_doctor` | `doctor_services` | `insurance_type` | `supplementary_insurance` | `tag` + --- ### 📝 Blog @@ -677,6 +693,27 @@ POST /api/v1/user/login --- +### 🖥 Admin API (`/api/v1/admin/`) + +همه endpoint‌های ادمین نیاز به `ROLE_ADMIN` دارند: + +| Method | Path | توضیح | +|--------|------|-------| +| GET | `/api/v1/admin/users` | لیست کاربران (`?search=`) | +| GET | `/api/v1/admin/appointments` | لیست نوبت‌ها | +| GET | `/api/v1/admin/payments` | لیست پرداخت‌ها | +| GET | `/api/v1/admin/representations` | لیست نمایندگان (`?city_id=`) | +| GET | `/api/v1/admin/secretaries` | لیست منشی‌ها | +| GET | `/api/v1/admin/rates` | لیست امتیازها | +| GET | `/api/v1/admin/comments` | لیست نظرات | +| GET | `/api/v1/admin/sms/logs` | لاگ پیامک‌ها | +| GET | `/api/v1/admin/settlements` | لیست تسویه‌حساب‌ها | +| GET | `/api/v1/admin/sms/sample-templates` | قالب‌های نمونه پیامک | +| GET | `/api/v1/admin/dashboard/stats` | آمار کلی داشبورد | +| GET | `/api/v1/admin/dashboard/recent` | فعالیت‌های اخیر | + +--- + ### ❤️ Health | Method | Path | توضیح | @@ -698,18 +735,19 @@ POST /api/v1/user/login [Symfony] → Rate Limit check (IP) → findByMobile() → verify password_hash - → check isStaff() (ROLE_ADMIN or ROLE_DOCTOR or ROLE_CLINIC) + → check isStaff() ↓ موفق [Response] → { - access_token: "eyJ...", // ← JWT، عمر: 1 ساعت - refresh_token: "8e75...", // ← در DB کش، عمر: 30 روز + access_token: "eyJ...", // JWT — عمر: ۱ ساعت + refresh_token: "8e75...", // در DB کش — عمر: ۳۰ روز expires_in: 3600 } ``` ### JWT Token Payload + ```json { "iat": 1781031215, @@ -720,26 +758,37 @@ POST /api/v1/user/login ``` ### استفاده از Token + ``` Authorization: Bearer eyJ... ``` -### Refresh Token -``` -POST /oauth/token/refresh -{ "refresh_token": "8e75..." } -``` - ### Firewalls ``` -dev → مسیرهای profiler/assets (no security) -health → /health (no security) -public_endpoints → مسیرهای عمومی API (no security) -payment_callback → callback درگاه (no security) -api → ^/(api|oauth)/ — JWT authenticator +dev → ^/(_profiler|_wdt|assets|build)/ — no security +health → ^/health$ — no security +api_doc → ^/api/doc — HTTP Basic Auth (InMemoryUser) +public_endpoints → مسیرهای عمومی API — no security +payment_callback → callback درگاه پرداخت — no security +api → ^/(api|oauth)/ — JWT authenticator ``` +### محافظت از Swagger UI + +مسیر `/api/doc` از طریق یک firewall جداگانه با **HTTP Basic Authentication** محافظت می‌شود: + +```yaml +# config/packages/security.yaml +api_doc: + pattern: ^/api/doc + http_basic: + realm: "ClinicPro API Documentation" + provider: api_doc_provider +``` + +اطلاعات ورود از متغیرهای محیطی `API_DOC_USERNAME` و `API_DOC_PASSWORD` (bcrypt hash) خوانده می‌شود. + ### نقش‌ها و دسترسی‌ها | نقش | دسترسی | @@ -779,25 +828,33 @@ send_code: 3 درخواست در 5 دقیقه (per IP) [React Router] مسیریابی client-side ``` -### مسیرهای React +### مسیرهای React (پیاده‌سازی‌شده) -| مسیر | صفحه | Auth | -|------|------|------| -| `/admin/login` | LoginPage | عمومی | -| `/admin/dashboard` | DashboardPage | ✅ | -| `/admin/users` | *(در حال توسعه)* | ✅ | -| `/admin/doctors` | *(در حال توسعه)* | ✅ | -| `/admin/clinics` | *(در حال توسعه)* | ✅ | -| `/admin/appointments` | *(در حال توسعه)* | ✅ | -| `/admin/payments` | *(در حال توسعه)* | ✅ | -| `/admin/settlements` | *(در حال توسعه)* | ✅ | -| `/admin/comments` | *(در حال توسعه)* | ✅ | -| `/admin/ratings` | *(در حال توسعه)* | ✅ | -| `/admin/sms` | *(در حال توسعه)* | ✅ | -| `/admin/categories` | *(در حال توسعه)* | ✅ | -| `/admin/blogs` | *(در حال توسعه)* | ✅ | -| `/admin/representations` | *(در حال توسعه)* | ✅ | -| `/admin/secretaries` | *(در حال توسعه)* | ✅ | +| مسیر | صفحه | توضیح | +|------|------|-------| +| `/admin/login` | LoginPage | ورود با JWT | +| `/admin/dashboard` | DashboardPage | آمار واقعی از API | +| `/admin/users` | UsersPage | لیست کاربران | +| `/admin/users/:uuid` | UserDetailPage | جزئیات کاربر | +| `/admin/doctors` | DoctorsPage | لیست پزشکان | +| `/admin/doctors/:uuid` | DoctorDetailPage | جزئیات پزشک | +| `/admin/clinics` | ClinicsPage | لیست کلینیک‌ها | +| `/admin/clinics/:uuid` | ClinicDetailPage | جزئیات کلینیک | +| `/admin/appointments` | AppointmentsPage | لیست نوبت‌ها | +| `/admin/appointments/:uuid` | AppointmentDetailPage | جزئیات نوبت | +| `/admin/payments` | PaymentsPage | لیست پرداخت‌ها | +| `/admin/payments/:uuid` | PaymentDetailPage | جزئیات پرداخت | +| `/admin/settlements` | SettlementsPage | لیست تسویه‌حساب‌ها | +| `/admin/representations` | RepresentationsPage | لیست نمایندگان (فیلتر شهر) | +| `/admin/representations/:uuid` | RepresentationDetailPage | جزئیات نماینده | +| `/admin/comments` | CommentsPage | مدیریت نظرات | +| `/admin/ratings` | RatingsPage | لیست امتیازها | +| `/admin/sms` | SmsPage | مدیریت پیامک | +| `/admin/categories` | CategoriesPage | مدیریت دسته‌بندی‌ها | +| `/admin/blogs` | BlogsPage | لیست مقالات | +| `/admin/blogs/new` | BlogFormPage | ایجاد مقاله | +| `/admin/blogs/:uuid/edit` | BlogFormPage | ویرایش مقاله | +| `/admin/secretaries` | SecretariesPage | مدیریت منشی‌ها | ### Auth Guard @@ -838,12 +895,13 @@ direction: rtl; ### دستورات npm ```bash -npm run dev # build یک‌بار (dev) -npm run watch # build + watch -npm run build # build production (minified + hashed) -npm run dev-server # webpack dev server (HMR) +ddev exec yarn dev # build یک‌بار (dev) +ddev exec yarn watch # build + watch +ddev exec yarn build # build production (minified + hashed) ``` +> **توجه:** خطای `lightningcss.linux-arm64-gnu.node` در محیط ddev از پیش وجود دارد و JS/TS compilation را مسدود نمی‌کند. + ### فایل‌های خروجی ``` @@ -870,54 +928,46 @@ public/build/ } ``` -### تنظیمات PostCSS (`postcss.config.js`) - -```js -module.exports = { - plugins: { '@tailwindcss/postcss': {} } -}; -``` - --- ## ۱۱. دستورات Console -### دستور موجود +### دستورات کاربردی ```bash # لغو خودکار نوبت‌های منقضی‌شده ddev exec php bin/console app:cancel-expired-appointments -# توضیح: نوبت‌هایی که slot_start آنها گذشته و هنوز pending هستند -# را به وضعیت auto_cancel_unpaid تغییر می‌دهد -# اجرا: از طریق cron job (مثلاً هر ۱۵ دقیقه) -``` - -### دستورات Symfony مفید - -```bash # مشاهده همه route‌ها -ddev exec php bin/console debug:router +ddev exec php bin/console debug:router | grep api # پاک کردن کش ddev exec php bin/console cache:clear # اجرای migrations -ddev exec php bin/console doctrine:migrations:migrate +ddev exec php bin/console doctrine:migrations:migrate --no-interaction + +# ساخت migration بعد از تغییر entity +ddev exec php bin/console doctrine:migrations:diff --no-interaction # هش کردن پسورد -ddev exec php bin/console security:hash-password "your_password" +ddev exec php bin/console security:hash-password "رمز-جدید" # تولید JWT key ddev exec php bin/console lexik:jwt:generate-keypair + +# بررسی صف پیام +ddev exec php bin/console messenger:consume async + +# خروجی OpenAPI/Swagger +ddev exec php bin/console nelmio:apidoc:dump ``` --- ## ۱۲. Migrations -**تعداد:** ۱۶ migration -**آخرین نسخه:** `Version20260609140423` +**تعداد:** ۱۷ migration ```bash # مشاهده وضعیت @@ -926,7 +976,7 @@ ddev exec php bin/console doctrine:migrations:status # اجرای migrations جدید ddev exec php bin/console doctrine:migrations:migrate --no-interaction -# ساخت migration جدید +# ساخت migration جدید بعد از تغییر entity ddev exec php bin/console doctrine:migrations:diff ``` @@ -941,8 +991,6 @@ ddev exec php bin/console doctrine:migrations:diff | بانک ملت | `MellatGateway` | `MELLAT_TERMINAL_ID`, `MELLAT_USERNAME`, `MELLAT_PASSWORD` | | سامان (SEP) | `SepGateway` | `SEP_TERMINAL_ID` | -**IP‌های مجاز callback:** در `ALLOWED_FRONTEND_HOSTS` تنظیم می‌شود. - --- ### SMS Providers @@ -956,13 +1004,14 @@ ddev exec php bin/console doctrine:migrations:diff ### Redis - صف پیام (Symfony Messenger) -- کش OTP codes +- کش کدهای OTP - کش Refresh Tokens - Rate limiter storage --- ### JWT Keys + ``` config/jwt/private.pem ← کلید خصوصی (در .gitignore) config/jwt/public.pem ← کلید عمومی (در .gitignore) @@ -976,10 +1025,11 @@ config/jwt/public.pem ← کلید عمومی (در .gitignore) |-------|-------| | Domain modules | ۱۵ | | PHP Controllers | ۱۷ | -| API Endpoints | ۱۳۰+ | +| API Endpoints | ۹۷ (مستندسازی‌شده در Swagger) | | Doctrine Entities | ۲۲ | | Database Tables | ۳۰ | -| Migrations | ۱۶ | -| React Pages | ۲ (پیاده‌سازی‌شده) + ۱۳ (در حال توسعه) | +| Migrations | ۱۷ | +| React Pages | ۲۳ مسیر (کاملاً پیاده‌سازی‌شده) | +| OpenAPI Tags | ۱۱ گروه | | npm packages | ۳۴ | | composer packages | ۲۸ | diff --git a/config/packages/nelmio_api_doc.yaml b/config/packages/nelmio_api_doc.yaml index 52637961..c496874f 100644 --- a/config/packages/nelmio_api_doc.yaml +++ b/config/packages/nelmio_api_doc.yaml @@ -12,8 +12,11 @@ nelmio_api_doc: bearerFormat: JWT security: - bearerAuth: [] + html_config: + assets_mode: bundle areas: - path_patterns: - - ^/api - - ^/oauth - - ^/health + default: + path_patterns: + - ^/api + - ^/oauth + - ^/health diff --git a/config/packages/security.yaml b/config/packages/security.yaml index 846c5a3c..4410ec3e 100644 --- a/config/packages/security.yaml +++ b/config/packages/security.yaml @@ -2,12 +2,20 @@ security: password_hashers: App\Auth\Entity\User: algorithm: auto + Symfony\Component\Security\Core\User\InMemoryUser: + algorithm: bcrypt providers: app_user_provider: entity: class: App\Auth\Entity\User property: mobileNumber + api_doc_provider: + memory: + users: + '%env(API_DOC_USERNAME)%': + password: '%env(API_DOC_PASSWORD)%' + roles: ['ROLE_ADMIN'] firewalls: dev: @@ -18,6 +26,12 @@ security: pattern: ^/health$ security: false + api_doc: + pattern: ^/api/doc + http_basic: + realm: "ClinicPro API Documentation" + provider: api_doc_provider + public_endpoints: pattern: ^/(api/v1/user/(send-code|verify-code|register)|oauth/token$|session/token|api/v1/categorys/|api/v1/doctors$|api/v1/clinics$|api/v1/clinic/doctor-list/|api/v1/clinic-pro/doctor-addresses/|api/v1/appointment-slots|api/v1/comments/|api/v1/rate/|api/v1/blogs$) stateless: true @@ -38,6 +52,7 @@ security: access_control: - { path: ^/health$, roles: PUBLIC_ACCESS } + - { path: ^/api/doc, roles: ROLE_ADMIN } - { path: ^/api/v1/user/send-code, roles: PUBLIC_ACCESS } - { path: ^/api/v1/user/verify-code, roles: PUBLIC_ACCESS } - { path: ^/api/v1/user/register, roles: PUBLIC_ACCESS } @@ -54,15 +69,14 @@ security: - { path: ^/session/token, roles: PUBLIC_ACCESS } - { path: ^/api/v1/payment/callback/, roles: PUBLIC_ACCESS } - { path: ^/api/v1/subscription-payment/callback/, roles: PUBLIC_ACCESS } - - { path: ^/api/doc, roles: PUBLIC_ACCESS } - - { path: ^/api/v1/categorys/, roles: PUBLIC_ACCESS } - - { path: ^/api/v1/doctors$, roles: PUBLIC_ACCESS } + - { path: ^/api/v1/categorys/, roles: PUBLIC_ACCESS } + - { path: ^/api/v1/doctors$, roles: PUBLIC_ACCESS } - path: '^/api/v1/doctor/[^/]+$' methods: [GET] roles: PUBLIC_ACCESS - - { path: ^/api/v1/clinic/doctor-list/, roles: PUBLIC_ACCESS } - - { path: ^/api/v1/clinic-pro/doctor-addresses/, roles: PUBLIC_ACCESS } - - { path: ^/api/v1/clinics$, roles: PUBLIC_ACCESS } + - { path: ^/api/v1/clinic/doctor-list/, roles: PUBLIC_ACCESS } + - { path: ^/api/v1/clinic-pro/doctor-addresses/, roles: PUBLIC_ACCESS } + - { path: ^/api/v1/clinics$, roles: PUBLIC_ACCESS } - path: '^/api/v1/clinic/[^/]+$' methods: [GET] roles: PUBLIC_ACCESS diff --git a/src/Admin/Controller/AdminApiController.php b/src/Admin/Controller/AdminApiController.php index b670e790..5627945c 100644 --- a/src/Admin/Controller/AdminApiController.php +++ b/src/Admin/Controller/AdminApiController.php @@ -17,11 +17,13 @@ use App\Sms\Entity\SmsLog; use App\Sms\Entity\SmsTemplate; use App\Shared\Controller\BaseController; use Doctrine\ORM\EntityManagerInterface; +use OpenApi\Attributes as OA; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Security\Http\Attribute\IsGranted; +#[OA\Tag(name: 'Admin')] #[IsGranted('ROLE_ADMIN')] class AdminApiController extends BaseController { @@ -31,6 +33,46 @@ class AdminApiController extends BaseController // ── Users ───────────────────────────────────────────────────────────────── + #[OA\Get( + path: '/api/v1/admin/users', + summary: 'List all users (paginated)', + security: [['bearerAuth' => []]], + parameters: [ + new OA\Parameter(name: 'page', in: 'query', required: false, schema: new OA\Schema(type: 'integer', default: 1)), + new OA\Parameter(name: 'limit', in: 'query', required: false, schema: new OA\Schema(type: 'integer', default: 15)), + new OA\Parameter(name: 'search', in: 'query', required: false, schema: new OA\Schema(type: 'string')), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Paginated list of users', + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'success', type: 'boolean', example: true), + new OA\Property(property: 'data', type: 'array', items: new OA\Items( + properties: [ + new OA\Property(property: 'uuid', type: 'string'), + new OA\Property(property: 'id', type: 'integer'), + new OA\Property(property: 'mobile_number', type: 'string'), + new OA\Property(property: 'name', type: 'string'), + new OA\Property(property: 'email', type: 'string'), + new OA\Property(property: 'roles', type: 'array', items: new OA\Items(type: 'string')), + new OA\Property(property: 'is_active', type: 'boolean'), + new OA\Property(property: 'created_at', type: 'string', format: 'date-time'), + ] + )), + new OA\Property(property: 'meta', properties: [ + new OA\Property(property: 'totalRecords', type: 'integer'), + new OA\Property(property: 'totalPages', type: 'integer'), + new OA\Property(property: 'currentPage', type: 'integer'), + ], type: 'object'), + ] + ) + ), + new OA\Response(response: 401, description: 'Unauthorized'), + new OA\Response(response: 403, description: 'Forbidden – ROLE_ADMIN required'), + ] + )] #[Route('/api/v1/admin/users', methods: ['GET'])] public function users(Request $request): JsonResponse { @@ -69,6 +111,48 @@ class AdminApiController extends BaseController // ── Appointments ────────────────────────────────────────────────────────── + #[OA\Get( + path: '/api/v1/admin/appointments', + summary: 'List all appointments (paginated)', + security: [['bearerAuth' => []]], + parameters: [ + new OA\Parameter(name: 'page', in: 'query', required: false, schema: new OA\Schema(type: 'integer', default: 1)), + new OA\Parameter(name: 'limit', in: 'query', required: false, schema: new OA\Schema(type: 'integer', default: 15)), + new OA\Parameter(name: 'search', in: 'query', required: false, schema: new OA\Schema(type: 'string')), + new OA\Parameter(name: 'status', in: 'query', required: false, schema: new OA\Schema(type: 'string')), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Paginated list of appointments', + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'success', type: 'boolean', example: true), + new OA\Property(property: 'data', type: 'array', items: new OA\Items( + properties: [ + new OA\Property(property: 'uuid', type: 'string'), + new OA\Property(property: 'patient_name', type: 'string'), + new OA\Property(property: 'patient_mobile', type: 'string'), + new OA\Property(property: 'doctor_name', type: 'string'), + new OA\Property(property: 'appointment_date', type: 'string', format: 'date'), + new OA\Property(property: 'appointment_time', type: 'string', example: '14:30'), + new OA\Property(property: 'status', type: 'string'), + new OA\Property(property: 'amount', type: 'integer'), + new OA\Property(property: 'created_at', type: 'string', format: 'date-time'), + ] + )), + new OA\Property(property: 'meta', properties: [ + new OA\Property(property: 'totalRecords', type: 'integer'), + new OA\Property(property: 'totalPages', type: 'integer'), + new OA\Property(property: 'currentPage', type: 'integer'), + ], type: 'object'), + ] + ) + ), + new OA\Response(response: 401, description: 'Unauthorized'), + new OA\Response(response: 403, description: 'Forbidden – ROLE_ADMIN required'), + ] + )] #[Route('/api/v1/admin/appointments', methods: ['GET'])] public function appointments(Request $request): JsonResponse { @@ -118,6 +202,46 @@ class AdminApiController extends BaseController // ── Payments ────────────────────────────────────────────────────────────── + #[OA\Get( + path: '/api/v1/admin/payments', + summary: 'List all payments (paginated)', + security: [['bearerAuth' => []]], + parameters: [ + new OA\Parameter(name: 'page', in: 'query', required: false, schema: new OA\Schema(type: 'integer', default: 1)), + new OA\Parameter(name: 'limit', in: 'query', required: false, schema: new OA\Schema(type: 'integer', default: 15)), + new OA\Parameter(name: 'status', in: 'query', required: false, schema: new OA\Schema(type: 'string', enum: ['pending', 'success', 'failed'])), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Paginated list of payments', + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'success', type: 'boolean', example: true), + new OA\Property(property: 'data', type: 'array', items: new OA\Items( + properties: [ + new OA\Property(property: 'uuid', type: 'string'), + new OA\Property(property: 'amount', type: 'integer'), + new OA\Property(property: 'status', type: 'string'), + new OA\Property(property: 'gateway', type: 'string'), + new OA\Property(property: 'ref_id', type: 'string', nullable: true), + new OA\Property(property: 'patient_mobile', type: 'string'), + new OA\Property(property: 'paid_at', type: 'string', format: 'date-time', nullable: true), + new OA\Property(property: 'created_at', type: 'string', format: 'date-time'), + ] + )), + new OA\Property(property: 'meta', properties: [ + new OA\Property(property: 'totalRecords', type: 'integer'), + new OA\Property(property: 'totalPages', type: 'integer'), + new OA\Property(property: 'currentPage', type: 'integer'), + ], type: 'object'), + ] + ) + ), + new OA\Response(response: 401, description: 'Unauthorized'), + new OA\Response(response: 403, description: 'Forbidden – ROLE_ADMIN required'), + ] + )] #[Route('/api/v1/admin/payments', methods: ['GET'])] public function payments(Request $request): JsonResponse { @@ -164,6 +288,49 @@ class AdminApiController extends BaseController // ── Representations ─────────────────────────────────────────────────────── + #[OA\Get( + path: '/api/v1/admin/representations', + summary: 'List all representations (paginated)', + security: [['bearerAuth' => []]], + parameters: [ + new OA\Parameter(name: 'page', in: 'query', required: false, schema: new OA\Schema(type: 'integer', default: 1)), + new OA\Parameter(name: 'limit', in: 'query', required: false, schema: new OA\Schema(type: 'integer', default: 15)), + new OA\Parameter(name: 'search', in: 'query', required: false, schema: new OA\Schema(type: 'string')), + new OA\Parameter(name: 'city_id', in: 'query', required: false, schema: new OA\Schema(type: 'integer')), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Paginated list of representations', + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'success', type: 'boolean', example: true), + new OA\Property(property: 'data', type: 'array', items: new OA\Items( + properties: [ + new OA\Property(property: 'uuid', type: 'string'), + new OA\Property(property: 'domain', type: 'string'), + new OA\Property(property: 'full_name', type: 'string'), + new OA\Property(property: 'mobile_number', type: 'string'), + new OA\Property(property: 'city_id', type: 'integer', nullable: true), + new OA\Property(property: 'city', type: 'string', nullable: true), + new OA\Property(property: 'commission_percent', type: 'number', format: 'float'), + new OA\Property(property: 'wallet_balance', type: 'integer'), + new OA\Property(property: 'is_active', type: 'boolean'), + new OA\Property(property: 'created_at', type: 'string', format: 'date-time'), + ] + )), + new OA\Property(property: 'meta', properties: [ + new OA\Property(property: 'totalRecords', type: 'integer'), + new OA\Property(property: 'totalPages', type: 'integer'), + new OA\Property(property: 'currentPage', type: 'integer'), + ], type: 'object'), + ] + ) + ), + new OA\Response(response: 401, description: 'Unauthorized'), + new OA\Response(response: 403, description: 'Forbidden – ROLE_ADMIN required'), + ] + )] #[Route('/api/v1/admin/representations', methods: ['GET'])] public function representations(Request $request): JsonResponse { @@ -211,6 +378,46 @@ class AdminApiController extends BaseController // ── Secretaries ─────────────────────────────────────────────────────────── + #[OA\Get( + path: '/api/v1/admin/secretaries', + summary: 'List all secretaries (paginated)', + security: [['bearerAuth' => []]], + parameters: [ + new OA\Parameter(name: 'page', in: 'query', required: false, schema: new OA\Schema(type: 'integer', default: 1)), + new OA\Parameter(name: 'limit', in: 'query', required: false, schema: new OA\Schema(type: 'integer', default: 15)), + new OA\Parameter(name: 'search', in: 'query', required: false, schema: new OA\Schema(type: 'string')), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Paginated list of secretaries', + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'success', type: 'boolean', example: true), + new OA\Property(property: 'data', type: 'array', items: new OA\Items( + properties: [ + new OA\Property(property: 'uuid', type: 'string'), + new OA\Property(property: 'user_name', type: 'string'), + new OA\Property(property: 'mobile_number', type: 'string'), + new OA\Property(property: 'doctor_name', type: 'string'), + new OA\Property(property: 'doctor_uuid', type: 'string'), + new OA\Property(property: 'is_active', type: 'boolean'), + new OA\Property(property: 'permissions', type: 'array', items: new OA\Items(type: 'string')), + new OA\Property(property: 'created_at', type: 'string', format: 'date-time'), + ] + )), + new OA\Property(property: 'meta', properties: [ + new OA\Property(property: 'totalRecords', type: 'integer'), + new OA\Property(property: 'totalPages', type: 'integer'), + new OA\Property(property: 'currentPage', type: 'integer'), + ], type: 'object'), + ] + ) + ), + new OA\Response(response: 401, description: 'Unauthorized'), + new OA\Response(response: 403, description: 'Forbidden – ROLE_ADMIN required'), + ] + )] #[Route('/api/v1/admin/secretaries', methods: ['GET'])] public function secretaries(Request $request): JsonResponse { @@ -255,6 +462,44 @@ class AdminApiController extends BaseController // ── Ratings ─────────────────────────────────────────────────────────────── + #[OA\Get( + path: '/api/v1/admin/rates', + summary: 'List all ratings (paginated)', + security: [['bearerAuth' => []]], + parameters: [ + new OA\Parameter(name: 'page', in: 'query', required: false, schema: new OA\Schema(type: 'integer', default: 1)), + new OA\Parameter(name: 'limit', in: 'query', required: false, schema: new OA\Schema(type: 'integer', default: 15)), + new OA\Parameter(name: 'search', in: 'query', required: false, schema: new OA\Schema(type: 'string')), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Paginated list of ratings', + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'success', type: 'boolean', example: true), + new OA\Property(property: 'data', type: 'array', items: new OA\Items( + properties: [ + new OA\Property(property: 'uuid', type: 'string'), + new OA\Property(property: 'patient_name', type: 'string'), + new OA\Property(property: 'doctor_name', type: 'string'), + new OA\Property(property: 'overall', type: 'integer'), + new OA\Property(property: 'score', type: 'integer'), + new OA\Property(property: 'created_at', type: 'string', format: 'date-time'), + ] + )), + new OA\Property(property: 'meta', properties: [ + new OA\Property(property: 'totalRecords', type: 'integer'), + new OA\Property(property: 'totalPages', type: 'integer'), + new OA\Property(property: 'currentPage', type: 'integer'), + ], type: 'object'), + ] + ) + ), + new OA\Response(response: 401, description: 'Unauthorized'), + new OA\Response(response: 403, description: 'Forbidden – ROLE_ADMIN required'), + ] + )] #[Route('/api/v1/admin/rates', methods: ['GET'])] public function rates(Request $request): JsonResponse { @@ -297,6 +542,47 @@ class AdminApiController extends BaseController // ── Comments ────────────────────────────────────────────────────────────── + #[OA\Get( + path: '/api/v1/admin/comments', + summary: 'List all comments (paginated)', + security: [['bearerAuth' => []]], + parameters: [ + new OA\Parameter(name: 'page', in: 'query', required: false, schema: new OA\Schema(type: 'integer', default: 1)), + new OA\Parameter(name: 'limit', in: 'query', required: false, schema: new OA\Schema(type: 'integer', default: 15)), + new OA\Parameter(name: 'search', in: 'query', required: false, schema: new OA\Schema(type: 'string')), + new OA\Parameter(name: 'status', in: 'query', required: false, schema: new OA\Schema(type: 'string', enum: ['pending', 'approved', 'rejected'])), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Paginated list of comments', + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'success', type: 'boolean', example: true), + new OA\Property(property: 'data', type: 'array', items: new OA\Items( + properties: [ + new OA\Property(property: 'uuid', type: 'string'), + new OA\Property(property: 'patient_name', type: 'string'), + new OA\Property(property: 'doctor_name', type: 'string'), + new OA\Property(property: 'title', type: 'string'), + new OA\Property(property: 'body', type: 'string'), + new OA\Property(property: 'is_approved', type: 'boolean'), + new OA\Property(property: 'status', type: 'string'), + new OA\Property(property: 'created_at', type: 'string', format: 'date-time'), + ] + )), + new OA\Property(property: 'meta', properties: [ + new OA\Property(property: 'totalRecords', type: 'integer'), + new OA\Property(property: 'totalPages', type: 'integer'), + new OA\Property(property: 'currentPage', type: 'integer'), + ], type: 'object'), + ] + ) + ), + new OA\Response(response: 401, description: 'Unauthorized'), + new OA\Response(response: 403, description: 'Forbidden – ROLE_ADMIN required'), + ] + )] #[Route('/api/v1/admin/comments', methods: ['GET'])] public function comments(Request $request): JsonResponse { @@ -345,6 +631,44 @@ class AdminApiController extends BaseController // ── SMS Logs ────────────────────────────────────────────────────────────── + #[OA\Get( + path: '/api/v1/admin/sms/logs', + summary: 'List SMS send logs (paginated)', + security: [['bearerAuth' => []]], + parameters: [ + new OA\Parameter(name: 'page', in: 'query', required: false, schema: new OA\Schema(type: 'integer', default: 1)), + new OA\Parameter(name: 'limit', in: 'query', required: false, schema: new OA\Schema(type: 'integer', default: 15)), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Paginated list of SMS logs', + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'success', type: 'boolean', example: true), + new OA\Property(property: 'data', type: 'array', items: new OA\Items( + properties: [ + new OA\Property(property: 'uuid', type: 'string'), + new OA\Property(property: 'recipient', type: 'string'), + new OA\Property(property: 'message', type: 'string'), + new OA\Property(property: 'status', type: 'string', enum: ['sent', 'failed']), + new OA\Property(property: 'provider', type: 'string'), + new OA\Property(property: 'sent_at', type: 'string', format: 'date-time'), + new OA\Property(property: 'created_at', type: 'string', format: 'date-time'), + ] + )), + new OA\Property(property: 'meta', properties: [ + new OA\Property(property: 'totalRecords', type: 'integer'), + new OA\Property(property: 'totalPages', type: 'integer'), + new OA\Property(property: 'currentPage', type: 'integer'), + ], type: 'object'), + ] + ) + ), + new OA\Response(response: 401, description: 'Unauthorized'), + new OA\Response(response: 403, description: 'Forbidden – ROLE_ADMIN required'), + ] + )] #[Route('/api/v1/admin/sms/logs', methods: ['GET'])] public function smsLogs(Request $request): JsonResponse { @@ -376,6 +700,47 @@ class AdminApiController extends BaseController // ── Settlements ─────────────────────────────────────────────────────────── + #[OA\Get( + path: '/api/v1/admin/settlements', + summary: 'List all settlement requests (paginated)', + security: [['bearerAuth' => []]], + parameters: [ + new OA\Parameter(name: 'page', in: 'query', required: false, schema: new OA\Schema(type: 'integer', default: 1)), + new OA\Parameter(name: 'limit', in: 'query', required: false, schema: new OA\Schema(type: 'integer', default: 15)), + new OA\Parameter(name: 'status', in: 'query', required: false, schema: new OA\Schema(type: 'string', enum: ['pending', 'approved', 'rejected'])), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Paginated list of settlement requests', + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'success', type: 'boolean', example: true), + new OA\Property(property: 'data', type: 'array', items: new OA\Items( + properties: [ + new OA\Property(property: 'uuid', type: 'string'), + new OA\Property(property: 'representation_name', type: 'string'), + new OA\Property(property: 'amount', type: 'integer'), + new OA\Property(property: 'status', type: 'string'), + new OA\Property(property: 'bank_card', type: 'string', nullable: true), + new OA\Property(property: 'bank_name', type: 'string', nullable: true), + new OA\Property(property: 'reject_reason', type: 'string', nullable: true), + new OA\Property(property: 'requested_at', type: 'string', format: 'date-time'), + new OA\Property(property: 'processed_at', type: 'string', format: 'date-time', nullable: true), + ] + )), + new OA\Property(property: 'meta', properties: [ + new OA\Property(property: 'totalRecords', type: 'integer'), + new OA\Property(property: 'totalPages', type: 'integer'), + new OA\Property(property: 'currentPage', type: 'integer'), + ], type: 'object'), + ] + ) + ), + new OA\Response(response: 401, description: 'Unauthorized'), + new OA\Response(response: 403, description: 'Forbidden – ROLE_ADMIN required'), + ] + )] #[Route('/api/v1/admin/settlements', methods: ['GET'])] public function settlements(Request $request): JsonResponse { @@ -418,6 +783,45 @@ class AdminApiController extends BaseController // ── SMS Templates (paginated list with optional status filter) ──────────── + #[OA\Get( + path: '/api/v1/admin/sms/sample-templates', + summary: 'List SMS sample templates (paginated)', + security: [['bearerAuth' => []]], + parameters: [ + new OA\Parameter(name: 'page', in: 'query', required: false, schema: new OA\Schema(type: 'integer', default: 1)), + new OA\Parameter(name: 'limit', in: 'query', required: false, schema: new OA\Schema(type: 'integer', default: 15)), + new OA\Parameter(name: 'status', in: 'query', required: false, schema: new OA\Schema(type: 'string', enum: ['approved', 'pending'], default: 'approved')), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Paginated list of SMS templates', + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'success', type: 'boolean', example: true), + new OA\Property(property: 'data', type: 'array', items: new OA\Items( + properties: [ + new OA\Property(property: 'uuid', type: 'string'), + new OA\Property(property: 'name', type: 'string'), + new OA\Property(property: 'body', type: 'string'), + new OA\Property(property: 'provider_code', type: 'string', nullable: true), + new OA\Property(property: 'status', type: 'string'), + new OA\Property(property: 'admin_note', type: 'string', nullable: true), + new OA\Property(property: 'created_at', type: 'string', format: 'date-time'), + ] + )), + new OA\Property(property: 'meta', properties: [ + new OA\Property(property: 'totalRecords', type: 'integer'), + new OA\Property(property: 'totalPages', type: 'integer'), + new OA\Property(property: 'currentPage', type: 'integer'), + ], type: 'object'), + ] + ) + ), + new OA\Response(response: 401, description: 'Unauthorized'), + new OA\Response(response: 403, description: 'Forbidden – ROLE_ADMIN required'), + ] + )] #[Route('/api/v1/admin/sms/sample-templates', methods: ['GET'])] public function smsSampleTemplates(Request $request): JsonResponse { @@ -454,6 +858,57 @@ class AdminApiController extends BaseController // ── Dashboard Recent ────────────────────────────────────────────────────── + #[OA\Get( + path: '/api/v1/admin/dashboard/recent', + summary: 'Get recent dashboard data (appointments, payments, users)', + security: [['bearerAuth' => []]], + responses: [ + new OA\Response( + response: 200, + description: 'Recent dashboard data', + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'success', type: 'boolean', example: true), + new OA\Property(property: 'data', properties: [ + new OA\Property(property: 'appointments', type: 'array', items: new OA\Items( + properties: [ + new OA\Property(property: 'uuid', type: 'string'), + new OA\Property(property: 'slot_start', type: 'string', format: 'date-time'), + new OA\Property(property: 'status', type: 'string'), + new OA\Property(property: 'doctor_name', type: 'string'), + new OA\Property(property: 'user_mobile', type: 'string'), + new OA\Property(property: 'user_name', type: 'string'), + new OA\Property(property: 'created_at', type: 'string', format: 'date-time'), + ] + )), + new OA\Property(property: 'payments', type: 'array', items: new OA\Items( + properties: [ + new OA\Property(property: 'uuid', type: 'string'), + new OA\Property(property: 'amount', type: 'integer'), + new OA\Property(property: 'status', type: 'string'), + new OA\Property(property: 'gateway', type: 'string'), + new OA\Property(property: 'user_mobile', type: 'string'), + new OA\Property(property: 'user_name', type: 'string'), + new OA\Property(property: 'created_at', type: 'string', format: 'date-time'), + ] + )), + new OA\Property(property: 'users', type: 'array', items: new OA\Items( + properties: [ + new OA\Property(property: 'uuid', type: 'string'), + new OA\Property(property: 'mobile', type: 'string'), + new OA\Property(property: 'name', type: 'string'), + new OA\Property(property: 'email', type: 'string', nullable: true), + new OA\Property(property: 'created_at', type: 'string', format: 'date-time'), + ] + )), + ], type: 'object'), + ] + ) + ), + new OA\Response(response: 401, description: 'Unauthorized'), + new OA\Response(response: 403, description: 'Forbidden – ROLE_ADMIN required'), + ] + )] #[Route('/api/v1/admin/dashboard/recent', methods: ['GET'])] public function dashboardRecent(): JsonResponse { @@ -521,6 +976,37 @@ class AdminApiController extends BaseController // ── Dashboard Stats ─────────────────────────────────────────────────────── + #[OA\Get( + path: '/api/v1/admin/dashboard/stats', + summary: 'Get aggregated dashboard statistics', + security: [['bearerAuth' => []]], + responses: [ + new OA\Response( + response: 200, + description: 'Dashboard statistics', + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'success', type: 'boolean', example: true), + new OA\Property(property: 'data', properties: [ + new OA\Property(property: 'total_users', type: 'integer'), + new OA\Property(property: 'active_doctors', type: 'integer'), + new OA\Property(property: 'total_doctors', type: 'integer'), + new OA\Property(property: 'total_clinics', type: 'integer'), + new OA\Property(property: 'today_appointments', type: 'integer'), + new OA\Property(property: 'total_appointments', type: 'integer'), + new OA\Property(property: 'today_payments_count', type: 'integer'), + new OA\Property(property: 'today_payments_amount', type: 'integer'), + new OA\Property(property: 'total_payments_amount', type: 'integer'), + new OA\Property(property: 'pending_comments', type: 'integer'), + new OA\Property(property: 'pending_settlements', type: 'integer'), + ], type: 'object'), + ] + ) + ), + new OA\Response(response: 401, description: 'Unauthorized'), + new OA\Response(response: 403, description: 'Forbidden – ROLE_ADMIN required'), + ] + )] #[Route('/api/v1/admin/dashboard/stats', methods: ['GET'])] public function dashboardStats(): JsonResponse { diff --git a/src/Appointment/Controller/AppointmentController.php b/src/Appointment/Controller/AppointmentController.php index 80bd694d..36e7dc62 100644 --- a/src/Appointment/Controller/AppointmentController.php +++ b/src/Appointment/Controller/AppointmentController.php @@ -10,12 +10,14 @@ use App\Doctor\Repository\DoctorRepository; use App\Shared\Constant\ErrorCodes; use App\Shared\Controller\BaseController; use Doctrine\ORM\OptimisticLockException; +use OpenApi\Attributes as OA; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Security\Http\Attribute\CurrentUser; use Symfony\Component\Security\Http\Attribute\IsGranted; +#[OA\Tag(name: 'Appointments')] class AppointmentController extends BaseController { public function __construct( @@ -26,6 +28,78 @@ class AppointmentController extends BaseController // ── Public: available slots ─────────────────────────────────────────────── + #[OA\Get( + path: '/api/v1/appointment-slots', + summary: 'Get available appointment slots for a doctor on a given date', + parameters: [ + new OA\Parameter( + name: 'doctor_uuid', + in: 'query', + required: true, + schema: new OA\Schema(type: 'string', format: 'uuid') + ), + new OA\Parameter( + name: 'date', + in: 'query', + required: true, + description: 'Date in Y-m-d format', + schema: new OA\Schema(type: 'string', format: 'date', example: '2025-06-15') + ), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Available slots returned', + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'success', type: 'boolean', example: true), + new OA\Property( + property: 'data', + properties: [ + new OA\Property(property: 'doctor_uuid', type: 'string', format: 'uuid'), + new OA\Property(property: 'date', type: 'string', format: 'date'), + new OA\Property( + property: 'slots', + type: 'array', + items: new OA\Items( + properties: [ + new OA\Property(property: 'start', type: 'integer', description: 'Unix timestamp'), + new OA\Property(property: 'end', type: 'integer', description: 'Unix timestamp'), + new OA\Property(property: 'available', type: 'boolean'), + ], + type: 'object' + ) + ), + ], + type: 'object' + ), + new OA\Property(property: 'errors', type: 'array', items: new OA\Items()), + ] + ) + ), + new OA\Response( + response: 404, + description: 'Doctor not found', + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'success', type: 'boolean', example: false), + new OA\Property( + property: 'errors', + type: 'array', + items: new OA\Items( + properties: [ + new OA\Property(property: 'code', type: 'string'), + new OA\Property(property: 'message', type: 'string'), + ], + type: 'object' + ) + ), + ] + ) + ), + new OA\Response(response: 422, description: 'Invalid date format'), + ] + )] #[Route('/api/v1/appointment-slots', methods: ['GET'])] public function slots(Request $request): JsonResponse { @@ -52,6 +126,40 @@ class AppointmentController extends BaseController // ── Authenticated: book / manage ───────────────────────────────────────── + #[OA\Post( + path: '/api/v1/appointment', + summary: 'Book a new appointment', + security: [['bearerAuth' => []]], + requestBody: new OA\RequestBody( + required: true, + content: new OA\JsonContent( + required: ['doctor_uuid', 'slot_start', 'slot_end'], + properties: [ + new OA\Property(property: 'doctor_uuid', type: 'string', format: 'uuid'), + new OA\Property(property: 'slot_start', type: 'integer', description: 'Slot start Unix timestamp'), + new OA\Property(property: 'slot_end', type: 'integer', description: 'Slot end Unix timestamp'), + new OA\Property(property: 'note', type: 'string'), + ] + ) + ), + responses: [ + new OA\Response( + response: 201, + description: 'Appointment booked successfully', + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'success', type: 'boolean', example: true), + new OA\Property(property: 'data', type: 'object', description: 'Appointment object'), + new OA\Property(property: 'errors', type: 'array', items: new OA\Items()), + ] + ) + ), + new OA\Response(response: 401, description: 'Unauthenticated'), + new OA\Response(response: 404, description: 'Doctor not found'), + new OA\Response(response: 409, description: 'Slot already taken'), + new OA\Response(response: 422, description: 'Validation error'), + ] + )] #[IsGranted('IS_AUTHENTICATED_FULLY')] #[Route('/api/v1/appointment', methods: ['POST'])] public function book(Request $request, #[CurrentUser] User $user): JsonResponse @@ -82,6 +190,35 @@ class AppointmentController extends BaseController return $this->success(['data' => $appointment->toArray()], 201); } + #[OA\Get( + path: '/api/v1/appointment/{uuid}', + summary: 'Get a single appointment by UUID', + security: [['bearerAuth' => []]], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + required: true, + schema: new OA\Schema(type: 'string', format: 'uuid') + ), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Appointment returned', + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'success', type: 'boolean', example: true), + new OA\Property(property: 'data', type: 'object', description: 'Appointment object'), + new OA\Property(property: 'errors', type: 'array', items: new OA\Items()), + ] + ) + ), + new OA\Response(response: 401, description: 'Unauthenticated'), + new OA\Response(response: 403, description: 'Access denied'), + new OA\Response(response: 404, description: 'Appointment not found'), + ] + )] #[IsGranted('IS_AUTHENTICATED_FULLY')] #[Route('/api/v1/appointment/{uuid}', methods: ['GET'])] public function get(string $uuid, #[CurrentUser] User $user): JsonResponse @@ -98,6 +235,46 @@ class AppointmentController extends BaseController return $this->success(['data' => $appointment->toArray()]); } + #[OA\Get( + path: '/api/v1/appointments/doctor/{doctorUuid}', + summary: 'List appointments for a specific doctor', + security: [['bearerAuth' => []]], + parameters: [ + new OA\Parameter( + name: 'doctorUuid', + in: 'path', + required: true, + schema: new OA\Schema(type: 'string', format: 'uuid') + ), + new OA\Parameter( + name: 'status', + in: 'query', + required: false, + description: 'Filter by appointment status', + schema: new OA\Schema(type: 'string', example: 'pending') + ), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Appointment list returned', + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'success', type: 'boolean', example: true), + new OA\Property( + property: 'data', + type: 'array', + items: new OA\Items(type: 'object', description: 'Appointment object') + ), + new OA\Property(property: 'errors', type: 'array', items: new OA\Items()), + ] + ) + ), + new OA\Response(response: 401, description: 'Unauthenticated'), + new OA\Response(response: 403, description: 'Access denied'), + new OA\Response(response: 404, description: 'Doctor not found'), + ] + )] #[IsGranted('IS_AUTHENTICATED_FULLY')] #[Route('/api/v1/appointments/doctor/{doctorUuid}', methods: ['GET'])] public function listByDoctor(string $doctorUuid, Request $request, #[CurrentUser] User $user): JsonResponse @@ -117,6 +294,38 @@ class AppointmentController extends BaseController return $this->success(['data' => array_map(fn(Appointment $a) => $a->toArray(), $appointments)]); } + #[OA\Get( + path: '/api/v1/appointments/user', + summary: 'List appointments for the authenticated user', + security: [['bearerAuth' => []]], + parameters: [ + new OA\Parameter( + name: 'status', + in: 'query', + required: false, + description: 'Filter by appointment status', + schema: new OA\Schema(type: 'string', example: 'pending') + ), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Appointment list returned', + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'success', type: 'boolean', example: true), + new OA\Property( + property: 'data', + type: 'array', + items: new OA\Items(type: 'object', description: 'Appointment object') + ), + new OA\Property(property: 'errors', type: 'array', items: new OA\Items()), + ] + ) + ), + new OA\Response(response: 401, description: 'Unauthenticated'), + ] + )] #[IsGranted('IS_AUTHENTICATED_FULLY')] #[Route('/api/v1/appointments/user', methods: ['GET'])] public function listByUser(Request $request, #[CurrentUser] User $user): JsonResponse @@ -141,6 +350,47 @@ class AppointmentController extends BaseController || $user->hasRole('ROLE_ADMIN'); } + #[OA\Patch( + path: '/api/v1/appointment/{uuid}/status', + summary: 'Update the status of an appointment', + security: [['bearerAuth' => []]], + requestBody: new OA\RequestBody( + required: true, + content: new OA\JsonContent( + required: ['status'], + properties: [ + new OA\Property(property: 'status', type: 'string', example: 'confirmed'), + new OA\Property(property: 'version', type: 'integer', description: 'Optimistic lock version'), + ] + ) + ), + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + required: true, + schema: new OA\Schema(type: 'string', format: 'uuid') + ), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Appointment status updated', + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'success', type: 'boolean', example: true), + new OA\Property(property: 'data', type: 'object', description: 'Updated appointment object'), + new OA\Property(property: 'errors', type: 'array', items: new OA\Items()), + ] + ) + ), + new OA\Response(response: 401, description: 'Unauthenticated'), + new OA\Response(response: 403, description: 'Access denied'), + new OA\Response(response: 404, description: 'Appointment not found'), + new OA\Response(response: 409, description: 'Optimistic lock conflict'), + new OA\Response(response: 422, description: 'Invalid status transition'), + ] + )] #[IsGranted('IS_AUTHENTICATED_FULLY')] #[Route('/api/v1/appointment/{uuid}/status', methods: ['PATCH'])] public function updateStatus(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse diff --git a/src/Auth/Controller/AuthController.php b/src/Auth/Controller/AuthController.php index d44354c9..3fe3a399 100644 --- a/src/Auth/Controller/AuthController.php +++ b/src/Auth/Controller/AuthController.php @@ -8,12 +8,14 @@ use App\Auth\Service\OtpService; use App\Auth\Service\TokenService; use App\Shared\Constant\ErrorCodes; use App\Shared\Controller\BaseController; +use OpenApi\Attributes as OA; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\RateLimiter\RateLimiterFactory; use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Security\Http\Attribute\CurrentUser; +#[OA\Tag(name: 'Auth')] class AuthController extends BaseController { public function __construct( @@ -27,12 +29,110 @@ class AuthController extends BaseController * Route exists so the router resolves it; PasswordAuthenticator intercepts * and returns the JWT response before this controller body ever runs. */ + #[OA\Post( + path: '/api/v1/user/login', + summary: 'Staff login (Admin / Doctor / Clinic / Secretary)', + description: 'ورود با شماره موبایل و رمز عبور — فقط برای کاربران دارای نقش ROLE_ADMIN، ROLE_DOCTOR، ROLE_CLINIC یا ROLE_SECRETARY. کاربران عادی باید از OTP استفاده کنند.', + requestBody: new OA\RequestBody( + required: true, + content: new OA\JsonContent( + required: ['mobile_number', 'password'], + properties: [ + new OA\Property(property: 'mobile_number', type: 'string', example: '09120671713', description: 'شماره موبایل ثبت‌شده'), + new OA\Property(property: 'password', type: 'string', format: 'password', example: 'admin1234', description: 'رمز عبور'), + ] + ) + ), + responses: [ + new OA\Response( + response: 200, + description: 'ورود موفق — JWT و refresh token برگردانده می‌شود', + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'access_token', type: 'string', description: 'JWT — عمر ۱ ساعت'), + new OA\Property(property: 'refresh_token', type: 'string', description: 'Refresh token — عمر ۳۰ روز'), + new OA\Property(property: 'token_type', type: 'string', example: 'Bearer'), + new OA\Property(property: 'expires_in', type: 'integer', example: 3600), + new OA\Property(property: 'refresh_token_expires_in', type: 'integer', example: 2592000), + ] + ) + ), + new OA\Response( + response: 401, + description: 'اطلاعات ورود نادرست', + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'success', type: 'boolean', example: false), + new OA\Property( + property: 'errors', + type: 'array', + items: new OA\Items( + properties: [ + new OA\Property(property: 'code', type: 'string', example: 'ERR_AUTH_005'), + new OA\Property(property: 'message', type: 'string'), + ], + type: 'object' + ) + ), + ] + ) + ), + new OA\Response(response: 403, description: 'کاربر نقش staff ندارد (ROLE_ADMIN/ROLE_DOCTOR/ROLE_CLINIC/ROLE_SECRETARY)'), + new OA\Response(response: 429, description: 'تعداد تلاش‌های ورود از حد مجاز گذشت'), + ] + )] #[Route('/api/v1/user/login', methods: ['POST'])] public function login(): JsonResponse { return $this->error(ErrorCodes::ERR_AUTH_005, ErrorCodes::message(ErrorCodes::ERR_AUTH_005), 401); } + #[OA\Post( + path: '/api/v1/user/send-code', + summary: 'Send OTP code to mobile number', + requestBody: new OA\RequestBody( + required: true, + content: new OA\JsonContent( + required: ['mobile'], + properties: [ + new OA\Property(property: 'mobile', type: 'string', example: '09123456789'), + ] + ) + ), + responses: [ + new OA\Response( + response: 200, + description: 'OTP sent successfully', + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'uuid', type: 'string', format: 'uuid'), + new OA\Property(property: 'message', type: 'string', example: 'کد تایید با موفقیت ارسال شد.'), + ] + ) + ), + new OA\Response( + response: 422, + description: 'Invalid mobile format', + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'success', type: 'boolean', example: false), + new OA\Property( + property: 'errors', + type: 'array', + items: new OA\Items( + properties: [ + new OA\Property(property: 'code', type: 'string'), + new OA\Property(property: 'message', type: 'string'), + ], + type: 'object' + ) + ), + ] + ) + ), + new OA\Response(response: 429, description: 'Rate limit exceeded'), + ] + )] #[Route('/api/v1/user/send-code', methods: ['POST'])] public function sendCode(Request $request): JsonResponse { @@ -53,6 +153,59 @@ class AuthController extends BaseController return new JsonResponse(['uuid' => $uuid, 'message' => 'کد تایید با موفقیت ارسال شد.']); } + #[OA\Post( + path: '/api/v1/user/verify-code', + summary: 'Verify OTP code', + requestBody: new OA\RequestBody( + required: true, + content: new OA\JsonContent( + required: ['uuid', 'code'], + properties: [ + new OA\Property(property: 'uuid', type: 'string', format: 'uuid'), + new OA\Property(property: 'code', type: 'string', example: '12345'), + ] + ) + ), + responses: [ + new OA\Response( + response: 200, + description: 'Code verified successfully', + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'success', type: 'boolean', example: true), + new OA\Property( + property: 'data', + properties: [ + new OA\Property(property: 'message', type: 'string', example: 'کد با موفقیت تایید شد.'), + ], + type: 'object' + ), + new OA\Property(property: 'errors', type: 'array', items: new OA\Items()), + ] + ) + ), + new OA\Response( + response: 422, + description: 'Missing or invalid uuid/code', + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'success', type: 'boolean', example: false), + new OA\Property( + property: 'errors', + type: 'array', + items: new OA\Items( + properties: [ + new OA\Property(property: 'code', type: 'string'), + new OA\Property(property: 'message', type: 'string'), + ], + type: 'object' + ) + ), + ] + ) + ), + ] + )] #[Route('/api/v1/user/verify-code', methods: ['POST'])] public function verifyCode(Request $request): JsonResponse { @@ -69,6 +222,60 @@ class AuthController extends BaseController return $this->success(['message' => 'کد با موفقیت تایید شد.']); } + #[OA\Post( + path: '/api/v1/user/register', + summary: 'Register a new user after OTP verification', + requestBody: new OA\RequestBody( + required: true, + content: new OA\JsonContent( + required: ['uuid'], + properties: [ + new OA\Property(property: 'uuid', type: 'string', format: 'uuid'), + new OA\Property(property: 'real_name', type: 'string', example: 'علی محمدی'), + ] + ) + ), + responses: [ + new OA\Response( + response: 201, + description: 'User registered successfully', + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'success', type: 'boolean', example: true), + new OA\Property( + property: 'data', + properties: [ + new OA\Property(property: 'message', type: 'string', example: 'ثبت‌نام با موفقیت انجام شد.'), + new OA\Property(property: 'uuid', type: 'string', format: 'uuid'), + ], + type: 'object' + ), + new OA\Property(property: 'errors', type: 'array', items: new OA\Items()), + ] + ) + ), + new OA\Response( + response: 422, + description: 'Missing uuid', + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'success', type: 'boolean', example: false), + new OA\Property( + property: 'errors', + type: 'array', + items: new OA\Items( + properties: [ + new OA\Property(property: 'code', type: 'string'), + new OA\Property(property: 'message', type: 'string'), + ], + type: 'object' + ) + ), + ] + ) + ), + ] + )] #[Route('/api/v1/user/register', methods: ['POST'])] public function register(Request $request): JsonResponse { @@ -94,6 +301,52 @@ class AuthController extends BaseController return $this->success(['message' => 'ثبت‌نام با موفقیت انجام شد.', 'uuid' => $user->getUuid()], 201); } + #[OA\Post( + path: '/oauth/token', + summary: 'Issue access and refresh tokens using OTP-verified UUID', + requestBody: new OA\RequestBody( + required: true, + content: new OA\JsonContent( + required: ['grant_type', 'uuid'], + properties: [ + new OA\Property(property: 'grant_type', type: 'string', enum: ['mobile'], example: 'mobile'), + new OA\Property(property: 'uuid', type: 'string', format: 'uuid'), + ] + ) + ), + responses: [ + new OA\Response( + response: 200, + description: 'Tokens issued successfully', + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'token', type: 'string'), + new OA\Property(property: 'refresh_token', type: 'string'), + ] + ) + ), + new OA\Response( + response: 400, + description: 'Invalid grant_type', + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'success', type: 'boolean', example: false), + new OA\Property( + property: 'errors', + type: 'array', + items: new OA\Items( + properties: [ + new OA\Property(property: 'code', type: 'string'), + new OA\Property(property: 'message', type: 'string'), + ], + type: 'object' + ) + ), + ] + ) + ), + ] + )] #[Route('/oauth/token', methods: ['POST'])] public function issueToken(Request $request): JsonResponse { @@ -115,6 +368,51 @@ class AuthController extends BaseController return new JsonResponse($this->tokenService->issueTokens($user)); } + #[OA\Post( + path: '/oauth/token/refresh', + summary: 'Refresh access token using a refresh token', + requestBody: new OA\RequestBody( + required: true, + content: new OA\JsonContent( + required: ['refresh_token'], + properties: [ + new OA\Property(property: 'refresh_token', type: 'string'), + ] + ) + ), + responses: [ + new OA\Response( + response: 200, + description: 'Token refreshed successfully', + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'token', type: 'string'), + new OA\Property(property: 'refresh_token', type: 'string'), + ] + ) + ), + new OA\Response( + response: 401, + description: 'Invalid or missing refresh token', + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'success', type: 'boolean', example: false), + new OA\Property( + property: 'errors', + type: 'array', + items: new OA\Items( + properties: [ + new OA\Property(property: 'code', type: 'string'), + new OA\Property(property: 'message', type: 'string'), + ], + type: 'object' + ) + ), + ] + ) + ), + ] + )] #[Route('/oauth/token/refresh', methods: ['POST'])] public function refreshToken(Request $request): JsonResponse { @@ -138,6 +436,55 @@ class AuthController extends BaseController return new JsonResponse($tokens); } + #[OA\Get( + path: '/oauth/userinfo', + summary: 'Get current authenticated user info', + security: [['bearerAuth' => []]], + responses: [ + new OA\Response( + response: 200, + description: 'User info returned successfully', + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'success', type: 'boolean', example: true), + new OA\Property( + property: 'data', + properties: [ + new OA\Property(property: 'id', type: 'integer'), + new OA\Property(property: 'uuid', type: 'string', format: 'uuid'), + new OA\Property(property: 'mobile_number', type: 'string'), + new OA\Property(property: 'realName', type: 'string'), + new OA\Property(property: 'status', type: 'string'), + new OA\Property(property: 'roles', type: 'array', items: new OA\Items(type: 'string')), + ], + type: 'object' + ), + new OA\Property(property: 'errors', type: 'array', items: new OA\Items()), + ] + ) + ), + new OA\Response( + response: 401, + description: 'Unauthenticated', + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'success', type: 'boolean', example: false), + new OA\Property( + property: 'errors', + type: 'array', + items: new OA\Items( + properties: [ + new OA\Property(property: 'code', type: 'string'), + new OA\Property(property: 'message', type: 'string'), + ], + type: 'object' + ) + ), + ] + ) + ), + ] + )] #[Route('/oauth/userinfo', methods: ['GET'])] public function userInfo(#[CurrentUser] ?User $user): JsonResponse { @@ -155,6 +502,37 @@ class AuthController extends BaseController ]); } + #[OA\Post( + path: '/oauth/logout', + summary: 'Logout and optionally revoke refresh token', + requestBody: new OA\RequestBody( + required: false, + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'refresh_token', type: 'string'), + ] + ) + ), + responses: [ + new OA\Response( + response: 200, + description: 'Logged out successfully', + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'success', type: 'boolean', example: true), + new OA\Property( + property: 'data', + properties: [ + new OA\Property(property: 'message', type: 'string', example: 'خروج با موفقیت انجام شد'), + ], + type: 'object' + ), + new OA\Property(property: 'errors', type: 'array', items: new OA\Items()), + ] + ) + ), + ] + )] #[Route('/oauth/logout', methods: ['POST'])] public function logout(Request $request): JsonResponse { diff --git a/src/Blog/Controller/BlogController.php b/src/Blog/Controller/BlogController.php index fec61377..6a7d3262 100644 --- a/src/Blog/Controller/BlogController.php +++ b/src/Blog/Controller/BlogController.php @@ -8,12 +8,14 @@ use App\Blog\Repository\BlogRepository; use App\Shared\Constant\ErrorCodes; use App\Shared\Controller\BaseController; use App\Shared\Service\FileValidatorService; +use OpenApi\Attributes as OA; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Security\Http\Attribute\CurrentUser; use Symfony\Component\Security\Http\Attribute\IsGranted; +#[OA\Tag(name: 'Blog')] class BlogController extends BaseController { public function __construct( @@ -24,6 +26,35 @@ class BlogController extends BaseController // ── Public list/detail ──────────────────────────────────────────────────── + #[OA\Get( + path: '/api/v1/blogs', + summary: 'List published blog posts (paginated)', + parameters: [ + new OA\Parameter(name: 'page', in: 'query', required: false, schema: new OA\Schema(type: 'integer', default: 1)), + new OA\Parameter(name: 'limit', in: 'query', required: false, schema: new OA\Schema(type: 'integer', default: 20, maximum: 50)), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Paginated list of published blog posts', + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'success', type: 'boolean', example: true), + new OA\Property(property: 'data', type: 'array', items: new OA\Items(type: 'object')), + new OA\Property( + property: 'meta', + properties: [ + new OA\Property(property: 'totalRecords', type: 'integer'), + new OA\Property(property: 'totalPages', type: 'integer'), + new OA\Property(property: 'currentPage', type: 'integer'), + ], + type: 'object' + ), + ] + ) + ), + ] + )] #[Route('/api/v1/blogs', methods: ['GET'])] public function list(Request $request): JsonResponse { @@ -36,6 +67,38 @@ class BlogController extends BaseController return $this->paginated($blogs, $total, $page, $limit); } + #[OA\Get( + path: '/api/v1/blog/{slug}', + summary: 'Get a published blog post by slug or UUID', + parameters: [ + new OA\Parameter( + name: 'slug', + in: 'path', + required: true, + description: 'Blog slug or UUID', + schema: new OA\Schema(type: 'string') + ), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Blog post detail', + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'success', type: 'boolean', example: true), + new OA\Property( + property: 'data', + properties: [ + new OA\Property(property: 'data', type: 'object'), + ], + type: 'object' + ), + ] + ) + ), + new OA\Response(response: 404, description: 'Blog post not found or not published'), + ] + )] #[Route('/api/v1/blog/{slug}', methods: ['GET'])] public function detail(string $slug): JsonResponse { @@ -49,6 +112,63 @@ class BlogController extends BaseController // ── Admin CRUD ──────────────────────────────────────────────────────────── + #[OA\Post( + path: '/api/v1/blog', + summary: 'Create a new blog post (admin only)', + security: [['bearerAuth' => []]], + requestBody: new OA\RequestBody( + required: true, + content: new OA\JsonContent( + required: ['title', 'body'], + properties: [ + new OA\Property(property: 'title', type: 'string'), + new OA\Property(property: 'body', type: 'string'), + new OA\Property(property: 'summary', type: 'string', nullable: true), + new OA\Property(property: 'tags', type: 'array', items: new OA\Items(type: 'string'), nullable: true), + new OA\Property(property: 'status', type: 'string', enum: ['draft', 'published'], nullable: true), + ] + ) + ), + responses: [ + new OA\Response( + response: 201, + description: 'Blog post created', + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'success', type: 'boolean', example: true), + new OA\Property( + property: 'data', + properties: [ + new OA\Property(property: 'data', type: 'object'), + ], + type: 'object' + ), + ] + ) + ), + new OA\Response(response: 401, description: 'Unauthorized'), + new OA\Response(response: 403, description: 'Forbidden — admin role required'), + new OA\Response( + response: 422, + description: 'Validation error', + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'success', type: 'boolean', example: false), + new OA\Property( + property: 'errors', + type: 'array', + items: new OA\Items( + properties: [ + new OA\Property(property: 'code', type: 'string'), + new OA\Property(property: 'message', type: 'string'), + ] + ) + ), + ] + ) + ), + ] + )] #[IsGranted('ROLE_ADMIN')] #[Route('/api/v1/blog', methods: ['POST'])] public function create(Request $request, #[CurrentUser] User $user): JsonResponse @@ -76,6 +196,52 @@ class BlogController extends BaseController return $this->success(['data' => $blog->toArray()], 201); } + #[OA\Patch( + path: '/api/v1/blog/{uuid}', + summary: 'Update a blog post (admin only)', + security: [['bearerAuth' => []]], + requestBody: new OA\RequestBody( + required: true, + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'title', type: 'string', nullable: true), + new OA\Property(property: 'body', type: 'string', nullable: true), + new OA\Property(property: 'summary', type: 'string', nullable: true), + new OA\Property(property: 'tags', type: 'array', items: new OA\Items(type: 'string'), nullable: true), + new OA\Property(property: 'status', type: 'string', enum: ['draft', 'published'], nullable: true), + ] + ) + ), + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + required: true, + schema: new OA\Schema(type: 'string', format: 'uuid') + ), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Blog post updated', + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'success', type: 'boolean', example: true), + new OA\Property( + property: 'data', + properties: [ + new OA\Property(property: 'data', type: 'object'), + ], + type: 'object' + ), + ] + ) + ), + new OA\Response(response: 401, description: 'Unauthorized'), + new OA\Response(response: 403, description: 'Forbidden — admin role required'), + new OA\Response(response: 404, description: 'Blog post not found'), + ] + )] #[IsGranted('ROLE_ADMIN')] #[Route('/api/v1/blog/{uuid}', methods: ['PATCH'])] public function update(string $uuid, Request $request): JsonResponse @@ -97,6 +263,40 @@ class BlogController extends BaseController return $this->success(['data' => $blog->toArray()]); } + #[OA\Delete( + path: '/api/v1/blog/{uuid}', + summary: 'Delete a blog post (admin only)', + security: [['bearerAuth' => []]], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + required: true, + schema: new OA\Schema(type: 'string', format: 'uuid') + ), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Blog post deleted', + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'success', type: 'boolean', example: true), + new OA\Property( + property: 'data', + properties: [ + new OA\Property(property: 'message', type: 'string'), + ], + type: 'object' + ), + ] + ) + ), + new OA\Response(response: 401, description: 'Unauthorized'), + new OA\Response(response: 403, description: 'Forbidden — admin role required'), + new OA\Response(response: 404, description: 'Blog post not found'), + ] + )] #[IsGranted('ROLE_ADMIN')] #[Route('/api/v1/blog/{uuid}', methods: ['DELETE'])] public function delete(string $uuid): JsonResponse @@ -112,6 +312,64 @@ class BlogController extends BaseController // ── Image upload ────────────────────────────────────────────────────────── + #[OA\Post( + path: '/file/upload/clinic_pro/blog/field_image', + summary: 'Upload a blog post image (admin only)', + security: [['bearerAuth' => []]], + requestBody: new OA\RequestBody( + required: true, + content: new OA\MediaType( + mediaType: 'multipart/form-data', + schema: new OA\Schema( + required: ['file'], + properties: [ + new OA\Property(property: 'file', type: 'string', format: 'binary'), + new OA\Property(property: 'blog_uuid', type: 'string', format: 'uuid', nullable: true), + ] + ) + ) + ), + responses: [ + new OA\Response( + response: 200, + description: 'Image uploaded', + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'success', type: 'boolean', example: true), + new OA\Property( + property: 'data', + properties: [ + new OA\Property(property: 'image_url', type: 'string'), + new OA\Property(property: 'filename', type: 'string'), + ], + type: 'object' + ), + ] + ) + ), + new OA\Response(response: 401, description: 'Unauthorized'), + new OA\Response(response: 403, description: 'Forbidden — admin role required'), + new OA\Response( + response: 422, + description: 'No file provided or invalid file', + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'success', type: 'boolean', example: false), + new OA\Property( + property: 'errors', + type: 'array', + items: new OA\Items( + properties: [ + new OA\Property(property: 'code', type: 'string'), + new OA\Property(property: 'message', type: 'string'), + ] + ) + ), + ] + ) + ), + ] + )] #[IsGranted('ROLE_ADMIN')] #[Route('/file/upload/clinic_pro/blog/field_image', methods: ['POST'])] public function uploadImage(Request $request): JsonResponse diff --git a/src/Clinic/Controller/ClinicController.php b/src/Clinic/Controller/ClinicController.php index f8d6ca30..13f7eb13 100644 --- a/src/Clinic/Controller/ClinicController.php +++ b/src/Clinic/Controller/ClinicController.php @@ -12,6 +12,7 @@ use App\Doctor\Repository\DoctorRepository; use App\Shared\Constant\ErrorCodes; use App\Shared\Controller\BaseController; use App\Shared\Service\FileValidatorService; +use OpenApi\Attributes as OA; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\Routing\Attribute\Route; @@ -19,6 +20,7 @@ use Symfony\Component\Security\Http\Attribute\CurrentUser; use Symfony\Component\Security\Http\Attribute\IsGranted; use Symfony\Component\Uid\Uuid; +#[OA\Tag(name: 'Clinics')] class ClinicController extends BaseController { public function __construct( @@ -30,6 +32,47 @@ class ClinicController extends BaseController private readonly string $projectDir, ) {} + #[OA\Post( + path: '/api/v1/clinic', + summary: 'Create a new clinic', + security: [['bearerAuth' => []]], + requestBody: new OA\RequestBody( + required: true, + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'name', type: 'string'), + new OA\Property(property: 'info', type: 'string', nullable: true), + new OA\Property(property: 'address', type: 'string', nullable: true), + new OA\Property(property: 'telephone', type: 'string', nullable: true), + new OA\Property(property: 'working_days', type: 'array', items: new OA\Items(type: 'string'), nullable: true), + new OA\Property(property: '24_7', type: 'boolean', nullable: true), + new OA\Property(property: 'latitude', type: 'number', format: 'float', nullable: true), + new OA\Property(property: 'longitude', type: 'number', format: 'float', nullable: true), + new OA\Property(property: 'state', type: 'array', items: new OA\Items(type: 'integer'), nullable: true), + new OA\Property(property: 'city', type: 'array', items: new OA\Items(type: 'integer'), nullable: true), + new OA\Property(property: 'image_clinic', type: 'array', items: new OA\Items(type: 'string'), nullable: true), + new OA\Property(property: 'clinic_logo', type: 'array', items: new OA\Items(type: 'string'), nullable: true), + new OA\Property(property: 'doctors', type: 'array', items: new OA\Items(type: 'integer'), nullable: true), + new OA\Property(property: 'specialties', type: 'array', items: new OA\Items(type: 'integer'), nullable: true), + new OA\Property(property: 'doctor_services', type: 'array', items: new OA\Items(type: 'integer'), nullable: true), + new OA\Property(property: 'insurance', type: 'array', items: new OA\Items(type: 'integer'), nullable: true), + ] + ) + ), + responses: [ + new OA\Response( + response: 201, + description: 'Clinic created', + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'success', type: 'boolean', example: true), + new OA\Property(property: 'data', type: 'object'), + ] + ) + ), + new OA\Response(response: 401, description: 'Unauthorized'), + ] + )] #[Route('/api/v1/clinic', methods: ['POST'])] #[IsGranted('IS_AUTHENTICATED_FULLY')] public function create(Request $request, #[CurrentUser] User $user): JsonResponse @@ -50,6 +93,31 @@ class ClinicController extends BaseController return $this->success(['data' => $clinic->toDetailArray()], 201); } + #[OA\Get( + path: '/api/v1/clinic/{uuid}', + summary: 'Get clinic details by UUID', + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + required: true, + schema: new OA\Schema(type: 'string', format: 'uuid') + ), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Clinic details', + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'success', type: 'boolean', example: true), + new OA\Property(property: 'data', type: 'object'), + ] + ) + ), + new OA\Response(response: 404, description: 'Clinic not found'), + ] + )] #[Route('/api/v1/clinic/{uuid}', methods: ['GET'])] public function show(string $uuid): JsonResponse { @@ -63,6 +131,57 @@ class ClinicController extends BaseController return $this->success(['data' => $clinic->toDetailArray($stateData, $cityData)]); } + #[OA\Patch( + path: '/api/v1/clinic/{uuid}', + summary: 'Update clinic details', + security: [['bearerAuth' => []]], + requestBody: new OA\RequestBody( + required: true, + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'name', type: 'string', nullable: true), + new OA\Property(property: 'info', type: 'string', nullable: true), + new OA\Property(property: 'address', type: 'string', nullable: true), + new OA\Property(property: 'telephone', type: 'string', nullable: true), + new OA\Property(property: 'working_days', type: 'array', items: new OA\Items(type: 'string'), nullable: true), + new OA\Property(property: '24_7', type: 'boolean', nullable: true), + new OA\Property(property: 'latitude', type: 'number', format: 'float', nullable: true), + new OA\Property(property: 'longitude', type: 'number', format: 'float', nullable: true), + new OA\Property(property: 'state', type: 'array', items: new OA\Items(type: 'integer'), nullable: true), + new OA\Property(property: 'city', type: 'array', items: new OA\Items(type: 'integer'), nullable: true), + new OA\Property(property: 'image_clinic', type: 'array', items: new OA\Items(type: 'string'), nullable: true), + new OA\Property(property: 'clinic_logo', type: 'array', items: new OA\Items(type: 'string'), nullable: true), + new OA\Property(property: 'doctors', type: 'array', items: new OA\Items(type: 'integer'), nullable: true), + new OA\Property(property: 'specialties', type: 'array', items: new OA\Items(type: 'integer'), nullable: true), + new OA\Property(property: 'doctor_services', type: 'array', items: new OA\Items(type: 'integer'), nullable: true), + new OA\Property(property: 'insurance', type: 'array', items: new OA\Items(type: 'integer'), nullable: true), + ] + ) + ), + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + required: true, + schema: new OA\Schema(type: 'string', format: 'uuid') + ), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Clinic updated', + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'success', type: 'boolean', example: true), + new OA\Property(property: 'data', type: 'object'), + ] + ) + ), + new OA\Response(response: 401, description: 'Unauthorized'), + new OA\Response(response: 403, description: 'Forbidden'), + new OA\Response(response: 404, description: 'Clinic not found'), + ] + )] #[Route('/api/v1/clinic/{uuid}', methods: ['PATCH'])] #[IsGranted('IS_AUTHENTICATED_FULLY')] public function update(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse @@ -85,6 +204,37 @@ class ClinicController extends BaseController return $this->success(['data' => $clinic->toDetailArray($stateData, $cityData)]); } + #[OA\Get( + path: '/api/v1/clinics', + summary: 'List clinics with optional filters (paginated)', + parameters: [ + new OA\Parameter(name: 'page', in: 'query', required: false, schema: new OA\Schema(type: 'integer', default: 1)), + new OA\Parameter(name: 'limit', in: 'query', required: false, schema: new OA\Schema(type: 'integer', default: 20)), + new OA\Parameter(name: 'name', in: 'query', required: false, schema: new OA\Schema(type: 'string')), + new OA\Parameter(name: 'city', in: 'query', required: false, schema: new OA\Schema(type: 'integer')), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Paginated list of clinics', + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'success', type: 'boolean', example: true), + new OA\Property(property: 'data', type: 'array', items: new OA\Items(type: 'object')), + new OA\Property( + property: 'meta', + properties: [ + new OA\Property(property: 'totalRecords', type: 'integer'), + new OA\Property(property: 'totalPages', type: 'integer'), + new OA\Property(property: 'currentPage', type: 'integer'), + ], + type: 'object' + ), + ] + ) + ), + ] + )] #[Route('/api/v1/clinics', methods: ['GET'])] public function list(Request $request): JsonResponse { @@ -99,6 +249,37 @@ class ClinicController extends BaseController ); } + #[OA\Get( + path: '/api/v1/clinic/doctor-list/{clinicUuid}', + summary: 'Get list of doctors for a clinic', + parameters: [ + new OA\Parameter( + name: 'clinicUuid', + in: 'path', + required: true, + schema: new OA\Schema(type: 'string', format: 'uuid') + ), + ], + responses: [ + new OA\Response( + response: 200, + description: 'List of doctors', + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'success', type: 'boolean', example: true), + new OA\Property( + property: 'data', + properties: [ + new OA\Property(property: 'data', type: 'array', items: new OA\Items(type: 'object')), + ], + type: 'object' + ), + ] + ) + ), + new OA\Response(response: 404, description: 'Clinic not found'), + ] + )] #[Route('/api/v1/clinic/doctor-list/{clinicUuid}', methods: ['GET'])] public function doctorList(string $clinicUuid): JsonResponse { @@ -115,6 +296,43 @@ class ClinicController extends BaseController return $this->success(['data' => $doctors]); } + #[OA\Post( + path: '/file/upload/clinic_pro/clinic/field_image_clinic', + summary: 'Upload a clinic gallery image', + security: [['bearerAuth' => []]], + requestBody: new OA\RequestBody( + required: true, + content: new OA\MediaType( + mediaType: 'application/octet-stream', + schema: new OA\Schema(type: 'string', format: 'binary') + ) + ), + responses: [ + new OA\Response( + response: 200, + description: 'Image uploaded', + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'success', type: 'boolean', example: true), + new OA\Property( + property: 'data', + properties: [ + new OA\Property(property: 'fid', type: 'integer'), + new OA\Property(property: 'uuid', type: 'string', format: 'uuid'), + new OA\Property(property: 'url', type: 'string'), + new OA\Property(property: 'filename', type: 'string'), + new OA\Property(property: 'filemime', type: 'string'), + new OA\Property(property: 'filesize', type: 'integer'), + ], + type: 'object' + ), + ] + ) + ), + new OA\Response(response: 401, description: 'Unauthorized'), + new OA\Response(response: 422, description: 'Invalid file'), + ] + )] #[Route('/file/upload/clinic_pro/clinic/field_image_clinic', methods: ['POST'])] #[IsGranted('IS_AUTHENTICATED_FULLY')] public function uploadImage(Request $request): JsonResponse @@ -122,6 +340,43 @@ class ClinicController extends BaseController return $this->handleFileUpload($request, 'clinics/gallery'); } + #[OA\Post( + path: '/file/upload/clinic_pro/clinic/field_clinic_logo', + summary: 'Upload a clinic logo image', + security: [['bearerAuth' => []]], + requestBody: new OA\RequestBody( + required: true, + content: new OA\MediaType( + mediaType: 'application/octet-stream', + schema: new OA\Schema(type: 'string', format: 'binary') + ) + ), + responses: [ + new OA\Response( + response: 200, + description: 'Logo uploaded', + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'success', type: 'boolean', example: true), + new OA\Property( + property: 'data', + properties: [ + new OA\Property(property: 'fid', type: 'integer'), + new OA\Property(property: 'uuid', type: 'string', format: 'uuid'), + new OA\Property(property: 'url', type: 'string'), + new OA\Property(property: 'filename', type: 'string'), + new OA\Property(property: 'filemime', type: 'string'), + new OA\Property(property: 'filesize', type: 'integer'), + ], + type: 'object' + ), + ] + ) + ), + new OA\Response(response: 401, description: 'Unauthorized'), + new OA\Response(response: 422, description: 'Invalid file'), + ] + )] #[Route('/file/upload/clinic_pro/clinic/field_clinic_logo', methods: ['POST'])] #[IsGranted('IS_AUTHENTICATED_FULLY')] public function uploadLogo(Request $request): JsonResponse diff --git a/src/Doctor/Controller/DoctorController.php b/src/Doctor/Controller/DoctorController.php index d64fbe0e..1109057d 100644 --- a/src/Doctor/Controller/DoctorController.php +++ b/src/Doctor/Controller/DoctorController.php @@ -12,12 +12,14 @@ use App\Doctor\Repository\DoctorRepository; use App\Shared\Constant\ErrorCodes; use App\Shared\Controller\BaseController; use App\Shared\Service\FileValidatorService; +use OpenApi\Attributes as OA; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Security\Http\Attribute\CurrentUser; use Symfony\Component\Security\Http\Attribute\IsGranted; +#[OA\Tag(name: 'Doctors')] class DoctorController extends BaseController { public function __construct( @@ -31,6 +33,51 @@ class DoctorController extends BaseController // ── Doctor CRUD ─────────────────────────────────────────────────────────── + #[OA\Post( + path: '/api/v1/doctor', + summary: 'Create a new doctor profile', + security: [['bearerAuth' => []]], + requestBody: new OA\RequestBody( + required: true, + content: new OA\JsonContent( + required: ['title'], + properties: [ + new OA\Property(property: 'title', type: 'string', description: 'Doctor name (also accepted as "name")'), + new OA\Property(property: 'gender', type: 'string', nullable: true), + new OA\Property(property: 'medical_system_code', type: 'string', nullable: true), + new OA\Property(property: 'degree', type: 'string', nullable: true), + new OA\Property(property: 'info', type: 'string', nullable: true), + new OA\Property( + property: 'specialties', + type: 'array', + items: new OA\Items(type: 'integer'), + nullable: true, + ), + new OA\Property( + property: 'doctor_services', + type: 'array', + items: new OA\Items(type: 'integer'), + nullable: true, + ), + ] + ) + ), + responses: [ + new OA\Response( + response: 201, + description: 'Doctor created successfully', + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'success', type: 'boolean', example: true), + new OA\Property(property: 'data', type: 'object', description: 'Doctor detail object'), + ] + ) + ), + new OA\Response(response: 401, description: 'Unauthorized'), + new OA\Response(response: 409, description: 'Doctor profile already exists'), + new OA\Response(response: 422, description: 'Validation error'), + ] + )] #[Route('/api/v1/doctor', methods: ['POST'])] #[IsGranted('IS_AUTHENTICATED_FULLY')] public function create(Request $request, #[CurrentUser] User $user): JsonResponse @@ -61,6 +108,26 @@ class DoctorController extends BaseController return $this->success(['data' => $doctor->toDetailArray()], 201); } + #[OA\Get( + path: '/api/v1/doctor/{uuid}', + summary: 'Get a doctor by UUID', + parameters: [ + new OA\Parameter(name: 'uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Doctor detail', + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'success', type: 'boolean', example: true), + new OA\Property(property: 'data', type: 'object', description: 'Doctor detail object'), + ] + ) + ), + new OA\Response(response: 404, description: 'Doctor not found'), + ] + )] #[Route('/api/v1/doctor/{uuid}', methods: ['GET'])] public function show(string $uuid): JsonResponse { @@ -72,6 +139,39 @@ class DoctorController extends BaseController return $this->success(['data' => $doctor->toDetailArray()]); } + #[OA\Get( + path: '/api/v1/doctors', + summary: 'List doctors with optional filters (paginated)', + parameters: [ + new OA\Parameter(name: 'page', in: 'query', required: false, schema: new OA\Schema(type: 'integer', default: 1)), + new OA\Parameter(name: 'limit', in: 'query', required: false, schema: new OA\Schema(type: 'integer', default: 20)), + new OA\Parameter(name: 'search', in: 'query', required: false, schema: new OA\Schema(type: 'string')), + new OA\Parameter(name: 'specialty_id', in: 'query', required: false, schema: new OA\Schema(type: 'integer')), + new OA\Parameter(name: 'city_id', in: 'query', required: false, schema: new OA\Schema(type: 'integer')), + new OA\Parameter(name: 'state_id', in: 'query', required: false, schema: new OA\Schema(type: 'integer')), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Paginated doctor list', + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'success', type: 'boolean', example: true), + new OA\Property(property: 'data', type: 'array', items: new OA\Items(type: 'object')), + new OA\Property( + property: 'meta', + properties: [ + new OA\Property(property: 'totalRecords', type: 'integer'), + new OA\Property(property: 'totalPages', type: 'integer'), + new OA\Property(property: 'currentPage', type: 'integer'), + ], + type: 'object' + ), + ] + ) + ), + ] + )] #[Route('/api/v1/doctors', methods: ['GET'])] public function list(Request $request): JsonResponse { @@ -86,6 +186,53 @@ class DoctorController extends BaseController ); } + #[OA\Patch( + path: '/api/v1/doctor/{uuid}', + summary: 'Update a doctor profile', + security: [['bearerAuth' => []]], + requestBody: new OA\RequestBody( + required: false, + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'title', type: 'string', nullable: true), + new OA\Property(property: 'gender', type: 'string', nullable: true), + new OA\Property(property: 'medical_system_code', type: 'string', nullable: true), + new OA\Property(property: 'degree', type: 'string', nullable: true), + new OA\Property(property: 'info', type: 'string', nullable: true), + new OA\Property( + property: 'specialties', + type: 'array', + items: new OA\Items(type: 'integer'), + nullable: true, + ), + new OA\Property( + property: 'doctor_services', + type: 'array', + items: new OA\Items(type: 'integer'), + nullable: true, + ), + ] + ) + ), + parameters: [ + new OA\Parameter(name: 'uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Doctor updated successfully', + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'success', type: 'boolean', example: true), + new OA\Property(property: 'data', type: 'object', description: 'Doctor detail object'), + ] + ) + ), + new OA\Response(response: 401, description: 'Unauthorized'), + new OA\Response(response: 403, description: 'Forbidden'), + new OA\Response(response: 404, description: 'Doctor not found'), + ] + )] #[Route('/api/v1/doctor/{uuid}', methods: ['PATCH'])] #[IsGranted('IS_AUTHENTICATED_FULLY')] public function update(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse @@ -108,6 +255,31 @@ class DoctorController extends BaseController return $this->success(['data' => $doctor->toDetailArray()]); } + #[OA\Delete( + path: '/api/v1/doctor/{uuid}', + summary: 'Delete a doctor (ROLE_ADMIN only)', + security: [['bearerAuth' => []]], + parameters: [ + new OA\Parameter(name: 'uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Doctor deleted successfully', + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'success', type: 'boolean', example: true), + new OA\Property(property: 'data', type: 'object', properties: [ + new OA\Property(property: 'message', type: 'string'), + ]), + ] + ) + ), + new OA\Response(response: 401, description: 'Unauthorized'), + new OA\Response(response: 403, description: 'Forbidden — ROLE_ADMIN required'), + new OA\Response(response: 404, description: 'Doctor not found'), + ] + )] #[Route('/api/v1/doctor/{uuid}', methods: ['DELETE'])] #[IsGranted('ROLE_ADMIN')] public function delete(string $uuid): JsonResponse @@ -123,6 +295,52 @@ class DoctorController extends BaseController // ── File Upload ─────────────────────────────────────────────────────────── + #[OA\Post( + path: '/file/upload/clinic_pro/doctor/field_image', + summary: 'Upload a doctor profile image (raw binary)', + security: [['bearerAuth' => []]], + requestBody: new OA\RequestBody( + required: true, + content: new OA\MediaType( + mediaType: 'application/octet-stream', + schema: new OA\Schema(type: 'string', format: 'binary') + ) + ), + parameters: [ + new OA\Parameter( + name: 'Content-Disposition', + in: 'header', + required: true, + description: 'Must include filename, e.g. attachment; filename="photo.jpg"', + schema: new OA\Schema(type: 'string') + ), + ], + responses: [ + new OA\Response( + response: 200, + description: 'File uploaded successfully', + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'success', type: 'boolean', example: true), + new OA\Property( + property: 'data', + properties: [ + new OA\Property(property: 'fid', type: 'integer'), + new OA\Property(property: 'uuid', type: 'string', format: 'uuid'), + new OA\Property(property: 'url', type: 'string'), + new OA\Property(property: 'filename', type: 'string'), + new OA\Property(property: 'filemime', type: 'string'), + new OA\Property(property: 'filesize', type: 'integer'), + ], + type: 'object' + ), + ] + ) + ), + new OA\Response(response: 401, description: 'Unauthorized'), + new OA\Response(response: 422, description: 'Invalid file'), + ] + )] #[Route('/file/upload/clinic_pro/doctor/field_image', methods: ['POST'])] #[IsGranted('IS_AUTHENTICATED_FULLY')] public function uploadImage(Request $request): JsonResponse @@ -171,6 +389,39 @@ class DoctorController extends BaseController // ── Doctor Addresses ────────────────────────────────────────────────────── + #[OA\Post( + path: '/api/v1/clinic-pro/doctor-address', + summary: 'Create a new doctor address', + security: [['bearerAuth' => []]], + requestBody: new OA\RequestBody( + required: false, + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'name', type: 'string', nullable: true), + new OA\Property(property: 'address', type: 'string', nullable: true), + new OA\Property(property: 'telephone', type: 'string', nullable: true), + new OA\Property(property: 'latitude', type: 'number', format: 'float', nullable: true), + new OA\Property(property: 'longitude', type: 'number', format: 'float', nullable: true), + ] + ) + ), + responses: [ + new OA\Response( + response: 201, + description: 'Address created successfully', + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'success', type: 'boolean', example: true), + new OA\Property(property: 'data', type: 'object', description: 'Address object'), + ] + ) + ), + new OA\Response(response: 401, description: 'Unauthorized'), + new OA\Response(response: 403, description: 'Forbidden'), + new OA\Response(response: 404, description: 'Doctor not found'), + new OA\Response(response: 422, description: 'Validation error'), + ] + )] #[Route('/api/v1/clinic-pro/doctor-address', methods: ['POST'])] #[IsGranted('IS_AUTHENTICATED_FULLY')] public function createAddress(Request $request, #[CurrentUser] User $user): JsonResponse @@ -201,6 +452,28 @@ class DoctorController extends BaseController return $this->success(['data' => $address->toArray()], 201); } + #[OA\Get( + path: '/api/v1/clinic-pro/doctor-address/{id}', + summary: 'Get a doctor address by ID', + security: [['bearerAuth' => []]], + parameters: [ + new OA\Parameter(name: 'id', in: 'path', required: true, schema: new OA\Schema(type: 'integer')), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Address detail', + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'success', type: 'boolean', example: true), + new OA\Property(property: 'data', type: 'object', description: 'Address object'), + ] + ) + ), + new OA\Response(response: 401, description: 'Unauthorized'), + new OA\Response(response: 404, description: 'Address not found'), + ] + )] #[Route('/api/v1/clinic-pro/doctor-address/{id}', methods: ['GET'])] #[IsGranted('IS_AUTHENTICATED_FULLY')] public function showAddress(int $id): JsonResponse @@ -213,6 +486,41 @@ class DoctorController extends BaseController return $this->success(['data' => $address->toArray()]); } + #[OA\Patch( + path: '/api/v1/clinic-pro/doctor-address/{id}', + summary: 'Update a doctor address', + security: [['bearerAuth' => []]], + requestBody: new OA\RequestBody( + required: false, + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'name', type: 'string', nullable: true), + new OA\Property(property: 'address', type: 'string', nullable: true), + new OA\Property(property: 'telephone', type: 'string', nullable: true), + new OA\Property(property: 'latitude', type: 'number', format: 'float', nullable: true), + new OA\Property(property: 'longitude', type: 'number', format: 'float', nullable: true), + ] + ) + ), + parameters: [ + new OA\Parameter(name: 'id', in: 'path', required: true, schema: new OA\Schema(type: 'integer')), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Address updated successfully', + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'success', type: 'boolean', example: true), + new OA\Property(property: 'data', type: 'object', description: 'Address object'), + ] + ) + ), + new OA\Response(response: 401, description: 'Unauthorized'), + new OA\Response(response: 403, description: 'Forbidden'), + new OA\Response(response: 404, description: 'Address not found'), + ] + )] #[Route('/api/v1/clinic-pro/doctor-address/{id}', methods: ['PATCH'])] #[IsGranted('IS_AUTHENTICATED_FULLY')] public function updateAddress(int $id, Request $request, #[CurrentUser] User $user): JsonResponse @@ -233,6 +541,31 @@ class DoctorController extends BaseController return $this->success(['data' => $address->toArray()]); } + #[OA\Delete( + path: '/api/v1/clinic-pro/doctor-address/{id}', + summary: 'Delete a doctor address', + security: [['bearerAuth' => []]], + parameters: [ + new OA\Parameter(name: 'id', in: 'path', required: true, schema: new OA\Schema(type: 'integer')), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Address deleted successfully', + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'success', type: 'boolean', example: true), + new OA\Property(property: 'data', type: 'object', properties: [ + new OA\Property(property: 'message', type: 'string'), + ]), + ] + ) + ), + new OA\Response(response: 401, description: 'Unauthorized'), + new OA\Response(response: 403, description: 'Forbidden'), + new OA\Response(response: 404, description: 'Address not found'), + ] + )] #[Route('/api/v1/clinic-pro/doctor-address/{id}', methods: ['DELETE'])] #[IsGranted('IS_AUTHENTICATED_FULLY')] public function deleteAddress(int $id, #[CurrentUser] User $user): JsonResponse @@ -250,6 +583,26 @@ class DoctorController extends BaseController return $this->success(['message' => 'آدرس با موفقیت حذف شد']); } + #[OA\Get( + path: '/api/v1/clinic-pro/doctor-addresses/{doctorId}', + summary: 'List all addresses for a doctor', + parameters: [ + new OA\Parameter(name: 'doctorId', in: 'path', required: true, schema: new OA\Schema(type: 'integer')), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Array of address objects', + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'success', type: 'boolean', example: true), + new OA\Property(property: 'data', type: 'array', items: new OA\Items(type: 'object')), + ] + ) + ), + new OA\Response(response: 404, description: 'Doctor not found'), + ] + )] #[Route('/api/v1/clinic-pro/doctor-addresses/{doctorId}', methods: ['GET'])] public function listAddresses(int $doctorId): JsonResponse { @@ -263,15 +616,6 @@ class DoctorController extends BaseController return $this->success(['data' => $addresses]); } - // ── Clinic/Doctor list (stub — implemented fully in Task 06) ────────────── - - #[Route('/api/v1/clinic/doctor-list/{clinicUuid}', methods: ['GET'])] - public function clinicDoctorList(string $clinicUuid): JsonResponse - { - // Full implementation in Task 06 (Clinic entity not yet created) - return $this->success(['data' => []]); - } - // ── Helpers ─────────────────────────────────────────────────────────────── private function hydrateDoctor(Doctor $doctor, array $data): void diff --git a/src/Payment/Controller/PaymentController.php b/src/Payment/Controller/PaymentController.php index 284b70b6..46cdd7d1 100644 --- a/src/Payment/Controller/PaymentController.php +++ b/src/Payment/Controller/PaymentController.php @@ -12,6 +12,7 @@ use App\Payment\Repository\PaymentRepository; use App\Payment\Service\CircuitBreakerService; use App\Shared\Constant\ErrorCodes; use App\Shared\Controller\BaseController; +use OpenApi\Attributes as OA; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\RedirectResponse; use Symfony\Component\HttpFoundation\Request; @@ -19,6 +20,7 @@ use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Security\Http\Attribute\CurrentUser; use Symfony\Component\Security\Http\Attribute\IsGranted; +#[OA\Tag(name: 'Payments')] class PaymentController extends BaseController { // Shaparak payment network callback IP ranges @@ -39,6 +41,99 @@ class PaymentController extends BaseController // ── Appointment Payment ─────────────────────────────────────────────────── + #[OA\Post( + path: '/api/v1/payment/appointment', + summary: 'Initiate an appointment payment', + security: [['bearerAuth' => []]], + requestBody: new OA\RequestBody( + required: true, + content: new OA\JsonContent( + required: ['appointment_uuid', 'gateway'], + properties: [ + new OA\Property(property: 'appointment_uuid', type: 'string', format: 'uuid'), + new OA\Property(property: 'gateway', type: 'string', enum: ['mellat', 'sep']), + new OA\Property(property: 'frontend_address', type: 'string', format: 'uri', nullable: true), + ] + ) + ), + responses: [ + new OA\Response( + response: 200, + description: 'Payment initiated', + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'success', type: 'boolean', example: true), + new OA\Property( + property: 'data', + properties: [ + new OA\Property(property: 'payment_uuid', type: 'string', format: 'uuid'), + new OA\Property(property: 'redirect_url', type: 'string', format: 'uri'), + new OA\Property(property: 'order_id', type: 'string'), + ], + type: 'object' + ), + ] + ) + ), + new OA\Response( + response: 401, + description: 'Unauthorized', + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'success', type: 'boolean', example: false), + new OA\Property( + property: 'errors', + type: 'array', + items: new OA\Items( + properties: [ + new OA\Property(property: 'code', type: 'string'), + new OA\Property(property: 'message', type: 'string'), + ] + ) + ), + ] + ) + ), + new OA\Response( + response: 422, + description: 'Validation error', + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'success', type: 'boolean', example: false), + new OA\Property( + property: 'errors', + type: 'array', + items: new OA\Items( + properties: [ + new OA\Property(property: 'code', type: 'string'), + new OA\Property(property: 'message', type: 'string'), + ] + ) + ), + ] + ) + ), + new OA\Response( + response: 503, + description: 'Gateway unavailable', + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'success', type: 'boolean', example: false), + new OA\Property( + property: 'errors', + type: 'array', + items: new OA\Items( + properties: [ + new OA\Property(property: 'code', type: 'string'), + new OA\Property(property: 'message', type: 'string'), + ] + ) + ), + ] + ) + ), + ] + )] #[IsGranted('IS_AUTHENTICATED_FULLY')] #[Route('/api/v1/payment/appointment', methods: ['POST'])] public function initiateAppointment(Request $request, #[CurrentUser] User $user): JsonResponse @@ -100,6 +195,33 @@ class PaymentController extends BaseController // ── Payment Callback (public — no JWT) ─────────────────────────────────── + #[OA\Post( + path: '/api/v1/payment/callback/{gateway}', + summary: 'Payment gateway callback (public, IP-restricted)', + parameters: [ + new OA\Parameter( + name: 'gateway', + in: 'path', + required: true, + schema: new OA\Schema(type: 'string', enum: ['mellat', 'sep']) + ), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Callback processed — either a redirect or JSON result', + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'success', type: 'boolean'), + new OA\Property(property: 'payment', type: 'object'), + ] + ) + ), + new OA\Response(response: 302, description: 'Redirect to frontend with payment result'), + new OA\Response(response: 403, description: 'Forbidden — IP not in allowed Shaparak ranges'), + new OA\Response(response: 404, description: 'Payment not found'), + ] + )] #[Route('/api/v1/payment/callback/{gateway}', methods: ['POST', 'GET'])] public function callback(string $gateway, Request $request): \Symfony\Component\HttpFoundation\Response { @@ -139,6 +261,99 @@ class PaymentController extends BaseController // ── Subscription Payment ────────────────────────────────────────────────── + #[OA\Post( + path: '/api/v1/subscription-payment', + summary: 'Initiate a subscription payment', + security: [['bearerAuth' => []]], + requestBody: new OA\RequestBody( + required: true, + content: new OA\JsonContent( + required: ['gateway', 'amount_rials'], + properties: [ + new OA\Property(property: 'gateway', type: 'string', enum: ['mellat', 'sep']), + new OA\Property(property: 'frontend_address', type: 'string', format: 'uri', nullable: true), + new OA\Property(property: 'amount_rials', type: 'integer', minimum: 1), + ] + ) + ), + responses: [ + new OA\Response( + response: 200, + description: 'Subscription payment initiated', + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'success', type: 'boolean', example: true), + new OA\Property( + property: 'data', + properties: [ + new OA\Property(property: 'payment_uuid', type: 'string', format: 'uuid'), + new OA\Property(property: 'redirect_url', type: 'string', format: 'uri'), + new OA\Property(property: 'order_id', type: 'string'), + ], + type: 'object' + ), + ] + ) + ), + new OA\Response( + response: 401, + description: 'Unauthorized', + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'success', type: 'boolean', example: false), + new OA\Property( + property: 'errors', + type: 'array', + items: new OA\Items( + properties: [ + new OA\Property(property: 'code', type: 'string'), + new OA\Property(property: 'message', type: 'string'), + ] + ) + ), + ] + ) + ), + new OA\Response( + response: 422, + description: 'Validation error', + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'success', type: 'boolean', example: false), + new OA\Property( + property: 'errors', + type: 'array', + items: new OA\Items( + properties: [ + new OA\Property(property: 'code', type: 'string'), + new OA\Property(property: 'message', type: 'string'), + ] + ) + ), + ] + ) + ), + new OA\Response( + response: 503, + description: 'Gateway unavailable', + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'success', type: 'boolean', example: false), + new OA\Property( + property: 'errors', + type: 'array', + items: new OA\Items( + properties: [ + new OA\Property(property: 'code', type: 'string'), + new OA\Property(property: 'message', type: 'string'), + ] + ) + ), + ] + ) + ), + ] + )] #[IsGranted('IS_AUTHENTICATED_FULLY')] #[Route('/api/v1/subscription-payment', methods: ['POST'])] public function initiateSubscription(Request $request, #[CurrentUser] User $user): JsonResponse @@ -187,6 +402,33 @@ class PaymentController extends BaseController ]); } + #[OA\Post( + path: '/api/v1/subscription-payment/callback/{gateway}', + summary: 'Subscription payment gateway callback (public, IP-restricted)', + parameters: [ + new OA\Parameter( + name: 'gateway', + in: 'path', + required: true, + schema: new OA\Schema(type: 'string', enum: ['mellat', 'sep']) + ), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Callback processed — either a redirect or JSON result', + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'success', type: 'boolean'), + new OA\Property(property: 'payment', type: 'object'), + ] + ) + ), + new OA\Response(response: 302, description: 'Redirect to frontend with payment result'), + new OA\Response(response: 403, description: 'Forbidden — IP not in allowed Shaparak ranges'), + new OA\Response(response: 404, description: 'Payment not found'), + ] + )] #[Route('/api/v1/subscription-payment/callback/{gateway}', methods: ['POST', 'GET'])] public function subscriptionCallback(string $gateway, Request $request): \Symfony\Component\HttpFoundation\Response { @@ -195,6 +437,51 @@ class PaymentController extends BaseController // ── Status ──────────────────────────────────────────────────────────────── + #[OA\Get( + path: '/api/v1/payment/{uuid}', + summary: 'Get payment status by UUID', + security: [['bearerAuth' => []]], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + required: true, + schema: new OA\Schema(type: 'string', format: 'uuid') + ), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Payment details', + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'success', type: 'boolean', example: true), + new OA\Property( + property: 'data', + properties: [ + new OA\Property( + property: 'data', + properties: [ + new OA\Property(property: 'uuid', type: 'string', format: 'uuid'), + new OA\Property(property: 'status', type: 'string'), + new OA\Property(property: 'amount_rials', type: 'integer'), + new OA\Property(property: 'gateway', type: 'string'), + new OA\Property(property: 'reference_id', type: 'string', nullable: true), + new OA\Property(property: 'created_at', type: 'string', format: 'date-time'), + ], + type: 'object' + ), + ], + type: 'object' + ), + ] + ) + ), + new OA\Response(response: 401, description: 'Unauthorized'), + new OA\Response(response: 403, description: 'Forbidden'), + new OA\Response(response: 404, description: 'Payment not found'), + ] + )] #[IsGranted('IS_AUTHENTICATED_FULLY')] #[Route('/api/v1/payment/{uuid}', methods: ['GET'])] public function getStatus(string $uuid, #[CurrentUser] User $user): JsonResponse diff --git a/src/Rating/Controller/RatingController.php b/src/Rating/Controller/RatingController.php index 9dbcfd2c..4cc48874 100644 --- a/src/Rating/Controller/RatingController.php +++ b/src/Rating/Controller/RatingController.php @@ -12,12 +12,14 @@ use App\Rating\Repository\LikeRepository; use App\Rating\Repository\RateRepository; use App\Shared\Constant\ErrorCodes; use App\Shared\Controller\BaseController; +use OpenApi\Attributes as OA; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Security\Http\Attribute\CurrentUser; use Symfony\Component\Security\Http\Attribute\IsGranted; +#[OA\Tag(name: 'Ratings & Comments')] class RatingController extends BaseController { public function __construct( @@ -29,6 +31,36 @@ class RatingController extends BaseController // ── Ratings ─────────────────────────────────────────────────────────────── + #[OA\Post( + path: '/api/v1/rate', + summary: 'Submit or update a rating for a doctor', + security: [['bearerAuth' => []]], + requestBody: new OA\RequestBody( + required: true, + content: new OA\JsonContent( + required: ['doctor_uuid', 'score'], + properties: [ + new OA\Property(property: 'doctor_uuid', type: 'string', description: 'UUID of the doctor'), + new OA\Property(property: 'score', type: 'integer', minimum: 1, maximum: 5, description: 'Rating score (1–5)'), + ] + ) + ), + responses: [ + new OA\Response( + response: 201, + description: 'Rate object created', + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'success', type: 'boolean', example: true), + new OA\Property(property: 'data', type: 'object', description: 'Rate object'), + ] + ) + ), + new OA\Response(response: 401, description: 'Unauthorized'), + new OA\Response(response: 404, description: 'Doctor not found'), + new OA\Response(response: 422, description: 'Validation error — score out of range'), + ] + )] #[IsGranted('IS_AUTHENTICATED_FULLY')] #[Route('/api/v1/rate', methods: ['POST'])] public function rate(Request $request, #[CurrentUser] User $user): JsonResponse @@ -59,6 +91,32 @@ class RatingController extends BaseController return $this->success(['data' => $rate->toArray()], 201); } + #[OA\Get( + path: '/api/v1/rate/{doctorUuid}', + summary: 'Get average rating for a doctor', + parameters: [ + new OA\Parameter(name: 'doctorUuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Average rating', + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'success', type: 'boolean', example: true), + new OA\Property( + property: 'data', + properties: [ + new OA\Property(property: 'average', type: 'number', format: 'float'), + ], + type: 'object' + ), + ] + ) + ), + new OA\Response(response: 404, description: 'Doctor not found'), + ] + )] #[Route('/api/v1/rate/{doctorUuid}', methods: ['GET'])] public function getAverage(string $doctorUuid): JsonResponse { @@ -72,6 +130,36 @@ class RatingController extends BaseController // ── Comments ────────────────────────────────────────────────────────────── + #[OA\Post( + path: '/api/v1/comment', + summary: 'Submit a comment for a doctor', + security: [['bearerAuth' => []]], + requestBody: new OA\RequestBody( + required: true, + content: new OA\JsonContent( + required: ['doctor_uuid', 'body'], + properties: [ + new OA\Property(property: 'doctor_uuid', type: 'string', description: 'UUID of the doctor'), + new OA\Property(property: 'body', type: 'string', description: 'Comment text'), + ] + ) + ), + responses: [ + new OA\Response( + response: 201, + description: 'Comment created', + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'success', type: 'boolean', example: true), + new OA\Property(property: 'data', type: 'object', description: 'Comment object'), + ] + ) + ), + new OA\Response(response: 401, description: 'Unauthorized'), + new OA\Response(response: 404, description: 'Doctor not found'), + new OA\Response(response: 422, description: 'Validation error'), + ] + )] #[IsGranted('IS_AUTHENTICATED_FULLY')] #[Route('/api/v1/comment', methods: ['POST'])] public function createComment(Request $request, #[CurrentUser] User $user): JsonResponse @@ -95,6 +183,26 @@ class RatingController extends BaseController return $this->success(['data' => $comment->toArray()], 201); } + #[OA\Get( + path: '/api/v1/comments/{doctorUuid}', + summary: 'List approved comments for a doctor', + parameters: [ + new OA\Parameter(name: 'doctorUuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Array of comment objects', + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'success', type: 'boolean', example: true), + new OA\Property(property: 'data', type: 'array', items: new OA\Items(type: 'object')), + ] + ) + ), + new OA\Response(response: 404, description: 'Doctor not found'), + ] + )] #[Route('/api/v1/comments/{doctorUuid}', methods: ['GET'])] public function listComments(string $doctorUuid): JsonResponse { @@ -111,6 +219,31 @@ class RatingController extends BaseController return $this->success(['data' => $comments]); } + #[OA\Delete( + path: '/api/v1/comment/{uuid}', + summary: 'Delete a comment (owner or ROLE_ADMIN)', + security: [['bearerAuth' => []]], + parameters: [ + new OA\Parameter(name: 'uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Comment deleted', + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'success', type: 'boolean', example: true), + new OA\Property(property: 'data', type: 'object', properties: [ + new OA\Property(property: 'message', type: 'string'), + ]), + ] + ) + ), + new OA\Response(response: 401, description: 'Unauthorized'), + new OA\Response(response: 403, description: 'Forbidden'), + new OA\Response(response: 404, description: 'Comment not found'), + ] + )] #[IsGranted('IS_AUTHENTICATED_FULLY')] #[Route('/api/v1/comment/{uuid}', methods: ['DELETE'])] public function deleteComment(string $uuid, #[CurrentUser] User $user): JsonResponse @@ -130,6 +263,25 @@ class RatingController extends BaseController // ── Admin: comment moderation ───────────────────────────────────────────── + #[OA\Get( + path: '/api/v1/admin/comments/pending', + summary: 'List all pending comments (ROLE_ADMIN only)', + security: [['bearerAuth' => []]], + responses: [ + new OA\Response( + response: 200, + description: 'Array of pending comment objects', + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'success', type: 'boolean', example: true), + new OA\Property(property: 'data', type: 'array', items: new OA\Items(type: 'object')), + ] + ) + ), + new OA\Response(response: 401, description: 'Unauthorized'), + new OA\Response(response: 403, description: 'Forbidden — ROLE_ADMIN required'), + ] + )] #[IsGranted('ROLE_ADMIN')] #[Route('/api/v1/admin/comments/pending', methods: ['GET'])] public function pendingComments(): JsonResponse @@ -138,6 +290,29 @@ class RatingController extends BaseController return $this->success(['data' => $comments]); } + #[OA\Post( + path: '/api/v1/admin/comment/{uuid}/approve', + summary: 'Approve a comment (ROLE_ADMIN only)', + security: [['bearerAuth' => []]], + parameters: [ + new OA\Parameter(name: 'uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Comment approved', + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'success', type: 'boolean', example: true), + new OA\Property(property: 'data', type: 'object', description: 'Comment object'), + ] + ) + ), + new OA\Response(response: 401, description: 'Unauthorized'), + new OA\Response(response: 403, description: 'Forbidden — ROLE_ADMIN required'), + new OA\Response(response: 404, description: 'Comment not found'), + ] + )] #[IsGranted('ROLE_ADMIN')] #[Route('/api/v1/admin/comment/{uuid}/approve', methods: ['POST'])] public function approveComment(string $uuid): JsonResponse @@ -151,6 +326,29 @@ class RatingController extends BaseController return $this->success(['data' => $comment->toArray()]); } + #[OA\Post( + path: '/api/v1/admin/comment/{uuid}/reject', + summary: 'Reject a comment (ROLE_ADMIN only)', + security: [['bearerAuth' => []]], + parameters: [ + new OA\Parameter(name: 'uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Comment rejected', + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'success', type: 'boolean', example: true), + new OA\Property(property: 'data', type: 'object', description: 'Comment object'), + ] + ) + ), + new OA\Response(response: 401, description: 'Unauthorized'), + new OA\Response(response: 403, description: 'Forbidden — ROLE_ADMIN required'), + new OA\Response(response: 404, description: 'Comment not found'), + ] + )] #[IsGranted('ROLE_ADMIN')] #[Route('/api/v1/admin/comment/{uuid}/reject', methods: ['POST'])] public function rejectComment(string $uuid): JsonResponse @@ -166,6 +364,52 @@ class RatingController extends BaseController // ── Likes (toggle) ──────────────────────────────────────────────────────── + #[OA\Post( + path: '/api/v1/like/{commentUuid}', + summary: 'Toggle like on a comment', + security: [['bearerAuth' => []]], + parameters: [ + new OA\Parameter(name: 'commentUuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Like removed (unliked)', + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'success', type: 'boolean', example: true), + new OA\Property( + property: 'data', + properties: [ + new OA\Property(property: 'liked', type: 'boolean', example: false), + new OA\Property(property: 'likes', type: 'integer'), + ], + type: 'object' + ), + ] + ) + ), + new OA\Response( + response: 201, + description: 'Like added', + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'success', type: 'boolean', example: true), + new OA\Property( + property: 'data', + properties: [ + new OA\Property(property: 'liked', type: 'boolean', example: true), + new OA\Property(property: 'likes', type: 'integer'), + ], + type: 'object' + ), + ] + ) + ), + new OA\Response(response: 401, description: 'Unauthorized'), + new OA\Response(response: 404, description: 'Comment not found'), + ] + )] #[IsGranted('IS_AUTHENTICATED_FULLY')] #[Route('/api/v1/like/{commentUuid}', methods: ['POST'])] public function toggleLike(string $commentUuid, #[CurrentUser] User $user): JsonResponse diff --git a/src/Representation/Controller/RepresentationController.php b/src/Representation/Controller/RepresentationController.php index 8f96dcb9..36d8759d 100644 --- a/src/Representation/Controller/RepresentationController.php +++ b/src/Representation/Controller/RepresentationController.php @@ -10,12 +10,14 @@ use App\Representation\Service\JalaliDateService; use App\Shared\Constant\ErrorCodes; use App\Shared\Controller\BaseController; use Doctrine\ORM\EntityManagerInterface; +use OpenApi\Attributes as OA; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Security\Http\Attribute\CurrentUser; use Symfony\Component\Security\Http\Attribute\IsGranted; +#[OA\Tag(name: 'Representations')] #[IsGranted('IS_AUTHENTICATED_FULLY')] class RepresentationController extends BaseController { @@ -28,6 +30,54 @@ class RepresentationController extends BaseController // ── CRUD ────────────────────────────────────────────────────────────────── + #[OA\Post( + path: '/api/v1/representation', + summary: 'Create a new representation (ROLE_ADMIN)', + security: [['bearerAuth' => []]], + requestBody: new OA\RequestBody( + required: true, + content: new OA\JsonContent( + required: ['full_name', 'mobile_number'], + properties: [ + new OA\Property(property: 'full_name', type: 'string'), + new OA\Property(property: 'mobile_number', type: 'string'), + new OA\Property(property: 'city_id', type: 'integer', nullable: true), + new OA\Property(property: 'commission_percent', type: 'number', format: 'float', nullable: true), + new OA\Property(property: 'bank_account', type: 'object', nullable: true), + ] + ) + ), + responses: [ + new OA\Response( + response: 201, + description: 'Representation created', + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'success', type: 'boolean', example: true), + new OA\Property(property: 'data', type: 'object'), + ] + ) + ), + new OA\Response( + response: 409, + description: 'Conflict – user is already a representation', + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'success', type: 'boolean', example: false), + new OA\Property(property: 'errors', type: 'array', items: new OA\Items( + properties: [ + new OA\Property(property: 'code', type: 'string'), + new OA\Property(property: 'message', type: 'string'), + ] + )), + ] + ) + ), + new OA\Response(response: 401, description: 'Unauthorized'), + new OA\Response(response: 403, description: 'Forbidden – ROLE_ADMIN required'), + new OA\Response(response: 422, description: 'Validation error'), + ] + )] #[IsGranted('ROLE_ADMIN')] #[Route('/api/v1/representation', methods: ['POST'])] public function create(Request $request): JsonResponse @@ -62,6 +112,29 @@ class RepresentationController extends BaseController return $this->success(['data' => $rep->toArray()], 201); } + #[OA\Get( + path: '/api/v1/representation/{uuid}', + summary: 'Get a representation by UUID (own or admin)', + security: [['bearerAuth' => []]], + parameters: [ + new OA\Parameter(name: 'uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Representation details', + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'success', type: 'boolean', example: true), + new OA\Property(property: 'data', type: 'object'), + ] + ) + ), + new OA\Response(response: 401, description: 'Unauthorized'), + new OA\Response(response: 403, description: 'Forbidden'), + new OA\Response(response: 404, description: 'Not found'), + ] + )] #[Route('/api/v1/representation/{uuid}', methods: ['GET'])] public function get(string $uuid, #[CurrentUser] User $user): JsonResponse { @@ -77,6 +150,41 @@ class RepresentationController extends BaseController return $this->success(['data' => $rep->toArray()]); } + #[OA\Patch( + path: '/api/v1/representation/{uuid}', + summary: 'Update a representation (own or admin)', + security: [['bearerAuth' => []]], + requestBody: new OA\RequestBody( + required: false, + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'full_name', type: 'string'), + new OA\Property(property: 'city_id', type: 'integer', nullable: true), + new OA\Property(property: 'bank_account', type: 'object', nullable: true), + new OA\Property(property: 'commission_percent', type: 'number', format: 'float'), + new OA\Property(property: 'active', type: 'boolean'), + ] + ) + ), + parameters: [ + new OA\Parameter(name: 'uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Representation updated', + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'success', type: 'boolean', example: true), + new OA\Property(property: 'data', type: 'object'), + ] + ) + ), + new OA\Response(response: 401, description: 'Unauthorized'), + new OA\Response(response: 403, description: 'Forbidden'), + new OA\Response(response: 404, description: 'Not found'), + ] + )] #[Route('/api/v1/representation/{uuid}', methods: ['PATCH'])] public function update(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse { @@ -101,6 +209,31 @@ class RepresentationController extends BaseController return $this->success(['data' => $rep->toArray()]); } + #[OA\Delete( + path: '/api/v1/representation/{uuid}', + summary: 'Delete a representation (ROLE_ADMIN)', + security: [['bearerAuth' => []]], + parameters: [ + new OA\Parameter(name: 'uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Representation deleted', + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'success', type: 'boolean', example: true), + new OA\Property(property: 'data', properties: [ + new OA\Property(property: 'message', type: 'string'), + ], type: 'object'), + ] + ) + ), + new OA\Response(response: 401, description: 'Unauthorized'), + new OA\Response(response: 403, description: 'Forbidden – ROLE_ADMIN required'), + new OA\Response(response: 404, description: 'Not found'), + ] + )] #[IsGranted('ROLE_ADMIN')] #[Route('/api/v1/representation/{uuid}', methods: ['DELETE'])] public function delete(string $uuid): JsonResponse @@ -116,6 +249,41 @@ class RepresentationController extends BaseController // ── Dashboard: monthly stats ────────────────────────────────────────────── + #[OA\Get( + path: '/api/v1/representation/{uuid}/dashboard/monthly', + summary: 'Get monthly dashboard stats for a representation', + security: [['bearerAuth' => []]], + parameters: [ + new OA\Parameter(name: 'uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')), + new OA\Parameter(name: 'year', in: 'query', required: false, description: 'Jalali year', schema: new OA\Schema(type: 'integer')), + new OA\Parameter(name: 'month', in: 'query', required: false, description: 'Jalali month (1–12)', schema: new OA\Schema(type: 'integer', minimum: 1, maximum: 12)), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Monthly statistics', + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'success', type: 'boolean', example: true), + new OA\Property(property: 'data', properties: [ + new OA\Property(property: 'period', properties: [ + new OA\Property(property: 'jalali_year', type: 'integer'), + new OA\Property(property: 'jalali_month', type: 'integer'), + ], type: 'object'), + new OA\Property(property: 'stats', properties: [ + new OA\Property(property: 'total_payments', type: 'integer'), + new OA\Property(property: 'total_revenue_rials', type: 'integer'), + new OA\Property(property: 'total_appointments', type: 'integer'), + ], type: 'object'), + ], type: 'object'), + ] + ) + ), + new OA\Response(response: 401, description: 'Unauthorized'), + new OA\Response(response: 403, description: 'Forbidden'), + new OA\Response(response: 404, description: 'Not found'), + ] + )] #[Route('/api/v1/representation/{uuid}/dashboard/monthly', methods: ['GET'])] public function dashboardMonthly(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse { @@ -139,6 +307,49 @@ class RepresentationController extends BaseController ]); } + #[OA\Get( + path: '/api/v1/representation/{uuid}/dashboard/yearly', + summary: 'Get yearly dashboard stats for a representation', + security: [['bearerAuth' => []]], + parameters: [ + new OA\Parameter(name: 'uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')), + new OA\Parameter(name: 'year', in: 'query', required: false, description: 'Jalali year', schema: new OA\Schema(type: 'integer')), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Yearly statistics broken down by month', + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'success', type: 'boolean', example: true), + new OA\Property(property: 'data', properties: [ + new OA\Property(property: 'period', properties: [ + new OA\Property(property: 'jalali_year', type: 'integer'), + ], type: 'object'), + new OA\Property(property: 'months', type: 'array', items: new OA\Items( + properties: [ + new OA\Property(property: 'jalali_month', type: 'integer'), + new OA\Property(property: 'stats', properties: [ + new OA\Property(property: 'total_payments', type: 'integer'), + new OA\Property(property: 'total_revenue_rials', type: 'integer'), + new OA\Property(property: 'total_appointments', type: 'integer'), + ], type: 'object'), + ] + )), + new OA\Property(property: 'totals', properties: [ + new OA\Property(property: 'total_payments', type: 'integer'), + new OA\Property(property: 'total_revenue_rials', type: 'integer'), + new OA\Property(property: 'total_appointments', type: 'integer'), + ], type: 'object'), + ], type: 'object'), + ] + ) + ), + new OA\Response(response: 401, description: 'Unauthorized'), + new OA\Response(response: 403, description: 'Forbidden'), + new OA\Response(response: 404, description: 'Not found'), + ] + )] #[Route('/api/v1/representation/{uuid}/dashboard/yearly', methods: ['GET'])] public function dashboardYearly(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse { diff --git a/src/Settlement/Controller/SettlementController.php b/src/Settlement/Controller/SettlementController.php index 56914466..4a293793 100644 --- a/src/Settlement/Controller/SettlementController.php +++ b/src/Settlement/Controller/SettlementController.php @@ -9,12 +9,14 @@ use App\Settlement\Repository\SettlementRepository; use App\Settlement\Repository\WalletTransactionRepository; use App\Shared\Constant\ErrorCodes; use App\Shared\Controller\BaseController; +use OpenApi\Attributes as OA; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Security\Http\Attribute\CurrentUser; use Symfony\Component\Security\Http\Attribute\IsGranted; +#[OA\Tag(name: 'Settlements')] #[IsGranted('IS_AUTHENTICATED_FULLY')] class SettlementController extends BaseController { @@ -25,6 +27,27 @@ class SettlementController extends BaseController // ── Wallet ──────────────────────────────────────────────────────────────── + #[OA\Get( + path: '/api/v1/wallet/balance', + summary: 'Get current wallet balance and recent transactions', + security: [['bearerAuth' => []]], + responses: [ + new OA\Response( + response: 200, + description: 'Wallet balance and recent transactions', + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'success', type: 'boolean', example: true), + new OA\Property(property: 'data', properties: [ + new OA\Property(property: 'balance_rials', type: 'integer'), + new OA\Property(property: 'recent_transactions', type: 'array', items: new OA\Items(type: 'object')), + ], type: 'object'), + ] + ) + ), + new OA\Response(response: 401, description: 'Unauthorized'), + ] + )] #[Route('/api/v1/wallet/balance', methods: ['GET'])] public function balance(#[CurrentUser] User $user): JsonResponse { @@ -40,6 +63,24 @@ class SettlementController extends BaseController ]); } + #[OA\Get( + path: '/api/v1/wallet/transactions', + summary: 'List all wallet transactions for the authenticated user', + security: [['bearerAuth' => []]], + responses: [ + new OA\Response( + response: 200, + description: 'List of wallet transactions', + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'success', type: 'boolean', example: true), + new OA\Property(property: 'data', type: 'array', items: new OA\Items(type: 'object')), + ] + ) + ), + new OA\Response(response: 401, description: 'Unauthorized'), + ] + )] #[Route('/api/v1/wallet/transactions', methods: ['GET'])] public function transactions(#[CurrentUser] User $user): JsonResponse { @@ -53,6 +94,35 @@ class SettlementController extends BaseController // ── Settlement Requests ─────────────────────────────────────────────────── + #[OA\Post( + path: '/api/v1/settlement', + summary: 'Request a new settlement (withdrawal)', + security: [['bearerAuth' => []]], + requestBody: new OA\RequestBody( + required: true, + content: new OA\JsonContent( + required: ['amount_rials'], + properties: [ + new OA\Property(property: 'amount_rials', type: 'integer', minimum: 1), + new OA\Property(property: 'bank_account', type: 'object', nullable: true), + ] + ) + ), + responses: [ + new OA\Response( + response: 201, + description: 'Settlement request created', + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'success', type: 'boolean', example: true), + new OA\Property(property: 'data', type: 'object'), + ] + ) + ), + new OA\Response(response: 401, description: 'Unauthorized'), + new OA\Response(response: 422, description: 'Validation error or insufficient balance'), + ] + )] #[Route('/api/v1/settlement', methods: ['POST'])] public function request(Request $request, #[CurrentUser] User $user): JsonResponse { @@ -80,6 +150,24 @@ class SettlementController extends BaseController return $this->success(['data' => $settlement->toArray()], 201); } + #[OA\Get( + path: '/api/v1/settlement', + summary: 'List settlement requests for the authenticated user', + security: [['bearerAuth' => []]], + responses: [ + new OA\Response( + response: 200, + description: 'List of settlement requests', + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'success', type: 'boolean', example: true), + new OA\Property(property: 'data', type: 'array', items: new OA\Items(type: 'object')), + ] + ) + ), + new OA\Response(response: 401, description: 'Unauthorized'), + ] + )] #[Route('/api/v1/settlement', methods: ['GET'])] public function listMine(#[CurrentUser] User $user): JsonResponse { @@ -91,6 +179,29 @@ class SettlementController extends BaseController return $this->success(['data' => $settlements]); } + #[OA\Get( + path: '/api/v1/settlement/{uuid}', + summary: 'Get a settlement request by UUID (own or admin)', + security: [['bearerAuth' => []]], + parameters: [ + new OA\Parameter(name: 'uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Settlement request details', + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'success', type: 'boolean', example: true), + new OA\Property(property: 'data', type: 'object'), + ] + ) + ), + new OA\Response(response: 401, description: 'Unauthorized'), + new OA\Response(response: 403, description: 'Forbidden'), + new OA\Response(response: 404, description: 'Not found'), + ] + )] #[Route('/api/v1/settlement/{uuid}', methods: ['GET'])] public function get(string $uuid, #[CurrentUser] User $user): JsonResponse { @@ -108,6 +219,38 @@ class SettlementController extends BaseController // ── Admin Actions ───────────────────────────────────────────────────────── + #[OA\Post( + path: '/api/v1/settlement/{uuid}/approve', + summary: 'Approve a pending settlement request (ROLE_ADMIN)', + security: [['bearerAuth' => []]], + requestBody: new OA\RequestBody( + required: false, + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'note', type: 'string', nullable: true), + ] + ) + ), + parameters: [ + new OA\Parameter(name: 'uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Settlement approved', + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'success', type: 'boolean', example: true), + new OA\Property(property: 'data', type: 'object'), + ] + ) + ), + new OA\Response(response: 401, description: 'Unauthorized'), + new OA\Response(response: 403, description: 'Forbidden – ROLE_ADMIN required'), + new OA\Response(response: 404, description: 'Not found'), + new OA\Response(response: 422, description: 'Settlement is not in pending state'), + ] + )] #[IsGranted('ROLE_ADMIN')] #[Route('/api/v1/settlement/{uuid}/approve', methods: ['POST'])] public function approve(string $uuid, Request $request, #[CurrentUser] User $admin): JsonResponse @@ -128,6 +271,39 @@ class SettlementController extends BaseController return $this->success(['data' => $settlement->toArray()]); } + #[OA\Post( + path: '/api/v1/settlement/{uuid}/reject', + summary: 'Reject a pending settlement request (ROLE_ADMIN)', + security: [['bearerAuth' => []]], + requestBody: new OA\RequestBody( + required: true, + content: new OA\JsonContent( + required: ['note'], + properties: [ + new OA\Property(property: 'note', type: 'string', description: 'Rejection reason (required)'), + ] + ) + ), + parameters: [ + new OA\Parameter(name: 'uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Settlement rejected and amount refunded to wallet', + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'success', type: 'boolean', example: true), + new OA\Property(property: 'data', type: 'object'), + ] + ) + ), + new OA\Response(response: 401, description: 'Unauthorized'), + new OA\Response(response: 403, description: 'Forbidden – ROLE_ADMIN required'), + new OA\Response(response: 404, description: 'Not found'), + new OA\Response(response: 422, description: 'Settlement is not pending or note is missing'), + ] + )] #[IsGranted('ROLE_ADMIN')] #[Route('/api/v1/settlement/{uuid}/reject', methods: ['POST'])] public function reject(string $uuid, Request $request, #[CurrentUser] User $admin): JsonResponse diff --git a/src/Shared/EventSubscriber/ExceptionSubscriber.php b/src/Shared/EventSubscriber/ExceptionSubscriber.php index 15bb2757..08968bd2 100644 --- a/src/Shared/EventSubscriber/ExceptionSubscriber.php +++ b/src/Shared/EventSubscriber/ExceptionSubscriber.php @@ -25,6 +25,10 @@ class ExceptionSubscriber implements EventSubscriberInterface public function onKernelException(ExceptionEvent $event): void { + if (str_starts_with($event->getRequest()->getPathInfo(), '/api/doc')) { + return; + } + $exception = $event->getThrowable(); if ($exception instanceof AppException) { diff --git a/src/Shared/EventSubscriber/SecurityHeadersSubscriber.php b/src/Shared/EventSubscriber/SecurityHeadersSubscriber.php index d314b150..04d02370 100644 --- a/src/Shared/EventSubscriber/SecurityHeadersSubscriber.php +++ b/src/Shared/EventSubscriber/SecurityHeadersSubscriber.php @@ -25,7 +25,8 @@ class SecurityHeadersSubscriber implements EventSubscriberInterface $response->headers->set('Strict-Transport-Security', 'max-age=31536000; includeSubDomains'); } - if (str_starts_with($event->getRequest()->getPathInfo(), '/api')) { + $path = $event->getRequest()->getPathInfo(); + if (str_starts_with($path, '/api') && !str_starts_with($path, '/api/doc')) { $response->headers->set('Content-Security-Policy', "default-src 'none'"); } } diff --git a/src/Sms/Controller/SmsController.php b/src/Sms/Controller/SmsController.php index f04a8a0e..3eeee800 100644 --- a/src/Sms/Controller/SmsController.php +++ b/src/Sms/Controller/SmsController.php @@ -8,12 +8,14 @@ use App\Shared\Controller\BaseController; use App\Sms\Entity\SmsTemplate; use App\Sms\Repository\SmsTemplateRepository; use App\Sms\Service\SmsService; +use OpenApi\Attributes as OA; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Security\Http\Attribute\CurrentUser; use Symfony\Component\Security\Http\Attribute\IsGranted; +#[OA\Tag(name: 'SMS')] #[IsGranted('IS_AUTHENTICATED_FULLY')] class SmsController extends BaseController { @@ -24,6 +26,39 @@ class SmsController extends BaseController // ── Send SMS directly ───────────────────────────────────────────────────── + #[OA\Post( + path: '/api/v1/sms/send', + summary: 'Send an SMS message directly (ROLE_ADMIN)', + security: [['bearerAuth' => []]], + requestBody: new OA\RequestBody( + required: true, + content: new OA\JsonContent( + required: ['mobile', 'message'], + properties: [ + new OA\Property(property: 'mobile', type: 'string'), + new OA\Property(property: 'message', type: 'string'), + new OA\Property(property: 'provider', type: 'string', default: 'kavenegar'), + ] + ) + ), + responses: [ + new OA\Response( + response: 200, + description: 'SMS queued for delivery', + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'success', type: 'boolean', example: true), + new OA\Property(property: 'data', properties: [ + new OA\Property(property: 'message', type: 'string'), + ], type: 'object'), + ] + ) + ), + new OA\Response(response: 401, description: 'Unauthorized'), + new OA\Response(response: 403, description: 'Forbidden – ROLE_ADMIN required'), + new OA\Response(response: 422, description: 'Validation error'), + ] + )] #[IsGranted('ROLE_ADMIN')] #[Route('/api/v1/sms/send', methods: ['POST'])] public function send(Request $request): JsonResponse @@ -44,6 +79,37 @@ class SmsController extends BaseController // ── Templates ───────────────────────────────────────────────────────────── + #[OA\Post( + path: '/api/v1/sms/template', + summary: 'Create a new SMS template (ROLE_ADMIN)', + security: [['bearerAuth' => []]], + requestBody: new OA\RequestBody( + required: true, + content: new OA\JsonContent( + required: ['name', 'body'], + properties: [ + new OA\Property(property: 'name', type: 'string'), + new OA\Property(property: 'body', type: 'string'), + new OA\Property(property: 'variables', type: 'array', items: new OA\Items(type: 'string')), + ] + ) + ), + responses: [ + new OA\Response( + response: 201, + description: 'Template created', + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'success', type: 'boolean', example: true), + new OA\Property(property: 'data', type: 'object'), + ] + ) + ), + new OA\Response(response: 401, description: 'Unauthorized'), + new OA\Response(response: 403, description: 'Forbidden – ROLE_ADMIN required'), + new OA\Response(response: 422, description: 'Validation error'), + ] + )] #[IsGranted('ROLE_ADMIN')] #[Route('/api/v1/sms/template', methods: ['POST'])] public function createTemplate(Request $request): JsonResponse @@ -63,6 +129,28 @@ class SmsController extends BaseController return $this->success(['data' => $template->toArray()], 201); } + #[OA\Get( + path: '/api/v1/sms/template/{uuid}', + summary: 'Get an SMS template by UUID', + security: [['bearerAuth' => []]], + parameters: [ + new OA\Parameter(name: 'uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Template details', + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'success', type: 'boolean', example: true), + new OA\Property(property: 'data', type: 'object'), + ] + ) + ), + new OA\Response(response: 401, description: 'Unauthorized'), + new OA\Response(response: 404, description: 'Not found'), + ] + )] #[Route('/api/v1/sms/template/{uuid}', methods: ['GET'])] public function getTemplate(string $uuid): JsonResponse { @@ -73,6 +161,40 @@ class SmsController extends BaseController return $this->success(['data' => $template->toArray()]); } + #[OA\Patch( + path: '/api/v1/sms/template/{uuid}', + summary: 'Update an SMS template (ROLE_ADMIN, only non-approved templates)', + security: [['bearerAuth' => []]], + requestBody: new OA\RequestBody( + required: false, + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'name', type: 'string'), + new OA\Property(property: 'body', type: 'string'), + new OA\Property(property: 'variables', type: 'array', items: new OA\Items(type: 'string')), + ] + ) + ), + parameters: [ + new OA\Parameter(name: 'uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Template updated', + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'success', type: 'boolean', example: true), + new OA\Property(property: 'data', type: 'object'), + ] + ) + ), + new OA\Response(response: 401, description: 'Unauthorized'), + new OA\Response(response: 403, description: 'Forbidden – ROLE_ADMIN required'), + new OA\Response(response: 404, description: 'Not found'), + new OA\Response(response: 422, description: 'Template is already approved and cannot be edited'), + ] + )] #[IsGranted('ROLE_ADMIN')] #[Route('/api/v1/sms/template/{uuid}', methods: ['PATCH'])] public function updateTemplate(string $uuid, Request $request): JsonResponse @@ -96,6 +218,29 @@ class SmsController extends BaseController return $this->success(['data' => $template->toArray()]); } + #[OA\Post( + path: '/api/v1/sms/template/{uuid}/submit', + summary: 'Submit an SMS template for review (ROLE_ADMIN)', + security: [['bearerAuth' => []]], + parameters: [ + new OA\Parameter(name: 'uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Template submitted for review', + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'success', type: 'boolean', example: true), + new OA\Property(property: 'data', type: 'object'), + ] + ) + ), + new OA\Response(response: 401, description: 'Unauthorized'), + new OA\Response(response: 403, description: 'Forbidden – ROLE_ADMIN required'), + new OA\Response(response: 404, description: 'Not found'), + ] + )] #[IsGranted('ROLE_ADMIN')] #[Route('/api/v1/sms/template/{uuid}/submit', methods: ['POST'])] public function submitTemplate(string $uuid): JsonResponse @@ -111,6 +256,31 @@ class SmsController extends BaseController return $this->success(['data' => $template->toArray()]); } + #[OA\Delete( + path: '/api/v1/sms/template/{uuid}', + summary: 'Delete an SMS template (ROLE_ADMIN)', + security: [['bearerAuth' => []]], + parameters: [ + new OA\Parameter(name: 'uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Template deleted', + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'success', type: 'boolean', example: true), + new OA\Property(property: 'data', properties: [ + new OA\Property(property: 'message', type: 'string'), + ], type: 'object'), + ] + ) + ), + new OA\Response(response: 401, description: 'Unauthorized'), + new OA\Response(response: 403, description: 'Forbidden – ROLE_ADMIN required'), + new OA\Response(response: 404, description: 'Not found'), + ] + )] #[IsGranted('ROLE_ADMIN')] #[Route('/api/v1/sms/template/{uuid}', methods: ['DELETE'])] public function deleteTemplate(string $uuid): JsonResponse @@ -125,6 +295,25 @@ class SmsController extends BaseController // ── Admin moderation ────────────────────────────────────────────────────── + #[OA\Get( + path: '/api/v1/admin/sms/templates', + summary: 'List all SMS templates (ROLE_ADMIN)', + security: [['bearerAuth' => []]], + responses: [ + new OA\Response( + response: 200, + description: 'List of all SMS templates', + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'success', type: 'boolean', example: true), + new OA\Property(property: 'data', type: 'array', items: new OA\Items(type: 'object')), + ] + ) + ), + new OA\Response(response: 401, description: 'Unauthorized'), + new OA\Response(response: 403, description: 'Forbidden – ROLE_ADMIN required'), + ] + )] #[IsGranted('ROLE_ADMIN')] #[Route('/api/v1/admin/sms/templates', methods: ['GET'])] public function listTemplates(): JsonResponse @@ -133,6 +322,38 @@ class SmsController extends BaseController return $this->success(['data' => $templates]); } + #[OA\Post( + path: '/api/v1/admin/sms/template/{uuid}/approve', + summary: 'Approve an SMS template (ROLE_ADMIN)', + security: [['bearerAuth' => []]], + requestBody: new OA\RequestBody( + required: false, + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'note', type: 'string', nullable: true), + new OA\Property(property: 'provider_code', type: 'string', nullable: true), + ] + ) + ), + parameters: [ + new OA\Parameter(name: 'uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Template approved', + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'success', type: 'boolean', example: true), + new OA\Property(property: 'data', type: 'object'), + ] + ) + ), + new OA\Response(response: 401, description: 'Unauthorized'), + new OA\Response(response: 403, description: 'Forbidden – ROLE_ADMIN required'), + new OA\Response(response: 404, description: 'Not found'), + ] + )] #[IsGranted('ROLE_ADMIN')] #[Route('/api/v1/admin/sms/template/{uuid}/approve', methods: ['POST'])] public function approveTemplate(string $uuid, Request $request): JsonResponse @@ -150,6 +371,39 @@ class SmsController extends BaseController return $this->success(['data' => $template->toArray()]); } + #[OA\Post( + path: '/api/v1/admin/sms/template/{uuid}/reject', + summary: 'Reject an SMS template (ROLE_ADMIN)', + security: [['bearerAuth' => []]], + requestBody: new OA\RequestBody( + required: true, + content: new OA\JsonContent( + required: ['note'], + properties: [ + new OA\Property(property: 'note', type: 'string', description: 'Rejection reason (required)'), + ] + ) + ), + parameters: [ + new OA\Parameter(name: 'uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Template rejected', + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'success', type: 'boolean', example: true), + new OA\Property(property: 'data', type: 'object'), + ] + ) + ), + new OA\Response(response: 401, description: 'Unauthorized'), + new OA\Response(response: 403, description: 'Forbidden – ROLE_ADMIN required'), + new OA\Response(response: 404, description: 'Not found'), + new OA\Response(response: 422, description: 'Rejection note is required'), + ] + )] #[IsGranted('ROLE_ADMIN')] #[Route('/api/v1/admin/sms/template/{uuid}/reject', methods: ['POST'])] public function rejectTemplate(string $uuid, Request $request): JsonResponse @@ -172,6 +426,41 @@ class SmsController extends BaseController // ── Send via template ───────────────────────────────────────────────────── + #[OA\Post( + path: '/api/v1/sms/send-template', + summary: 'Send an SMS using an approved template (ROLE_ADMIN)', + security: [['bearerAuth' => []]], + requestBody: new OA\RequestBody( + required: true, + content: new OA\JsonContent( + required: ['mobile', 'template_uuid'], + properties: [ + new OA\Property(property: 'mobile', type: 'string'), + new OA\Property(property: 'template_uuid', type: 'string'), + new OA\Property(property: 'vars', type: 'object', description: 'Key-value map of template variables'), + new OA\Property(property: 'provider', type: 'string', default: 'kavenegar'), + ] + ) + ), + responses: [ + new OA\Response( + response: 200, + description: 'SMS queued via template', + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'success', type: 'boolean', example: true), + new OA\Property(property: 'data', properties: [ + new OA\Property(property: 'message', type: 'string'), + ], type: 'object'), + ] + ) + ), + new OA\Response(response: 401, description: 'Unauthorized'), + new OA\Response(response: 403, description: 'Forbidden – ROLE_ADMIN required'), + new OA\Response(response: 404, description: 'Template not found'), + new OA\Response(response: 422, description: 'Template not yet approved'), + ] + )] #[IsGranted('ROLE_ADMIN')] #[Route('/api/v1/sms/send-template', methods: ['POST'])] public function sendViaTemplate(Request $request): JsonResponse