From de1a78a2354c668b9b78de9585c1672b19a44316 Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Tue, 9 Jun 2026 22:00:34 +0330 Subject: [PATCH] feat: Implement SMS sending functionality with KavehNegar and Rangineh providers - Add SendSmsMessage class for encapsulating SMS message data. - Create KavehNegarProvider and RanginehProvider classes implementing SmsProviderInterface for sending SMS. - Implement SmsLogRepository and SmsTemplateRepository for managing SMS logs and templates. - Develop SendSmsHandler for handling SMS sending messages. - Create SmsService to manage SMS dispatching and logging. - Add UserProfileController for managing user profiles with CRUD operations. - Implement UserProfile entity and repository for user profile data management. - Update symfony.lock and bootstrap.php for project dependencies and environment setup. --- .editorconfig | 17 + .env | 64 + .env.dev | 4 + .env.example | 61 + .env.test | 3 + .gitignore | 48 + LICENSE | 19 + bin/console | 21 + bin/phpunit | 4 + compose.override.yaml | 7 + compose.yaml | 25 + composer.json | 98 + composer.lock | 9214 +++++++++++++++++ config/bundles.php | 13 + config/packages/cache.yaml | 4 + config/packages/debug.yaml | 5 + config/packages/doctrine.yaml | 45 + config/packages/doctrine_migrations.yaml | 6 + config/packages/framework.yaml | 15 + config/packages/lexik_jwt_authentication.yaml | 5 + config/packages/messenger.yaml | 22 + config/packages/nelmio_api_doc.yaml | 19 + config/packages/nelmio_cors.yaml | 16 + config/packages/property_info.yaml | 3 + config/packages/rate_limiter.yaml | 13 + config/packages/routing.yaml | 10 + config/packages/security.yaml | 79 + config/packages/validator.yaml | 11 + config/preload.php | 5 + config/reference.php | 1587 +++ config/routes.yaml | 11 + config/routes/framework.yaml | 4 + config/routes/nelmio_api_doc.yaml | 11 + config/routes/security.yaml | 3 + config/services.yaml | 75 + docs/Architecture_Audit.md | 823 ++ docs/ClinicPro_Manual_v2.md | 5893 +++++++++++ docs/admin-ui/ui-design-spec.md | 482 + docs/admin-ui/user-flow.md | 446 + docs/security-audit.md | 321 + docs/tasks/README.md | 82 + .../task-01-project-setup/architecture.md | 149 + docs/tasks/task-01-project-setup/database.md | 94 + .../implementation_notes.md | 317 + docs/tasks/task-01-project-setup/task.md | 562 + .../task-02-authentication/architecture.md | 84 + docs/tasks/task-02-authentication/database.md | 103 + .../implementation_notes.md | 162 + docs/tasks/task-02-authentication/task.md | 488 + .../tasks/task-02-authentication/user_flow.md | 77 + .../task-03-user-profile/architecture.md | 103 + docs/tasks/task-03-user-profile/database.md | 109 + .../implementation_notes.md | 67 + docs/tasks/task-03-user-profile/task.md | 60 + docs/tasks/task-04-blog/architecture.md | 70 + docs/tasks/task-04-blog/database.md | 50 + .../task-04-blog/implementation_notes.md | 139 + docs/tasks/task-04-blog/task.md | 71 + docs/tasks/task-05-doctor/architecture.md | 176 + docs/tasks/task-05-doctor/database.md | 160 + .../task-05-doctor/implementation_notes.md | 64 + docs/tasks/task-05-doctor/task.md | 178 + docs/tasks/task-06-clinic/architecture.md | 69 + docs/tasks/task-06-clinic/database.md | 69 + .../task-06-clinic/implementation_notes.md | 24 + docs/tasks/task-06-clinic/task.md | 165 + docs/tasks/task-08-categories/architecture.md | 68 + docs/tasks/task-08-categories/database.md | 69 + .../implementation_notes.md | 33 + docs/tasks/task-08-categories/task.md | 52 + .../architecture.md | 130 + .../task-09-appointment-settings/database.md | 121 + .../implementation_notes.md | 72 + .../task-09-appointment-settings/task.md | 66 + .../task-09-appointment-settings/user_flow.md | 67 + .../tasks/task-10-appointment/architecture.md | 71 + docs/tasks/task-10-appointment/database.md | 82 + .../implementation_notes.md | 122 + docs/tasks/task-10-appointment/task.md | 284 + docs/tasks/task-10-appointment/user_flow.md | 48 + docs/tasks/task-11-insurance/architecture.md | 44 + docs/tasks/task-11-insurance/database.md | 31 + .../task-11-insurance/implementation_notes.md | 17 + docs/tasks/task-11-insurance/task.md | 24 + .../task-12-rating-comment/architecture.md | 89 + docs/tasks/task-12-rating-comment/database.md | 71 + .../implementation_notes.md | 164 + docs/tasks/task-12-rating-comment/task.md | 99 + docs/tasks/task-13-like/architecture.md | 46 + docs/tasks/task-13-like/database.md | 33 + .../task-13-like/implementation_notes.md | 21 + docs/tasks/task-13-like/task.md | 17 + docs/tasks/task-14-secretary/architecture.md | 50 + docs/tasks/task-14-secretary/database.md | 31 + .../task-14-secretary/implementation_notes.md | 25 + docs/tasks/task-14-secretary/task.md | 313 + docs/tasks/task-15-payment/architecture.md | 67 + docs/tasks/task-15-payment/database.md | 107 + .../task-15-payment/implementation_notes.md | 99 + docs/tasks/task-15-payment/task.md | 285 + .../task-16-representation/architecture.md | 54 + docs/tasks/task-16-representation/database.md | 70 + .../implementation_notes.md | 133 + docs/tasks/task-16-representation/task.md | 153 + docs/tasks/task-17-sms/database.md | 44 + .../tasks/task-17-sms/implementation_notes.md | 70 + docs/tasks/task-17-sms/task.md | 471 + docs/tasks/task-18-settlement/database.md | 90 + docs/tasks/task-18-settlement/task.md | 117 + migrations/.gitignore | 0 migrations/Version20260609130407.php | 31 + migrations/Version20260609131304.php | 171 + migrations/Version20260609131553.php | 33 + migrations/Version20260609132009.php | 33 + migrations/Version20260609132708.php | 61 + migrations/Version20260609133121.php | 57 + migrations/Version20260609133334.php | 35 + migrations/Version20260609133546.php | 35 + migrations/Version20260609134112.php | 41 + migrations/Version20260609134741.php | 35 + migrations/Version20260609135126.php | 35 + migrations/Version20260609135514.php | 33 + migrations/Version20260609135704.php | 39 + migrations/Version20260609135923.php | 47 + migrations/Version20260609140223.php | 33 + migrations/Version20260609140423.php | 33 + phpstan.neon | 14 + phpunit.dist.xml | 44 + public/index.php | 9 + .../CancelExpiredAppointmentsCommand.php | 41 + .../Controller/AppointmentController.php | 177 + .../AppointmentSettingsController.php | 286 + src/Appointment/Entity/Appointment.php | 134 + src/Appointment/Entity/DateOverride.php | 81 + src/Appointment/Entity/Holiday.php | 81 + src/Appointment/Entity/WeeklySchedule.php | 63 + .../Repository/AppointmentRepository.php | 94 + .../Repository/DateOverrideRepository.php | 39 + .../Repository/HolidayRepository.php | 48 + .../Repository/WeeklyScheduleRepository.php | 38 + .../Service/SlotCalculatorService.php | 104 + src/Auth/Controller/AuthController.php | 176 + src/Auth/Entity/User.php | 105 + src/Auth/Repository/UserRepository.php | 41 + src/Auth/Security/PasswordAuthenticator.php | 105 + src/Auth/Service/OtpService.php | 98 + src/Auth/Service/TokenService.php | 71 + src/Blog/Controller/BlogController.php | 151 + src/Blog/Entity/Blog.php | 135 + src/Blog/Repository/BlogRepository.php | 39 + .../Controller/CategoryController.php | 123 + src/Category/Entity/Category.php | 155 + .../Repository/CategoryRepository.php | 48 + src/Category/Service/CategoryService.php | 97 + src/Clinic/Controller/ClinicController.php | 268 + src/Clinic/Entity/Clinic.php | 212 + src/Clinic/Repository/ClinicRepository.php | 80 + src/Controller/.gitignore | 0 src/Doctor/Controller/DoctorController.php | 367 + src/Doctor/Entity/Doctor.php | 232 + src/Doctor/Entity/DoctorAddress.php | 85 + .../Repository/DoctorAddressRepository.php | 31 + src/Doctor/Repository/DoctorRepository.php | 95 + src/Entity/.gitignore | 0 .../Controller/InsuranceController.php | 118 + src/Insurance/Entity/DoctorInsurance.php | 55 + .../Repository/DoctorInsuranceRepository.php | 31 + src/Kernel.php | 11 + src/Payment/Controller/PaymentController.php | 270 + src/Payment/Entity/Payment.php | 122 + src/Payment/Gateway/MellatGateway.php | 131 + .../Gateway/PaymentGatewayInterface.php | 18 + src/Payment/Gateway/PaymentInitResult.php | 13 + src/Payment/Gateway/PaymentVerifyResult.php | 13 + src/Payment/Gateway/SepGateway.php | 80 + src/Payment/Repository/PaymentRepository.php | 31 + src/Payment/Service/CircuitBreakerService.php | 42 + src/Rating/Controller/RatingController.php | 188 + src/Rating/Entity/Comment.php | 88 + src/Rating/Entity/Like.php | 55 + src/Rating/Entity/Rate.php | 67 + src/Rating/Repository/CommentRepository.php | 30 + src/Rating/Repository/LikeRepository.php | 19 + src/Rating/Repository/RateRepository.php | 28 + src/Repository/.gitignore | 0 .../Controller/RepresentationController.php | 201 + src/Representation/Entity/Representation.php | 90 + .../Repository/RepresentationRepository.php | 38 + .../Service/JalaliDateService.php | 112 + .../Controller/SecretaryController.php | 180 + src/Secretary/Entity/DoctorSecretary.php | 116 + .../Repository/DoctorSecretaryRepository.php | 54 + .../Security/SecretaryPermissionChecker.php | 28 + .../Controller/SettlementController.php | 166 + src/Settlement/Entity/Settlement.php | 112 + src/Settlement/Entity/WalletTransaction.php | 82 + .../Repository/SettlementRepository.php | 51 + .../WalletTransactionRepository.php | 28 + src/Shared/Constant/ErrorCodes.php | 81 + src/Shared/Controller/BaseController.php | 51 + src/Shared/Controller/HealthController.php | 45 + .../EventSubscriber/ExceptionSubscriber.php | 123 + .../SecurityHeadersSubscriber.php | 37 + src/Shared/Exception/AppException.php | 21 + src/Shared/Message/SendSmsMessage.php | 12 + src/Shared/Service/FileValidatorService.php | 84 + src/Sms/Controller/SmsController.php | 202 + src/Sms/Entity/SmsLog.php | 63 + src/Sms/Entity/SmsTemplate.php | 115 + src/Sms/Message/SendSmsMessage.php | 15 + src/Sms/Provider/KavehNegarProvider.php | 58 + src/Sms/Provider/RanginehProvider.php | 50 + src/Sms/Provider/SmsProviderInterface.php | 14 + src/Sms/Repository/SmsLogRepository.php | 13 + src/Sms/Repository/SmsTemplateRepository.php | 15 + src/Sms/Service/SendSmsHandler.php | 17 + src/Sms/Service/SmsService.php | 61 + .../Controller/UserProfileController.php | 144 + src/UserProfile/Entity/UserProfile.php | 181 + .../Repository/UserProfileRepository.php | 42 + symfony.lock | 220 + tests/bootstrap.php | 13 + 222 files changed, 36388 insertions(+) create mode 100644 .editorconfig create mode 100644 .env create mode 100644 .env.dev create mode 100644 .env.example create mode 100644 .env.test create mode 100644 .gitignore create mode 100644 LICENSE create mode 100755 bin/console create mode 100755 bin/phpunit create mode 100644 compose.override.yaml create mode 100644 compose.yaml create mode 100644 composer.json create mode 100644 composer.lock create mode 100644 config/bundles.php create mode 100644 config/packages/cache.yaml create mode 100644 config/packages/debug.yaml create mode 100644 config/packages/doctrine.yaml create mode 100644 config/packages/doctrine_migrations.yaml create mode 100644 config/packages/framework.yaml create mode 100644 config/packages/lexik_jwt_authentication.yaml create mode 100644 config/packages/messenger.yaml create mode 100644 config/packages/nelmio_api_doc.yaml create mode 100644 config/packages/nelmio_cors.yaml create mode 100644 config/packages/property_info.yaml create mode 100644 config/packages/rate_limiter.yaml create mode 100644 config/packages/routing.yaml create mode 100644 config/packages/security.yaml create mode 100644 config/packages/validator.yaml create mode 100644 config/preload.php create mode 100644 config/reference.php create mode 100644 config/routes.yaml create mode 100644 config/routes/framework.yaml create mode 100644 config/routes/nelmio_api_doc.yaml create mode 100644 config/routes/security.yaml create mode 100644 config/services.yaml create mode 100644 docs/Architecture_Audit.md create mode 100644 docs/ClinicPro_Manual_v2.md create mode 100644 docs/admin-ui/ui-design-spec.md create mode 100644 docs/admin-ui/user-flow.md create mode 100644 docs/security-audit.md create mode 100644 docs/tasks/README.md create mode 100644 docs/tasks/task-01-project-setup/architecture.md create mode 100644 docs/tasks/task-01-project-setup/database.md create mode 100644 docs/tasks/task-01-project-setup/implementation_notes.md create mode 100644 docs/tasks/task-01-project-setup/task.md create mode 100644 docs/tasks/task-02-authentication/architecture.md create mode 100644 docs/tasks/task-02-authentication/database.md create mode 100644 docs/tasks/task-02-authentication/implementation_notes.md create mode 100644 docs/tasks/task-02-authentication/task.md create mode 100644 docs/tasks/task-02-authentication/user_flow.md create mode 100644 docs/tasks/task-03-user-profile/architecture.md create mode 100644 docs/tasks/task-03-user-profile/database.md create mode 100644 docs/tasks/task-03-user-profile/implementation_notes.md create mode 100644 docs/tasks/task-03-user-profile/task.md create mode 100644 docs/tasks/task-04-blog/architecture.md create mode 100644 docs/tasks/task-04-blog/database.md create mode 100644 docs/tasks/task-04-blog/implementation_notes.md create mode 100644 docs/tasks/task-04-blog/task.md create mode 100644 docs/tasks/task-05-doctor/architecture.md create mode 100644 docs/tasks/task-05-doctor/database.md create mode 100644 docs/tasks/task-05-doctor/implementation_notes.md create mode 100644 docs/tasks/task-05-doctor/task.md create mode 100644 docs/tasks/task-06-clinic/architecture.md create mode 100644 docs/tasks/task-06-clinic/database.md create mode 100644 docs/tasks/task-06-clinic/implementation_notes.md create mode 100644 docs/tasks/task-06-clinic/task.md create mode 100644 docs/tasks/task-08-categories/architecture.md create mode 100644 docs/tasks/task-08-categories/database.md create mode 100644 docs/tasks/task-08-categories/implementation_notes.md create mode 100644 docs/tasks/task-08-categories/task.md create mode 100644 docs/tasks/task-09-appointment-settings/architecture.md create mode 100644 docs/tasks/task-09-appointment-settings/database.md create mode 100644 docs/tasks/task-09-appointment-settings/implementation_notes.md create mode 100644 docs/tasks/task-09-appointment-settings/task.md create mode 100644 docs/tasks/task-09-appointment-settings/user_flow.md create mode 100644 docs/tasks/task-10-appointment/architecture.md create mode 100644 docs/tasks/task-10-appointment/database.md create mode 100644 docs/tasks/task-10-appointment/implementation_notes.md create mode 100644 docs/tasks/task-10-appointment/task.md create mode 100644 docs/tasks/task-10-appointment/user_flow.md create mode 100644 docs/tasks/task-11-insurance/architecture.md create mode 100644 docs/tasks/task-11-insurance/database.md create mode 100644 docs/tasks/task-11-insurance/implementation_notes.md create mode 100644 docs/tasks/task-11-insurance/task.md create mode 100644 docs/tasks/task-12-rating-comment/architecture.md create mode 100644 docs/tasks/task-12-rating-comment/database.md create mode 100644 docs/tasks/task-12-rating-comment/implementation_notes.md create mode 100644 docs/tasks/task-12-rating-comment/task.md create mode 100644 docs/tasks/task-13-like/architecture.md create mode 100644 docs/tasks/task-13-like/database.md create mode 100644 docs/tasks/task-13-like/implementation_notes.md create mode 100644 docs/tasks/task-13-like/task.md create mode 100644 docs/tasks/task-14-secretary/architecture.md create mode 100644 docs/tasks/task-14-secretary/database.md create mode 100644 docs/tasks/task-14-secretary/implementation_notes.md create mode 100644 docs/tasks/task-14-secretary/task.md create mode 100644 docs/tasks/task-15-payment/architecture.md create mode 100644 docs/tasks/task-15-payment/database.md create mode 100644 docs/tasks/task-15-payment/implementation_notes.md create mode 100644 docs/tasks/task-15-payment/task.md create mode 100644 docs/tasks/task-16-representation/architecture.md create mode 100644 docs/tasks/task-16-representation/database.md create mode 100644 docs/tasks/task-16-representation/implementation_notes.md create mode 100644 docs/tasks/task-16-representation/task.md create mode 100644 docs/tasks/task-17-sms/database.md create mode 100644 docs/tasks/task-17-sms/implementation_notes.md create mode 100644 docs/tasks/task-17-sms/task.md create mode 100644 docs/tasks/task-18-settlement/database.md create mode 100644 docs/tasks/task-18-settlement/task.md create mode 100644 migrations/.gitignore create mode 100644 migrations/Version20260609130407.php create mode 100644 migrations/Version20260609131304.php create mode 100644 migrations/Version20260609131553.php create mode 100644 migrations/Version20260609132009.php create mode 100644 migrations/Version20260609132708.php create mode 100644 migrations/Version20260609133121.php create mode 100644 migrations/Version20260609133334.php create mode 100644 migrations/Version20260609133546.php create mode 100644 migrations/Version20260609134112.php create mode 100644 migrations/Version20260609134741.php create mode 100644 migrations/Version20260609135126.php create mode 100644 migrations/Version20260609135514.php create mode 100644 migrations/Version20260609135704.php create mode 100644 migrations/Version20260609135923.php create mode 100644 migrations/Version20260609140223.php create mode 100644 migrations/Version20260609140423.php create mode 100644 phpstan.neon create mode 100644 phpunit.dist.xml create mode 100644 public/index.php create mode 100644 src/Appointment/Command/CancelExpiredAppointmentsCommand.php create mode 100644 src/Appointment/Controller/AppointmentController.php create mode 100644 src/Appointment/Controller/AppointmentSettingsController.php create mode 100644 src/Appointment/Entity/Appointment.php create mode 100644 src/Appointment/Entity/DateOverride.php create mode 100644 src/Appointment/Entity/Holiday.php create mode 100644 src/Appointment/Entity/WeeklySchedule.php create mode 100644 src/Appointment/Repository/AppointmentRepository.php create mode 100644 src/Appointment/Repository/DateOverrideRepository.php create mode 100644 src/Appointment/Repository/HolidayRepository.php create mode 100644 src/Appointment/Repository/WeeklyScheduleRepository.php create mode 100644 src/Appointment/Service/SlotCalculatorService.php create mode 100644 src/Auth/Controller/AuthController.php create mode 100644 src/Auth/Entity/User.php create mode 100644 src/Auth/Repository/UserRepository.php create mode 100644 src/Auth/Security/PasswordAuthenticator.php create mode 100644 src/Auth/Service/OtpService.php create mode 100644 src/Auth/Service/TokenService.php create mode 100644 src/Blog/Controller/BlogController.php create mode 100644 src/Blog/Entity/Blog.php create mode 100644 src/Blog/Repository/BlogRepository.php create mode 100644 src/Category/Controller/CategoryController.php create mode 100644 src/Category/Entity/Category.php create mode 100644 src/Category/Repository/CategoryRepository.php create mode 100644 src/Category/Service/CategoryService.php create mode 100644 src/Clinic/Controller/ClinicController.php create mode 100644 src/Clinic/Entity/Clinic.php create mode 100644 src/Clinic/Repository/ClinicRepository.php create mode 100644 src/Controller/.gitignore create mode 100644 src/Doctor/Controller/DoctorController.php create mode 100644 src/Doctor/Entity/Doctor.php create mode 100644 src/Doctor/Entity/DoctorAddress.php create mode 100644 src/Doctor/Repository/DoctorAddressRepository.php create mode 100644 src/Doctor/Repository/DoctorRepository.php create mode 100644 src/Entity/.gitignore create mode 100644 src/Insurance/Controller/InsuranceController.php create mode 100644 src/Insurance/Entity/DoctorInsurance.php create mode 100644 src/Insurance/Repository/DoctorInsuranceRepository.php create mode 100644 src/Kernel.php create mode 100644 src/Payment/Controller/PaymentController.php create mode 100644 src/Payment/Entity/Payment.php create mode 100644 src/Payment/Gateway/MellatGateway.php create mode 100644 src/Payment/Gateway/PaymentGatewayInterface.php create mode 100644 src/Payment/Gateway/PaymentInitResult.php create mode 100644 src/Payment/Gateway/PaymentVerifyResult.php create mode 100644 src/Payment/Gateway/SepGateway.php create mode 100644 src/Payment/Repository/PaymentRepository.php create mode 100644 src/Payment/Service/CircuitBreakerService.php create mode 100644 src/Rating/Controller/RatingController.php create mode 100644 src/Rating/Entity/Comment.php create mode 100644 src/Rating/Entity/Like.php create mode 100644 src/Rating/Entity/Rate.php create mode 100644 src/Rating/Repository/CommentRepository.php create mode 100644 src/Rating/Repository/LikeRepository.php create mode 100644 src/Rating/Repository/RateRepository.php create mode 100644 src/Repository/.gitignore create mode 100644 src/Representation/Controller/RepresentationController.php create mode 100644 src/Representation/Entity/Representation.php create mode 100644 src/Representation/Repository/RepresentationRepository.php create mode 100644 src/Representation/Service/JalaliDateService.php create mode 100644 src/Secretary/Controller/SecretaryController.php create mode 100644 src/Secretary/Entity/DoctorSecretary.php create mode 100644 src/Secretary/Repository/DoctorSecretaryRepository.php create mode 100644 src/Secretary/Security/SecretaryPermissionChecker.php create mode 100644 src/Settlement/Controller/SettlementController.php create mode 100644 src/Settlement/Entity/Settlement.php create mode 100644 src/Settlement/Entity/WalletTransaction.php create mode 100644 src/Settlement/Repository/SettlementRepository.php create mode 100644 src/Settlement/Repository/WalletTransactionRepository.php create mode 100644 src/Shared/Constant/ErrorCodes.php create mode 100644 src/Shared/Controller/BaseController.php create mode 100644 src/Shared/Controller/HealthController.php create mode 100644 src/Shared/EventSubscriber/ExceptionSubscriber.php create mode 100644 src/Shared/EventSubscriber/SecurityHeadersSubscriber.php create mode 100644 src/Shared/Exception/AppException.php create mode 100644 src/Shared/Message/SendSmsMessage.php create mode 100644 src/Shared/Service/FileValidatorService.php create mode 100644 src/Sms/Controller/SmsController.php create mode 100644 src/Sms/Entity/SmsLog.php create mode 100644 src/Sms/Entity/SmsTemplate.php create mode 100644 src/Sms/Message/SendSmsMessage.php create mode 100644 src/Sms/Provider/KavehNegarProvider.php create mode 100644 src/Sms/Provider/RanginehProvider.php create mode 100644 src/Sms/Provider/SmsProviderInterface.php create mode 100644 src/Sms/Repository/SmsLogRepository.php create mode 100644 src/Sms/Repository/SmsTemplateRepository.php create mode 100644 src/Sms/Service/SendSmsHandler.php create mode 100644 src/Sms/Service/SmsService.php create mode 100644 src/UserProfile/Controller/UserProfileController.php create mode 100644 src/UserProfile/Entity/UserProfile.php create mode 100644 src/UserProfile/Repository/UserProfileRepository.php create mode 100644 symfony.lock create mode 100644 tests/bootstrap.php diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000..66990769 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,17 @@ +# editorconfig.org + +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_size = 4 +indent_style = space +insert_final_newline = true +trim_trailing_whitespace = true + +[{compose.yaml,compose.*.yaml}] +indent_size = 2 + +[*.md] +trim_trailing_whitespace = false diff --git a/.env b/.env new file mode 100644 index 00000000..393712b9 --- /dev/null +++ b/.env @@ -0,0 +1,64 @@ +###> symfony/framework-bundle ### +APP_ENV=dev +APP_SECRET=clinic_pro_secret_change_in_prod +APP_SHARE_DIR=var/share +###< symfony/framework-bundle ### + +###> symfony/routing ### +DEFAULT_URI=https://clinic-pro.ddev.site +###< symfony/routing ### + +###> doctrine/doctrine-bundle ### +DATABASE_URL="mysql://db:db@db:3306/db?serverVersion=8.0&charset=utf8mb4" +###< doctrine/doctrine-bundle ### + +###> lexik/jwt-authentication-bundle ### +JWT_SECRET_KEY=%kernel.project_dir%/config/jwt/private.pem +JWT_PUBLIC_KEY=%kernel.project_dir%/config/jwt/public.pem +JWT_PASSPHRASE=5778180ab122fbb3253d84f4137dbc1672109bab9ad051d3d40fb1c2be3e242d +###< lexik/jwt-authentication-bundle ### + +###> nelmio/cors-bundle ### +CORS_ALLOW_ORIGIN='^https?://(clinic-pro\.ddev\.site|localhost|127\.0\.0\.1)(:[0-9]+)?$' +###< nelmio/cors-bundle ### + +###> symfony/messenger ### +MESSENGER_TRANSPORT_DSN=redis://redis:6379/messages +###< symfony/messenger ### + +###> Redis ### +REDIS_URL=redis://redis:6379 +###< Redis ### + +###> Auth ### +REFRESH_TOKEN_TTL=2592000 +OTP_TTL=1200 +###< Auth ### + +###> SMS ### +KAVENEGAR_API_KEY=change_me +RANGINEH_API_KEY=change_me +SMS_PROVIDER=kavenegar +###< SMS ### + +###> File Upload ### +MAX_FILE_SIZE_BYTES=5242880 +UPLOAD_DIR=var/uploads +###< File Upload ### + +###> Payment ### +ALLOWED_FRONTEND_HOSTS=clinic-pro.ddev.site,localhost +###< Payment ### + +# Payment +MELLAT_TERMINAL_ID=00000000 +MELLAT_USERNAME=testuser +MELLAT_PASSWORD=testpass +SEP_TERMINAL_ID=00000000 +APP_BASE_URL=https://clinic-pro.ddev.site + +# SMS +KAVENEGAR_API_KEY=test_key +KAVENEGAR_SENDER=1000596446 +RANGINEH_API_KEY=test_key +RANGINEH_SENDER=3000 diff --git a/.env.dev b/.env.dev new file mode 100644 index 00000000..a2575bc1 --- /dev/null +++ b/.env.dev @@ -0,0 +1,4 @@ + +###> symfony/framework-bundle ### +APP_SECRET=42f34156531bad221462ff02bd43f42b +###< symfony/framework-bundle ### diff --git a/.env.example b/.env.example new file mode 100644 index 00000000..b5aa0bb3 --- /dev/null +++ b/.env.example @@ -0,0 +1,61 @@ +###> symfony/framework-bundle ### +APP_ENV=prod +# IMPORTANT: generate a strong random secret for production: +# php -r "echo bin2hex(random_bytes(32));" +APP_SECRET=CHANGE_ME_STRONG_RANDOM_32_CHARS +APP_SHARE_DIR=var/share +###< symfony/framework-bundle ### + +###> symfony/routing ### +DEFAULT_URI=https://your-domain.com +###< symfony/routing ### + +###> doctrine/doctrine-bundle ### +DATABASE_URL="mysql://user:CHANGE_ME@db:3306/clinic_pro?serverVersion=8.0&charset=utf8mb4" +###< doctrine/doctrine-bundle ### + +###> lexik/jwt-authentication-bundle ### +JWT_SECRET_KEY=%kernel.project_dir%/config/jwt/private.pem +JWT_PUBLIC_KEY=%kernel.project_dir%/config/jwt/public.pem +# Generate with: openssl rand -hex 32 +JWT_PASSPHRASE=CHANGE_ME_STRONG_PASSPHRASE +###< lexik/jwt-authentication-bundle ### + +###> nelmio/cors-bundle ### +CORS_ALLOW_ORIGIN='^https://your-domain\.com$' +###< nelmio/cors-bundle ### + +###> symfony/messenger ### +MESSENGER_TRANSPORT_DSN=redis://redis:6379/messages +###< symfony/messenger ### + +###> Redis ### +REDIS_URL=redis://redis:6379 +###< Redis ### + +###> Auth ### +REFRESH_TOKEN_TTL=2592000 +OTP_TTL=1200 +###< Auth ### + +###> SMS ### +KAVENEGAR_API_KEY=CHANGE_ME +KAVENEGAR_SENDER=CHANGE_ME +RANGINEH_API_KEY=CHANGE_ME +RANGINEH_SENDER=CHANGE_ME +SMS_PROVIDER=kavenegar +###< SMS ### + +###> File Upload ### +MAX_FILE_SIZE_BYTES=5242880 +UPLOAD_DIR=var/uploads +###< File Upload ### + +###> Payment ### +ALLOWED_FRONTEND_HOSTS=your-domain.com +APP_BASE_URL=https://your-domain.com +MELLAT_TERMINAL_ID=CHANGE_ME +MELLAT_USERNAME=CHANGE_ME +MELLAT_PASSWORD=CHANGE_ME +SEP_TERMINAL_ID=CHANGE_ME +###< Payment ### diff --git a/.env.test b/.env.test new file mode 100644 index 00000000..64bd1114 --- /dev/null +++ b/.env.test @@ -0,0 +1,3 @@ +# define your env variables for the test env here +KERNEL_CLASS='App\Kernel' +APP_SECRET='$ecretf0rt3st' diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..489b5cf2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,48 @@ + +###> symfony/framework-bundle ### +/.env.local +/.env.local.php +/.env.*.local +/config/secrets/prod/prod.decrypt.private.php +/public/bundles/ +/var/ +/vendor/ +###< symfony/framework-bundle ### + +###> lexik/jwt-authentication-bundle ### +/config/jwt/*.pem +###< lexik/jwt-authentication-bundle ### + +###> phpunit/phpunit ### +/phpunit.xml +/.phpunit.cache/ +###< phpunit/phpunit ### + +# macOS +.DS_Store +.AppleDouble +.LSOverride +._* +.Spotlight-V100 +.Trashes + +# IDE +/.idea/ +/.vscode/ +*.swp +*.swo +*.sublime-project +*.sublime-workspace + +# ddev local environment +/.ddev/ + +# Logs +*.log +/var/log/ + +# Uploaded files +/public/uploads/ + +# Composer +/composer.phar diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..bc6b2a12 --- /dev/null +++ b/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) Fabien Potencier + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished +to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/bin/console b/bin/console new file mode 100755 index 00000000..d8d530e2 --- /dev/null +++ b/bin/console @@ -0,0 +1,21 @@ +#!/usr/bin/env php + doctrine/doctrine-bundle ### + database: + ports: + - "5432" +###< doctrine/doctrine-bundle ### diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 00000000..89c74d18 --- /dev/null +++ b/compose.yaml @@ -0,0 +1,25 @@ + +services: +###> doctrine/doctrine-bundle ### + database: + image: postgres:${POSTGRES_VERSION:-16}-alpine + environment: + POSTGRES_DB: ${POSTGRES_DB:-app} + # You should definitely change the password in production + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-!ChangeMe!} + POSTGRES_USER: ${POSTGRES_USER:-app} + healthcheck: + test: ["CMD", "pg_isready", "-d", "${POSTGRES_DB:-app}", "-U", "${POSTGRES_USER:-app}"] + timeout: 5s + retries: 5 + start_period: 60s + volumes: + - database_data:/var/lib/postgresql/data:rw + # You may use a bind-mounted host directory instead, so that it is harder to accidentally remove the volume and lose all your data! + # - ./docker/db/data:/var/lib/postgresql/data:rw +###< doctrine/doctrine-bundle ### + +volumes: +###> doctrine/doctrine-bundle ### + database_data: +###< doctrine/doctrine-bundle ### diff --git a/composer.json b/composer.json new file mode 100644 index 00000000..df06aa63 --- /dev/null +++ b/composer.json @@ -0,0 +1,98 @@ +{ + "name": "symfony/skeleton", + "type": "project", + "license": "MIT", + "description": "A minimal Symfony project recommended to create bare bones applications", + "minimum-stability": "stable", + "prefer-stable": true, + "require": { + "php": ">=8.2", + "ext-ctype": "*", + "ext-iconv": "*", + "doctrine/doctrine-bundle": "*", + "doctrine/doctrine-migrations-bundle": "*", + "doctrine/orm": "^3.6", + "lexik/jwt-authentication-bundle": "*", + "nelmio/api-doc-bundle": "*", + "nelmio/cors-bundle": "*", + "symfony/asset": "7.4.*", + "symfony/cache": "7.4.*", + "symfony/console": "7.4.*", + "symfony/dotenv": "7.4.*", + "symfony/flex": "^2", + "symfony/framework-bundle": "7.4.*", + "symfony/http-client": "7.4.*", + "symfony/messenger": "7.4.*", + "symfony/property-access": "7.4.*", + "symfony/property-info": "7.4.*", + "symfony/rate-limiter": "7.4.*", + "symfony/runtime": "7.4.*", + "symfony/security-bundle": "7.4.*", + "symfony/serializer": "7.4.*", + "symfony/uid": "7.4.*", + "symfony/validator": "7.4.*", + "symfony/yaml": "7.4.*", + "twig/twig": "*", + "zircote/swagger-php": "*" + }, + "config": { + "allow-plugins": { + "php-http/discovery": true, + "symfony/flex": true, + "symfony/runtime": true + }, + "bump-after-update": true, + "sort-packages": true + }, + "autoload": { + "psr-4": { + "App\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "App\\Tests\\": "tests/" + } + }, + "replace": { + "symfony/polyfill-ctype": "*", + "symfony/polyfill-iconv": "*", + "symfony/polyfill-php72": "*", + "symfony/polyfill-php73": "*", + "symfony/polyfill-php74": "*", + "symfony/polyfill-php80": "*", + "symfony/polyfill-php81": "*", + "symfony/polyfill-php82": "*" + }, + "scripts": { + "auto-scripts": { + "cache:clear": "symfony-cmd", + "assets:install %PUBLIC_DIR%": "symfony-cmd" + }, + "post-install-cmd": [ + "@auto-scripts" + ], + "post-update-cmd": [ + "@auto-scripts" + ] + }, + "conflict": { + "symfony/symfony": "*" + }, + "extra": { + "symfony": { + "allow-contrib": false, + "require": "7.4.*" + } + }, + "require-dev": { + "phpstan/phpstan": "^2.2", + "phpstan/phpstan-doctrine": "^2.0", + "phpstan/phpstan-symfony": "^2.0", + "phpunit/phpunit": "^12.5", + "symfony/browser-kit": "7.4.*", + "symfony/css-selector": "7.4.*", + "symfony/debug-bundle": "7.4.*", + "symfony/maker-bundle": "^1.67" + } +} diff --git a/composer.lock b/composer.lock new file mode 100644 index 00000000..9652d7a0 --- /dev/null +++ b/composer.lock @@ -0,0 +1,9214 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "This file is @generated automatically" + ], + "content-hash": "4fb67bd5aaa2425722fa73ea10b6a1b2", + "packages": [ + { + "name": "doctrine/collections", + "version": "2.6.0", + "source": { + "type": "git", + "url": "https://github.com/doctrine/collections.git", + "reference": "7713da39d8e237f28411d6a616a3dce5e20d5de2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/collections/zipball/7713da39d8e237f28411d6a616a3dce5e20d5de2", + "reference": "7713da39d8e237f28411d6a616a3dce5e20d5de2", + "shasum": "" + }, + "require": { + "doctrine/deprecations": "^1", + "php": "^8.1", + "symfony/polyfill-php84": "^1.30" + }, + "require-dev": { + "doctrine/coding-standard": "^14", + "ext-json": "*", + "phpstan/phpstan": "^2.1.30", + "phpstan/phpstan-phpunit": "^2.0.7", + "phpunit/phpunit": "^10.5.58 || ^11.5.42 || ^12.4" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Common\\Collections\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Guilherme Blanco", + "email": "guilhermeblanco@gmail.com" + }, + { + "name": "Roman Borschel", + "email": "roman@code-factory.org" + }, + { + "name": "Benjamin Eberlei", + "email": "kontakt@beberlei.de" + }, + { + "name": "Jonathan Wage", + "email": "jonwage@gmail.com" + }, + { + "name": "Johannes Schmitt", + "email": "schmittjoh@gmail.com" + } + ], + "description": "PHP Doctrine Collections library that adds additional functionality on top of PHP arrays.", + "homepage": "https://www.doctrine-project.org/projects/collections.html", + "keywords": [ + "array", + "collections", + "iterators", + "php" + ], + "support": { + "issues": "https://github.com/doctrine/collections/issues", + "source": "https://github.com/doctrine/collections/tree/2.6.0" + }, + "funding": [ + { + "url": "https://www.doctrine-project.org/sponsorship.html", + "type": "custom" + }, + { + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Fcollections", + "type": "tidelift" + } + ], + "time": "2026-01-15T10:01:58+00:00" + }, + { + "name": "doctrine/dbal", + "version": "4.4.3", + "source": { + "type": "git", + "url": "https://github.com/doctrine/dbal.git", + "reference": "61e730f1658814821a85f2402c945f3883407dec" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/dbal/zipball/61e730f1658814821a85f2402c945f3883407dec", + "reference": "61e730f1658814821a85f2402c945f3883407dec", + "shasum": "" + }, + "require": { + "doctrine/deprecations": "^1.1.5", + "php": "^8.2", + "psr/cache": "^1|^2|^3", + "psr/log": "^1|^2|^3" + }, + "require-dev": { + "doctrine/coding-standard": "14.0.0", + "fig/log-test": "^1", + "jetbrains/phpstorm-stubs": "2023.2", + "phpstan/phpstan": "2.1.30", + "phpstan/phpstan-phpunit": "2.0.7", + "phpstan/phpstan-strict-rules": "^2", + "phpunit/phpunit": "11.5.50", + "slevomat/coding-standard": "8.27.1", + "squizlabs/php_codesniffer": "4.0.1", + "symfony/cache": "^6.3.8|^7.0|^8.0", + "symfony/console": "^5.4|^6.3|^7.0|^8.0" + }, + "suggest": { + "symfony/console": "For helpful console commands such as SQL execution and import of files." + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\DBAL\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Guilherme Blanco", + "email": "guilhermeblanco@gmail.com" + }, + { + "name": "Roman Borschel", + "email": "roman@code-factory.org" + }, + { + "name": "Benjamin Eberlei", + "email": "kontakt@beberlei.de" + }, + { + "name": "Jonathan Wage", + "email": "jonwage@gmail.com" + } + ], + "description": "Powerful PHP database abstraction layer (DBAL) with many features for database schema introspection and management.", + "homepage": "https://www.doctrine-project.org/projects/dbal.html", + "keywords": [ + "abstraction", + "database", + "db2", + "dbal", + "mariadb", + "mssql", + "mysql", + "oci8", + "oracle", + "pdo", + "pgsql", + "postgresql", + "queryobject", + "sasql", + "sql", + "sqlite", + "sqlserver", + "sqlsrv" + ], + "support": { + "issues": "https://github.com/doctrine/dbal/issues", + "source": "https://github.com/doctrine/dbal/tree/4.4.3" + }, + "funding": [ + { + "url": "https://www.doctrine-project.org/sponsorship.html", + "type": "custom" + }, + { + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Fdbal", + "type": "tidelift" + } + ], + "time": "2026-03-20T08:52:12+00:00" + }, + { + "name": "doctrine/deprecations", + "version": "1.1.6", + "source": { + "type": "git", + "url": "https://github.com/doctrine/deprecations.git", + "reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/deprecations/zipball/d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca", + "reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "conflict": { + "phpunit/phpunit": "<=7.5 || >=14" + }, + "require-dev": { + "doctrine/coding-standard": "^9 || ^12 || ^14", + "phpstan/phpstan": "1.4.10 || 2.1.30", + "phpstan/phpstan-phpunit": "^1.0 || ^2", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.6 || ^10.5 || ^11.5 || ^12.4 || ^13.0", + "psr/log": "^1 || ^2 || ^3" + }, + "suggest": { + "psr/log": "Allows logging deprecations via PSR-3 logger implementation" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Deprecations\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A small layer on top of trigger_error(E_USER_DEPRECATED) or PSR-3 logging with options to disable all deprecations or selectively for packages.", + "homepage": "https://www.doctrine-project.org/", + "support": { + "issues": "https://github.com/doctrine/deprecations/issues", + "source": "https://github.com/doctrine/deprecations/tree/1.1.6" + }, + "time": "2026-02-07T07:09:04+00:00" + }, + { + "name": "doctrine/doctrine-bundle", + "version": "2.18.2", + "source": { + "type": "git", + "url": "https://github.com/doctrine/DoctrineBundle.git", + "reference": "0ff098b29b8b3c68307c8987dcaed7fd829c6546" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/DoctrineBundle/zipball/0ff098b29b8b3c68307c8987dcaed7fd829c6546", + "reference": "0ff098b29b8b3c68307c8987dcaed7fd829c6546", + "shasum": "" + }, + "require": { + "doctrine/dbal": "^3.7.0 || ^4.0", + "doctrine/deprecations": "^1.0", + "doctrine/persistence": "^3.1 || ^4", + "doctrine/sql-formatter": "^1.0.1", + "php": "^8.1", + "symfony/cache": "^6.4 || ^7.0", + "symfony/config": "^6.4 || ^7.0", + "symfony/console": "^6.4 || ^7.0", + "symfony/dependency-injection": "^6.4 || ^7.0", + "symfony/doctrine-bridge": "^6.4.3 || ^7.0.3", + "symfony/framework-bundle": "^6.4 || ^7.0", + "symfony/service-contracts": "^2.5 || ^3" + }, + "conflict": { + "doctrine/annotations": ">=3.0", + "doctrine/cache": "< 1.11", + "doctrine/orm": "<2.17 || >=4.0", + "symfony/var-exporter": "< 6.4.1 || 7.0.0", + "twig/twig": "<2.13 || >=3.0 <3.0.4" + }, + "require-dev": { + "doctrine/annotations": "^1 || ^2", + "doctrine/cache": "^1.11 || ^2.0", + "doctrine/coding-standard": "^14", + "doctrine/orm": "^2.17 || ^3.1", + "friendsofphp/proxy-manager-lts": "^1.0", + "phpstan/phpstan": "2.1.1", + "phpstan/phpstan-phpunit": "2.0.3", + "phpstan/phpstan-strict-rules": "^2", + "phpunit/phpunit": "^10.5.53 || ^12.3.10", + "psr/log": "^1.1.4 || ^2.0 || ^3.0", + "symfony/doctrine-messenger": "^6.4 || ^7.0", + "symfony/expression-language": "^6.4 || ^7.0", + "symfony/messenger": "^6.4 || ^7.0", + "symfony/property-info": "^6.4 || ^7.0", + "symfony/security-bundle": "^6.4 || ^7.0", + "symfony/stopwatch": "^6.4 || ^7.0", + "symfony/string": "^6.4 || ^7.0", + "symfony/twig-bridge": "^6.4 || ^7.0", + "symfony/validator": "^6.4 || ^7.0", + "symfony/var-exporter": "^6.4.1 || ^7.0.1", + "symfony/web-profiler-bundle": "^6.4 || ^7.0", + "symfony/yaml": "^6.4 || ^7.0", + "twig/twig": "^2.14.7 || ^3.0.4" + }, + "suggest": { + "doctrine/orm": "The Doctrine ORM integration is optional in the bundle.", + "ext-pdo": "*", + "symfony/web-profiler-bundle": "To use the data collector." + }, + "type": "symfony-bundle", + "autoload": { + "psr-4": { + "Doctrine\\Bundle\\DoctrineBundle\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Benjamin Eberlei", + "email": "kontakt@beberlei.de" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + }, + { + "name": "Doctrine Project", + "homepage": "https://www.doctrine-project.org/" + } + ], + "description": "Symfony DoctrineBundle", + "homepage": "https://www.doctrine-project.org", + "keywords": [ + "database", + "dbal", + "orm", + "persistence" + ], + "support": { + "issues": "https://github.com/doctrine/DoctrineBundle/issues", + "source": "https://github.com/doctrine/DoctrineBundle/tree/2.18.2" + }, + "funding": [ + { + "url": "https://www.doctrine-project.org/sponsorship.html", + "type": "custom" + }, + { + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Fdoctrine-bundle", + "type": "tidelift" + } + ], + "time": "2025-12-20T21:35:32+00:00" + }, + { + "name": "doctrine/doctrine-migrations-bundle", + "version": "3.7.0", + "source": { + "type": "git", + "url": "https://github.com/doctrine/DoctrineMigrationsBundle.git", + "reference": "1e380c6dd8ac8488217f39cff6b77e367f1a644b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/DoctrineMigrationsBundle/zipball/1e380c6dd8ac8488217f39cff6b77e367f1a644b", + "reference": "1e380c6dd8ac8488217f39cff6b77e367f1a644b", + "shasum": "" + }, + "require": { + "doctrine/doctrine-bundle": "^2.4 || ^3.0", + "doctrine/migrations": "^3.2", + "php": "^7.2 || ^8.0", + "symfony/deprecation-contracts": "^2.1 || ^3", + "symfony/framework-bundle": "^5.4 || ^6.0 || ^7.0 || ^8.0" + }, + "require-dev": { + "composer/semver": "^3.0", + "doctrine/coding-standard": "^12 || ^14", + "doctrine/orm": "^2.6 || ^3", + "phpstan/phpstan": "^1.4 || ^2", + "phpstan/phpstan-deprecation-rules": "^1 || ^2", + "phpstan/phpstan-phpunit": "^1 || ^2", + "phpstan/phpstan-strict-rules": "^1.1 || ^2", + "phpstan/phpstan-symfony": "^1.3 || ^2", + "phpunit/phpunit": "^8.5 || ^9.5", + "symfony/phpunit-bridge": "^6.3 || ^7 || ^8", + "symfony/var-exporter": "^5.4 || ^6 || ^7 || ^8" + }, + "type": "symfony-bundle", + "autoload": { + "psr-4": { + "Doctrine\\Bundle\\MigrationsBundle\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Doctrine Project", + "homepage": "https://www.doctrine-project.org" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony DoctrineMigrationsBundle", + "homepage": "https://www.doctrine-project.org", + "keywords": [ + "dbal", + "migrations", + "schema" + ], + "support": { + "issues": "https://github.com/doctrine/DoctrineMigrationsBundle/issues", + "source": "https://github.com/doctrine/DoctrineMigrationsBundle/tree/3.7.0" + }, + "funding": [ + { + "url": "https://www.doctrine-project.org/sponsorship.html", + "type": "custom" + }, + { + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Fdoctrine-migrations-bundle", + "type": "tidelift" + } + ], + "time": "2025-11-15T19:02:59+00:00" + }, + { + "name": "doctrine/event-manager", + "version": "2.1.1", + "source": { + "type": "git", + "url": "https://github.com/doctrine/event-manager.git", + "reference": "dda33921b198841ca8dbad2eaa5d4d34769d18cf" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/event-manager/zipball/dda33921b198841ca8dbad2eaa5d4d34769d18cf", + "reference": "dda33921b198841ca8dbad2eaa5d4d34769d18cf", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "conflict": { + "doctrine/common": "<2.9" + }, + "require-dev": { + "doctrine/coding-standard": "^14", + "phpdocumentor/guides-cli": "^1.4", + "phpstan/phpstan": "^2.1.32", + "phpunit/phpunit": "^10.5.58" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Common\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Guilherme Blanco", + "email": "guilhermeblanco@gmail.com" + }, + { + "name": "Roman Borschel", + "email": "roman@code-factory.org" + }, + { + "name": "Benjamin Eberlei", + "email": "kontakt@beberlei.de" + }, + { + "name": "Jonathan Wage", + "email": "jonwage@gmail.com" + }, + { + "name": "Johannes Schmitt", + "email": "schmittjoh@gmail.com" + }, + { + "name": "Marco Pivetta", + "email": "ocramius@gmail.com" + } + ], + "description": "The Doctrine Event Manager is a simple PHP event system that was built to be used with the various Doctrine projects.", + "homepage": "https://www.doctrine-project.org/projects/event-manager.html", + "keywords": [ + "event", + "event dispatcher", + "event manager", + "event system", + "events" + ], + "support": { + "issues": "https://github.com/doctrine/event-manager/issues", + "source": "https://github.com/doctrine/event-manager/tree/2.1.1" + }, + "funding": [ + { + "url": "https://www.doctrine-project.org/sponsorship.html", + "type": "custom" + }, + { + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Fevent-manager", + "type": "tidelift" + } + ], + "time": "2026-01-29T07:11:08+00:00" + }, + { + "name": "doctrine/inflector", + "version": "2.1.0", + "source": { + "type": "git", + "url": "https://github.com/doctrine/inflector.git", + "reference": "6d6c96277ea252fc1304627204c3d5e6e15faa3b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/inflector/zipball/6d6c96277ea252fc1304627204c3d5e6e15faa3b", + "reference": "6d6c96277ea252fc1304627204c3d5e6e15faa3b", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "require-dev": { + "doctrine/coding-standard": "^12.0 || ^13.0", + "phpstan/phpstan": "^1.12 || ^2.0", + "phpstan/phpstan-phpunit": "^1.4 || ^2.0", + "phpstan/phpstan-strict-rules": "^1.6 || ^2.0", + "phpunit/phpunit": "^8.5 || ^12.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Inflector\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Guilherme Blanco", + "email": "guilhermeblanco@gmail.com" + }, + { + "name": "Roman Borschel", + "email": "roman@code-factory.org" + }, + { + "name": "Benjamin Eberlei", + "email": "kontakt@beberlei.de" + }, + { + "name": "Jonathan Wage", + "email": "jonwage@gmail.com" + }, + { + "name": "Johannes Schmitt", + "email": "schmittjoh@gmail.com" + } + ], + "description": "PHP Doctrine Inflector is a small library that can perform string manipulations with regard to upper/lowercase and singular/plural forms of words.", + "homepage": "https://www.doctrine-project.org/projects/inflector.html", + "keywords": [ + "inflection", + "inflector", + "lowercase", + "manipulation", + "php", + "plural", + "singular", + "strings", + "uppercase", + "words" + ], + "support": { + "issues": "https://github.com/doctrine/inflector/issues", + "source": "https://github.com/doctrine/inflector/tree/2.1.0" + }, + "funding": [ + { + "url": "https://www.doctrine-project.org/sponsorship.html", + "type": "custom" + }, + { + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Finflector", + "type": "tidelift" + } + ], + "time": "2025-08-10T19:31:58+00:00" + }, + { + "name": "doctrine/instantiator", + "version": "2.0.0", + "source": { + "type": "git", + "url": "https://github.com/doctrine/instantiator.git", + "reference": "c6222283fa3f4ac679f8b9ced9a4e23f163e80d0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/instantiator/zipball/c6222283fa3f4ac679f8b9ced9a4e23f163e80d0", + "reference": "c6222283fa3f4ac679f8b9ced9a4e23f163e80d0", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "require-dev": { + "doctrine/coding-standard": "^11", + "ext-pdo": "*", + "ext-phar": "*", + "phpbench/phpbench": "^1.2", + "phpstan/phpstan": "^1.9.4", + "phpstan/phpstan-phpunit": "^1.3", + "phpunit/phpunit": "^9.5.27", + "vimeo/psalm": "^5.4" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Instantiator\\": "src/Doctrine/Instantiator/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Marco Pivetta", + "email": "ocramius@gmail.com", + "homepage": "https://ocramius.github.io/" + } + ], + "description": "A small, lightweight utility to instantiate objects in PHP without invoking their constructors", + "homepage": "https://www.doctrine-project.org/projects/instantiator.html", + "keywords": [ + "constructor", + "instantiate" + ], + "support": { + "issues": "https://github.com/doctrine/instantiator/issues", + "source": "https://github.com/doctrine/instantiator/tree/2.0.0" + }, + "funding": [ + { + "url": "https://www.doctrine-project.org/sponsorship.html", + "type": "custom" + }, + { + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Finstantiator", + "type": "tidelift" + } + ], + "time": "2022-12-30T00:23:10+00:00" + }, + { + "name": "doctrine/lexer", + "version": "3.0.1", + "source": { + "type": "git", + "url": "https://github.com/doctrine/lexer.git", + "reference": "31ad66abc0fc9e1a1f2d9bc6a42668d2fbbcd6dd" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/lexer/zipball/31ad66abc0fc9e1a1f2d9bc6a42668d2fbbcd6dd", + "reference": "31ad66abc0fc9e1a1f2d9bc6a42668d2fbbcd6dd", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "require-dev": { + "doctrine/coding-standard": "^12", + "phpstan/phpstan": "^1.10", + "phpunit/phpunit": "^10.5", + "psalm/plugin-phpunit": "^0.18.3", + "vimeo/psalm": "^5.21" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Common\\Lexer\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Guilherme Blanco", + "email": "guilhermeblanco@gmail.com" + }, + { + "name": "Roman Borschel", + "email": "roman@code-factory.org" + }, + { + "name": "Johannes Schmitt", + "email": "schmittjoh@gmail.com" + } + ], + "description": "PHP Doctrine Lexer parser library that can be used in Top-Down, Recursive Descent Parsers.", + "homepage": "https://www.doctrine-project.org/projects/lexer.html", + "keywords": [ + "annotations", + "docblock", + "lexer", + "parser", + "php" + ], + "support": { + "issues": "https://github.com/doctrine/lexer/issues", + "source": "https://github.com/doctrine/lexer/tree/3.0.1" + }, + "funding": [ + { + "url": "https://www.doctrine-project.org/sponsorship.html", + "type": "custom" + }, + { + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Flexer", + "type": "tidelift" + } + ], + "time": "2024-02-05T11:56:58+00:00" + }, + { + "name": "doctrine/migrations", + "version": "3.9.7", + "source": { + "type": "git", + "url": "https://github.com/doctrine/migrations.git", + "reference": "96cb2a89b56c9efb0bac38e606dc0b0f13e650ec" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/migrations/zipball/96cb2a89b56c9efb0bac38e606dc0b0f13e650ec", + "reference": "96cb2a89b56c9efb0bac38e606dc0b0f13e650ec", + "shasum": "" + }, + "require": { + "composer-runtime-api": "^2", + "doctrine/dbal": "^3.6 || ^4", + "doctrine/deprecations": "^0.5.3 || ^1", + "doctrine/event-manager": "^1.2 || ^2.0", + "php": "^8.1", + "psr/log": "^1.1.3 || ^2 || ^3", + "symfony/console": "^5.4 || ^6.0 || ^7.0 || ^8.0", + "symfony/stopwatch": "^5.4 || ^6.0 || ^7.0 || ^8.0", + "symfony/var-exporter": "^6.2 || ^7.0 || ^8.0" + }, + "conflict": { + "doctrine/orm": "<2.12 || >=4" + }, + "require-dev": { + "doctrine/coding-standard": "^14", + "doctrine/orm": "^2.13 || ^3", + "doctrine/persistence": "^2 || ^3 || ^4", + "doctrine/sql-formatter": "^1.0", + "ext-pdo_sqlite": "*", + "fig/log-test": "^1", + "phpstan/phpstan": "^2", + "phpstan/phpstan-deprecation-rules": "^2", + "phpstan/phpstan-phpunit": "^2", + "phpstan/phpstan-strict-rules": "^2", + "phpstan/phpstan-symfony": "^2", + "phpunit/phpunit": "^10.3 || ^11.0 || ^12.0", + "symfony/cache": "^5.4 || ^6.0 || ^7.0 || ^8.0", + "symfony/process": "^5.4 || ^6.0 || ^7.0 || ^8.0", + "symfony/yaml": "^5.4 || ^6.0 || ^7.0 || ^8.0" + }, + "suggest": { + "doctrine/sql-formatter": "Allows to generate formatted SQL with the diff command.", + "symfony/yaml": "Allows the use of yaml for migration configuration files." + }, + "bin": [ + "bin/doctrine-migrations" + ], + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Migrations\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Benjamin Eberlei", + "email": "kontakt@beberlei.de" + }, + { + "name": "Jonathan Wage", + "email": "jonwage@gmail.com" + }, + { + "name": "Michael Simonson", + "email": "contact@mikesimonson.com" + } + ], + "description": "PHP Doctrine Migrations project offer additional functionality on top of the database abstraction layer (DBAL) for versioning your database schema and easily deploying changes to it. It is a very easy to use and a powerful tool.", + "homepage": "https://www.doctrine-project.org/projects/migrations.html", + "keywords": [ + "database", + "dbal", + "migrations" + ], + "support": { + "issues": "https://github.com/doctrine/migrations/issues", + "source": "https://github.com/doctrine/migrations/tree/3.9.7" + }, + "funding": [ + { + "url": "https://www.doctrine-project.org/sponsorship.html", + "type": "custom" + }, + { + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Fmigrations", + "type": "tidelift" + } + ], + "time": "2026-04-23T19:33:20+00:00" + }, + { + "name": "doctrine/orm", + "version": "3.6.7", + "source": { + "type": "git", + "url": "https://github.com/doctrine/orm.git", + "reference": "bc217c0e19c3a9eadfa67697143b87c9ba01272c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/orm/zipball/bc217c0e19c3a9eadfa67697143b87c9ba01272c", + "reference": "bc217c0e19c3a9eadfa67697143b87c9ba01272c", + "shasum": "" + }, + "require": { + "composer-runtime-api": "^2", + "doctrine/collections": "^2.2", + "doctrine/dbal": "^3.8.2 || ^4", + "doctrine/deprecations": "^0.5.3 || ^1", + "doctrine/event-manager": "^1.2 || ^2", + "doctrine/inflector": "^1.4 || ^2.0", + "doctrine/instantiator": "^1.3 || ^2", + "doctrine/lexer": "^3", + "doctrine/persistence": "^3.3.1 || ^4", + "ext-ctype": "*", + "php": "^8.1", + "psr/cache": "^1 || ^2 || ^3", + "symfony/console": "^5.4 || ^6.0 || ^7.0 || ^8.0", + "symfony/var-exporter": "^6.3.9 || ^7.0 || ^8.0" + }, + "require-dev": { + "doctrine/coding-standard": "^14.0", + "phpbench/phpbench": "^1.0", + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan": "2.1.23", + "phpstan/phpstan-deprecation-rules": "^2", + "phpunit/phpunit": "^10.5.0 || ^11.5", + "psr/log": "^1 || ^2 || ^3", + "symfony/cache": "^5.4 || ^6.2 || ^7.0 || ^8.0" + }, + "suggest": { + "ext-dom": "Provides support for XSD validation for XML mapping files", + "symfony/cache": "Provides cache support for Setup Tool with doctrine/cache 2.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\ORM\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Guilherme Blanco", + "email": "guilhermeblanco@gmail.com" + }, + { + "name": "Roman Borschel", + "email": "roman@code-factory.org" + }, + { + "name": "Benjamin Eberlei", + "email": "kontakt@beberlei.de" + }, + { + "name": "Jonathan Wage", + "email": "jonwage@gmail.com" + }, + { + "name": "Marco Pivetta", + "email": "ocramius@gmail.com" + } + ], + "description": "Object-Relational-Mapper for PHP", + "homepage": "https://www.doctrine-project.org/projects/orm.html", + "keywords": [ + "database", + "orm" + ], + "support": { + "issues": "https://github.com/doctrine/orm/issues", + "source": "https://github.com/doctrine/orm/tree/3.6.7" + }, + "time": "2026-05-25T16:45:47+00:00" + }, + { + "name": "doctrine/persistence", + "version": "4.2.0", + "source": { + "type": "git", + "url": "https://github.com/doctrine/persistence.git", + "reference": "49ab73e0d3e2ac8d1f5ecda3dd8acd5503781e8b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/persistence/zipball/49ab73e0d3e2ac8d1f5ecda3dd8acd5503781e8b", + "reference": "49ab73e0d3e2ac8d1f5ecda3dd8acd5503781e8b", + "shasum": "" + }, + "require": { + "doctrine/deprecations": "^1", + "doctrine/event-manager": "^1 || ^2", + "php": "^8.1", + "psr/cache": "^1.0 || ^2.0 || ^3.0" + }, + "require-dev": { + "doctrine/coding-standard": "^14", + "phpstan/phpstan": "2.1.30", + "phpstan/phpstan-phpunit": "^2", + "phpstan/phpstan-strict-rules": "^2", + "phpunit/phpunit": "^10.5.58 || ^12", + "symfony/cache": "^4.4 || ^5.4 || ^6.0 || ^7.0 || ^8.0", + "symfony/finder": "^4.4 || ^5.4 || ^6.0 || ^7.0 || ^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Persistence\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Guilherme Blanco", + "email": "guilhermeblanco@gmail.com" + }, + { + "name": "Roman Borschel", + "email": "roman@code-factory.org" + }, + { + "name": "Benjamin Eberlei", + "email": "kontakt@beberlei.de" + }, + { + "name": "Jonathan Wage", + "email": "jonwage@gmail.com" + }, + { + "name": "Johannes Schmitt", + "email": "schmittjoh@gmail.com" + }, + { + "name": "Marco Pivetta", + "email": "ocramius@gmail.com" + } + ], + "description": "The Doctrine Persistence project is a set of shared interfaces and functionality that the different Doctrine object mappers share.", + "homepage": "https://www.doctrine-project.org/projects/persistence.html", + "keywords": [ + "mapper", + "object", + "odm", + "orm", + "persistence" + ], + "support": { + "issues": "https://github.com/doctrine/persistence/issues", + "source": "https://github.com/doctrine/persistence/tree/4.2.0" + }, + "funding": [ + { + "url": "https://www.doctrine-project.org/sponsorship.html", + "type": "custom" + }, + { + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Fpersistence", + "type": "tidelift" + } + ], + "time": "2026-04-26T12:12:52+00:00" + }, + { + "name": "doctrine/sql-formatter", + "version": "1.5.4", + "source": { + "type": "git", + "url": "https://github.com/doctrine/sql-formatter.git", + "reference": "9563949f5cd3bd12a17d12fb980528bc141c5806" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/sql-formatter/zipball/9563949f5cd3bd12a17d12fb980528bc141c5806", + "reference": "9563949f5cd3bd12a17d12fb980528bc141c5806", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "require-dev": { + "doctrine/coding-standard": "^14", + "ergebnis/phpunit-slow-test-detector": "^2.20", + "phpstan/phpstan": "^2.1.31", + "phpunit/phpunit": "^10.5.58" + }, + "bin": [ + "bin/sql-formatter" + ], + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\SqlFormatter\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jeremy Dorn", + "email": "jeremy@jeremydorn.com", + "homepage": "https://jeremydorn.com/" + } + ], + "description": "a PHP SQL highlighting library", + "homepage": "https://github.com/doctrine/sql-formatter/", + "keywords": [ + "highlight", + "sql" + ], + "support": { + "issues": "https://github.com/doctrine/sql-formatter/issues", + "source": "https://github.com/doctrine/sql-formatter/tree/1.5.4" + }, + "time": "2026-02-08T16:21:46+00:00" + }, + { + "name": "lcobucci/jwt", + "version": "5.6.0", + "source": { + "type": "git", + "url": "https://github.com/lcobucci/jwt.git", + "reference": "bb3e9f21e4196e8afc41def81ef649c164bca25e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/lcobucci/jwt/zipball/bb3e9f21e4196e8afc41def81ef649c164bca25e", + "reference": "bb3e9f21e4196e8afc41def81ef649c164bca25e", + "shasum": "" + }, + "require": { + "ext-openssl": "*", + "ext-sodium": "*", + "php": "~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0", + "psr/clock": "^1.0" + }, + "require-dev": { + "infection/infection": "^0.29", + "lcobucci/clock": "^3.2", + "lcobucci/coding-standard": "^11.0", + "phpbench/phpbench": "^1.2", + "phpstan/extension-installer": "^1.2", + "phpstan/phpstan": "^1.10.7", + "phpstan/phpstan-deprecation-rules": "^1.1.3", + "phpstan/phpstan-phpunit": "^1.3.10", + "phpstan/phpstan-strict-rules": "^1.5.0", + "phpunit/phpunit": "^11.1" + }, + "suggest": { + "lcobucci/clock": ">= 3.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "Lcobucci\\JWT\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Luís Cobucci", + "email": "lcobucci@gmail.com", + "role": "Developer" + } + ], + "description": "A simple library to work with JSON Web Token and JSON Web Signature", + "keywords": [ + "JWS", + "jwt" + ], + "support": { + "issues": "https://github.com/lcobucci/jwt/issues", + "source": "https://github.com/lcobucci/jwt/tree/5.6.0" + }, + "funding": [ + { + "url": "https://github.com/lcobucci", + "type": "github" + }, + { + "url": "https://www.patreon.com/lcobucci", + "type": "patreon" + } + ], + "time": "2025-10-17T11:30:53+00:00" + }, + { + "name": "lexik/jwt-authentication-bundle", + "version": "v3.2.0", + "source": { + "type": "git", + "url": "https://github.com/lexik/LexikJWTAuthenticationBundle.git", + "reference": "60df75dc70ee6f597929cb2f0812adda591dfa4b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/lexik/LexikJWTAuthenticationBundle/zipball/60df75dc70ee6f597929cb2f0812adda591dfa4b", + "reference": "60df75dc70ee6f597929cb2f0812adda591dfa4b", + "shasum": "" + }, + "require": { + "ext-openssl": "*", + "lcobucci/jwt": "^5.0", + "php": ">=8.2", + "symfony/clock": "^6.4|^7.0|^8.0", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/deprecation-contracts": "^2.4|^3.0", + "symfony/event-dispatcher": "^6.4|^7.0|^8.0", + "symfony/http-foundation": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/property-access": "^6.4|^7.0|^8.0", + "symfony/security-bundle": "^6.4|^7.0|^8.0", + "symfony/translation-contracts": "^1.0|^2.0|^3.0" + }, + "require-dev": { + "api-platform/core": "^3.0|^4.0", + "rector/rector": "^1.2", + "symfony/browser-kit": "^6.4|^7.0|^8.0", + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/dom-crawler": "^6.4|^7.0|^8.0", + "symfony/filesystem": "^6.4|^7.0|^8.0", + "symfony/framework-bundle": "^6.4|^7.0|^8.0", + "symfony/phpunit-bridge": "^6.4|^7.0|^8.0", + "symfony/var-dumper": "^6.4|^7.0|^8.0", + "symfony/yaml": "^6.4|^7.0|^8.0" + }, + "suggest": { + "gesdinet/jwt-refresh-token-bundle": "Implements a refresh token system over Json Web Tokens in Symfony", + "spomky-labs/lexik-jose-bridge": "Provides a JWT Token encoder with encryption support" + }, + "type": "symfony-bundle", + "autoload": { + "psr-4": { + "Lexik\\Bundle\\JWTAuthenticationBundle\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jeremy Barthe", + "email": "j.barthe@lexik.fr", + "homepage": "https://github.com/jeremyb" + }, + { + "name": "Nicolas Cabot", + "email": "n.cabot@lexik.fr", + "homepage": "https://github.com/slashfan" + }, + { + "name": "Cedric Girard", + "email": "c.girard@lexik.fr", + "homepage": "https://github.com/cedric-g" + }, + { + "name": "Dev Lexik", + "email": "dev@lexik.fr", + "homepage": "https://github.com/lexik" + }, + { + "name": "Robin Chalas", + "email": "robin.chalas@gmail.com", + "homepage": "https://github.com/chalasr" + }, + { + "name": "Lexik Community", + "homepage": "https://github.com/lexik/LexikJWTAuthenticationBundle/graphs/contributors" + } + ], + "description": "This bundle provides JWT authentication for your Symfony REST API", + "homepage": "https://github.com/lexik/LexikJWTAuthenticationBundle", + "keywords": [ + "Authentication", + "JWS", + "api", + "bundle", + "jwt", + "rest", + "symfony" + ], + "support": { + "issues": "https://github.com/lexik/LexikJWTAuthenticationBundle/issues", + "source": "https://github.com/lexik/LexikJWTAuthenticationBundle/tree/v3.2.0" + }, + "funding": [ + { + "url": "https://github.com/chalasr", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/lexik/jwt-authentication-bundle", + "type": "tidelift" + } + ], + "time": "2025-12-20T17:47:00+00:00" + }, + { + "name": "nelmio/api-doc-bundle", + "version": "v5.10.3", + "source": { + "type": "git", + "url": "https://github.com/nelmio/NelmioApiDocBundle.git", + "reference": "e6b6d3f4e413f8f9a78b8470a082b9e4ffb49533" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nelmio/NelmioApiDocBundle/zipball/e6b6d3f4e413f8f9a78b8470a082b9e4ffb49533", + "reference": "e6b6d3f4e413f8f9a78b8470a082b9e4ffb49533", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "phpdocumentor/reflection-docblock": "^5.0 || ^6.0", + "phpdocumentor/type-resolver": "^1.8.2 || ^2.0", + "psr/cache": "^1.0 || ^2.0 || ^3.0", + "psr/container": "^1.0 || ^2.0", + "psr/log": "^1.0 || ^2.0 || ^3.0", + "symfony/config": "^6.4 || ^7.2 || ^8.0", + "symfony/console": "^6.4 || ^7.2 || ^8.0", + "symfony/dependency-injection": "^6.4 || ^7.2 || ^8.0", + "symfony/deprecation-contracts": "^2.1 || ^3", + "symfony/framework-bundle": "^6.4 || ^7.2 || ^8.0", + "symfony/http-foundation": "^6.4 || ^7.2 || ^8.0", + "symfony/http-kernel": "^6.4 || ^7.2 || ^8.0", + "symfony/options-resolver": "^6.4 || ^7.2 || ^8.0", + "symfony/property-info": "^6.4 || ^7.2 || ^8.0", + "symfony/routing": "^6.4 || ^7.2 || ^8.0", + "symfony/type-info": "^7.2 || ^8.0", + "zircote/swagger-php": "^4.11.1 || ^5.0 || ^6.0" + }, + "conflict": { + "symfony/property-info": "6.4.32 || 7.3.10 || 7.4.4 || 8.0.4", + "zircote/swagger-php": "4.8.7 || 5.5.0" + }, + "require-dev": { + "api-platform/core": "^3.2 || ^4.0", + "doctrine/inflector": "^2.0", + "friendsofphp/php-cs-fixer": "^3.52", + "friendsofsymfony/rest-bundle": "^3.2.0", + "jms/serializer": "^3.32", + "jms/serializer-bundle": "^5.5", + "phpstan/phpstan": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpstan/phpstan-strict-rules": "^2.0", + "phpstan/phpstan-symfony": "^2.0", + "phpunit/phpunit": "^10.5", + "symfony/asset": "^6.4 || ^7.2 || ^8.0", + "symfony/browser-kit": "^6.4 || ^7.2 || ^8.0", + "symfony/cache": "^6.4 || ^7.2 || ^8.0", + "symfony/dom-crawler": "^6.4 || ^7.2 || ^8.0", + "symfony/expression-language": "^6.4 || ^7.2 || ^8.0", + "symfony/finder": "^6.4 || ^7.2 || ^8.0", + "symfony/form": "^6.4 || ^7.2 || ^8.0", + "symfony/phpunit-bridge": "^6.4 || ^7.2 || ^8.0", + "symfony/security-csrf": "^6.4 || ^7.2 || ^8.0", + "symfony/security-http": "^6.4 || ^7.2 || ^8.0", + "symfony/serializer": "^6.4 || ^7.2 || ^8.0", + "symfony/translation": "^6.4 || ^7.2 || ^8.0", + "symfony/twig-bundle": "^6.4 || ^7.2 || ^8.0", + "symfony/uid": "^6.4 || ^7.2 || ^8.0", + "symfony/validator": "^6.4 || ^7.2 || ^8.0", + "willdurand/hateoas-bundle": "^2.7 || ^3.0", + "willdurand/negotiation": "^3.0" + }, + "suggest": { + "api-platform/core": "For using an API oriented framework.", + "friendsofsymfony/rest-bundle": "For using the parameters annotations.", + "jms/serializer-bundle": "For describing your models.", + "symfony/asset": "For using the Swagger UI.", + "symfony/cache": "For using a PSR-6 compatible cache implementation with the API doc generator.", + "symfony/form": "For describing your form type models.", + "symfony/monolog-bundle": "For using a PSR-3 compatible logger implementation with the API PHP describer.", + "symfony/security-csrf": "For using csrf protection tokens in forms.", + "symfony/serializer": "For describing your models.", + "symfony/twig-bundle": "For using the Swagger UI.", + "symfony/validator": "For describing the validation constraints in your models.", + "willdurand/hateoas-bundle": "For extracting HATEOAS metadata." + }, + "type": "symfony-bundle", + "extra": { + "branch-alias": { + "dev-4.x": "4.x-dev", + "dev-5.x": "5.x-dev" + } + }, + "autoload": { + "psr-4": { + "Nelmio\\ApiDocBundle\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Symfony Community", + "homepage": "https://github.com/nelmio/NelmioApiDocBundle/contributors" + } + ], + "description": "Generates documentation for your REST API from attributes", + "keywords": [ + "api", + "doc", + "documentation", + "rest" + ], + "support": { + "issues": "https://github.com/nelmio/NelmioApiDocBundle/issues", + "source": "https://github.com/nelmio/NelmioApiDocBundle/tree/v5.10.3" + }, + "funding": [ + { + "url": "https://github.com/DjordyKoert", + "type": "github" + } + ], + "time": "2026-06-01T07:56:07+00:00" + }, + { + "name": "nelmio/cors-bundle", + "version": "2.6.1", + "source": { + "type": "git", + "url": "https://github.com/nelmio/NelmioCorsBundle.git", + "reference": "3d80dbcd5d1eb5f8b20ed5199e1778d44c2e4d1c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nelmio/NelmioCorsBundle/zipball/3d80dbcd5d1eb5f8b20ed5199e1778d44c2e4d1c", + "reference": "3d80dbcd5d1eb5f8b20ed5199e1778d44c2e4d1c", + "shasum": "" + }, + "require": { + "psr/log": "^1.0 || ^2.0 || ^3.0", + "symfony/framework-bundle": "^5.4 || ^6.0 || ^7.0 || ^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^1.11.5", + "phpstan/phpstan-deprecation-rules": "^1.2.0", + "phpstan/phpstan-phpunit": "^1.4", + "phpstan/phpstan-symfony": "^1.4.4", + "phpunit/phpunit": "^8" + }, + "type": "symfony-bundle", + "extra": { + "branch-alias": { + "dev-master": "2.x-dev" + } + }, + "autoload": { + "psr-4": { + "Nelmio\\CorsBundle\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nelmio", + "homepage": "http://nelm.io" + }, + { + "name": "Symfony Community", + "homepage": "https://github.com/nelmio/NelmioCorsBundle/contributors" + } + ], + "description": "Adds CORS (Cross-Origin Resource Sharing) headers support in your Symfony application", + "keywords": [ + "api", + "cors", + "crossdomain" + ], + "support": { + "issues": "https://github.com/nelmio/NelmioCorsBundle/issues", + "source": "https://github.com/nelmio/NelmioCorsBundle/tree/2.6.1" + }, + "time": "2026-01-12T15:59:08+00:00" + }, + { + "name": "nikic/php-parser", + "version": "v5.7.0", + "source": { + "type": "git", + "url": "https://github.com/nikic/PHP-Parser.git", + "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/dca41cd15c2ac9d055ad70dbfd011130757d1f82", + "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82", + "shasum": "" + }, + "require": { + "ext-ctype": "*", + "ext-json": "*", + "ext-tokenizer": "*", + "php": ">=7.4" + }, + "require-dev": { + "ircmaxell/php-yacc": "^0.0.7", + "phpunit/phpunit": "^9.0" + }, + "bin": [ + "bin/php-parse" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.x-dev" + } + }, + "autoload": { + "psr-4": { + "PhpParser\\": "lib/PhpParser" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Nikita Popov" + } + ], + "description": "A PHP parser written in PHP", + "keywords": [ + "parser", + "php" + ], + "support": { + "issues": "https://github.com/nikic/PHP-Parser/issues", + "source": "https://github.com/nikic/PHP-Parser/tree/v5.7.0" + }, + "time": "2025-12-06T11:56:16+00:00" + }, + { + "name": "phpdocumentor/reflection-common", + "version": "2.2.0", + "source": { + "type": "git", + "url": "https://github.com/phpDocumentor/ReflectionCommon.git", + "reference": "1d01c49d4ed62f25aa84a747ad35d5a16924662b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpDocumentor/ReflectionCommon/zipball/1d01c49d4ed62f25aa84a747ad35d5a16924662b", + "reference": "1d01c49d4ed62f25aa84a747ad35d5a16924662b", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-2.x": "2.x-dev" + } + }, + "autoload": { + "psr-4": { + "phpDocumentor\\Reflection\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jaap van Otterdijk", + "email": "opensource@ijaap.nl" + } + ], + "description": "Common reflection classes used by phpdocumentor to reflect the code structure", + "homepage": "http://www.phpdoc.org", + "keywords": [ + "FQSEN", + "phpDocumentor", + "phpdoc", + "reflection", + "static analysis" + ], + "support": { + "issues": "https://github.com/phpDocumentor/ReflectionCommon/issues", + "source": "https://github.com/phpDocumentor/ReflectionCommon/tree/2.x" + }, + "time": "2020-06-27T09:03:43+00:00" + }, + { + "name": "phpdocumentor/reflection-docblock", + "version": "6.0.3", + "source": { + "type": "git", + "url": "https://github.com/phpDocumentor/ReflectionDocBlock.git", + "reference": "7bae67520aa9f5ecc506d646810bd40d9da54582" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/7bae67520aa9f5ecc506d646810bd40d9da54582", + "reference": "7bae67520aa9f5ecc506d646810bd40d9da54582", + "shasum": "" + }, + "require": { + "doctrine/deprecations": "^1.1", + "ext-filter": "*", + "php": "^7.4 || ^8.0", + "phpdocumentor/reflection-common": "^2.2", + "phpdocumentor/type-resolver": "^2.0", + "phpstan/phpdoc-parser": "^2.0", + "webmozart/assert": "^1.9.1 || ^2" + }, + "require-dev": { + "mockery/mockery": "~1.3.5 || ~1.6.0", + "phpstan/extension-installer": "^1.1", + "phpstan/phpstan": "^1.8", + "phpstan/phpstan-mockery": "^1.1", + "phpstan/phpstan-webmozart-assert": "^1.2", + "phpunit/phpunit": "^9.5", + "psalm/phar": "^5.26", + "shipmonk/dead-code-detector": "^0.5.1" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.x-dev" + } + }, + "autoload": { + "psr-4": { + "phpDocumentor\\Reflection\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mike van Riel", + "email": "me@mikevanriel.com" + }, + { + "name": "Jaap van Otterdijk", + "email": "opensource@ijaap.nl" + } + ], + "description": "With this component, a library can provide support for annotations via DocBlocks or otherwise retrieve information that is embedded in a DocBlock.", + "support": { + "issues": "https://github.com/phpDocumentor/ReflectionDocBlock/issues", + "source": "https://github.com/phpDocumentor/ReflectionDocBlock/tree/6.0.3" + }, + "time": "2026-03-18T20:49:53+00:00" + }, + { + "name": "phpdocumentor/type-resolver", + "version": "2.0.0", + "source": { + "type": "git", + "url": "https://github.com/phpDocumentor/TypeResolver.git", + "reference": "327a05bbee54120d4786a0dc67aad30226ad4cf9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpDocumentor/TypeResolver/zipball/327a05bbee54120d4786a0dc67aad30226ad4cf9", + "reference": "327a05bbee54120d4786a0dc67aad30226ad4cf9", + "shasum": "" + }, + "require": { + "doctrine/deprecations": "^1.0", + "php": "^7.4 || ^8.0", + "phpdocumentor/reflection-common": "^2.0", + "phpstan/phpdoc-parser": "^2.0" + }, + "require-dev": { + "ext-tokenizer": "*", + "phpbench/phpbench": "^1.2", + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/phpunit": "^9.5", + "psalm/phar": "^4" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-1.x": "1.x-dev", + "dev-2.x": "2.x-dev" + } + }, + "autoload": { + "psr-4": { + "phpDocumentor\\Reflection\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mike van Riel", + "email": "me@mikevanriel.com" + } + ], + "description": "A PSR-5 based resolver of Class names, Types and Structural Element Names", + "support": { + "issues": "https://github.com/phpDocumentor/TypeResolver/issues", + "source": "https://github.com/phpDocumentor/TypeResolver/tree/2.0.0" + }, + "time": "2026-01-06T21:53:42+00:00" + }, + { + "name": "phpstan/phpdoc-parser", + "version": "2.3.2", + "source": { + "type": "git", + "url": "https://github.com/phpstan/phpdoc-parser.git", + "reference": "a004701b11273a26cd7955a61d67a7f1e525a45a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/a004701b11273a26cd7955a61d67a7f1e525a45a", + "reference": "a004701b11273a26cd7955a61d67a7f1e525a45a", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "doctrine/annotations": "^2.0", + "nikic/php-parser": "^5.3.0", + "php-parallel-lint/php-parallel-lint": "^1.2", + "phpstan/extension-installer": "^1.0", + "phpstan/phpstan": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpstan/phpstan-strict-rules": "^2.0", + "phpunit/phpunit": "^9.6", + "symfony/process": "^5.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "PHPStan\\PhpDocParser\\": [ + "src/" + ] + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "PHPDoc parser with support for nullable, intersection and generic types", + "support": { + "issues": "https://github.com/phpstan/phpdoc-parser/issues", + "source": "https://github.com/phpstan/phpdoc-parser/tree/2.3.2" + }, + "time": "2026-01-25T14:56:51+00:00" + }, + { + "name": "psr/cache", + "version": "3.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/cache.git", + "reference": "aa5030cfa5405eccfdcb1083ce040c2cb8d253bf" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/cache/zipball/aa5030cfa5405eccfdcb1083ce040c2cb8d253bf", + "reference": "aa5030cfa5405eccfdcb1083ce040c2cb8d253bf", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Cache\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for caching libraries", + "keywords": [ + "cache", + "psr", + "psr-6" + ], + "support": { + "source": "https://github.com/php-fig/cache/tree/3.0.0" + }, + "time": "2021-02-03T23:26:27+00:00" + }, + { + "name": "psr/clock", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/clock.git", + "reference": "e41a24703d4560fd0acb709162f73b8adfc3aa0d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/clock/zipball/e41a24703d4560fd0acb709162f73b8adfc3aa0d", + "reference": "e41a24703d4560fd0acb709162f73b8adfc3aa0d", + "shasum": "" + }, + "require": { + "php": "^7.0 || ^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Psr\\Clock\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for reading the clock.", + "homepage": "https://github.com/php-fig/clock", + "keywords": [ + "clock", + "now", + "psr", + "psr-20", + "time" + ], + "support": { + "issues": "https://github.com/php-fig/clock/issues", + "source": "https://github.com/php-fig/clock/tree/1.0.0" + }, + "time": "2022-11-25T14:36:26+00:00" + }, + { + "name": "psr/container", + "version": "2.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/container.git", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/container/zipball/c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "shasum": "" + }, + "require": { + "php": ">=7.4.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Container\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common Container Interface (PHP FIG PSR-11)", + "homepage": "https://github.com/php-fig/container", + "keywords": [ + "PSR-11", + "container", + "container-interface", + "container-interop", + "psr" + ], + "support": { + "issues": "https://github.com/php-fig/container/issues", + "source": "https://github.com/php-fig/container/tree/2.0.2" + }, + "time": "2021-11-05T16:47:00+00:00" + }, + { + "name": "psr/event-dispatcher", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/event-dispatcher.git", + "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/event-dispatcher/zipball/dbefd12671e8a14ec7f180cab83036ed26714bb0", + "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0", + "shasum": "" + }, + "require": { + "php": ">=7.2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\EventDispatcher\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "http://www.php-fig.org/" + } + ], + "description": "Standard interfaces for event handling.", + "keywords": [ + "events", + "psr", + "psr-14" + ], + "support": { + "issues": "https://github.com/php-fig/event-dispatcher/issues", + "source": "https://github.com/php-fig/event-dispatcher/tree/1.0.0" + }, + "time": "2019-01-08T18:20:26+00:00" + }, + { + "name": "psr/log", + "version": "3.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/log.git", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/log/zipball/f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Log\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for logging libraries", + "homepage": "https://github.com/php-fig/log", + "keywords": [ + "log", + "psr", + "psr-3" + ], + "support": { + "source": "https://github.com/php-fig/log/tree/3.0.2" + }, + "time": "2024-09-11T13:17:53+00:00" + }, + { + "name": "radebatz/type-info-extras", + "version": "1.0.7", + "source": { + "type": "git", + "url": "https://github.com/DerManoMann/type-info-extras.git", + "reference": "95a524a74a61648b44e355cb33d38db4b17ef5ce" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/DerManoMann/type-info-extras/zipball/95a524a74a61648b44e355cb33d38db4b17ef5ce", + "reference": "95a524a74a61648b44e355cb33d38db4b17ef5ce", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "phpstan/phpdoc-parser": "^2.0", + "symfony/type-info": "^7.3.8 || ^7.4.1 || ^8.0 || ^8.1-@dev" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.70", + "phpstan/phpstan": "^2.1", + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Radebatz\\TypeInfoExtras\\": "src" + }, + "exclude-from-classmap": [ + "/tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Martin Rademacher", + "email": "mano@radebatz.org" + } + ], + "description": "Extras for symfony/type-info", + "homepage": "http://radebatz.net/mano/", + "keywords": [ + "component", + "symfony", + "type-info", + "types" + ], + "support": { + "issues": "https://github.com/DerManoMann/type-info-extras/issues", + "source": "https://github.com/DerManoMann/type-info-extras/tree/1.0.7" + }, + "time": "2026-03-06T22:40:29+00:00" + }, + { + "name": "symfony/asset", + "version": "v7.4.8", + "source": { + "type": "git", + "url": "https://github.com/symfony/asset.git", + "reference": "d2e2f014ccd6ec9fae8dbe6336a4164346a2a856" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/asset/zipball/d2e2f014ccd6ec9fae8dbe6336a4164346a2a856", + "reference": "d2e2f014ccd6ec9fae8dbe6336a4164346a2a856", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "conflict": { + "symfony/http-foundation": "<6.4" + }, + "require-dev": { + "symfony/http-client": "^6.4|^7.0|^8.0", + "symfony/http-foundation": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Asset\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Manages URL generation and versioning of web assets such as CSS stylesheets, JavaScript files and image files", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/asset/tree/v7.4.8" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-03-24T13:12:05+00:00" + }, + { + "name": "symfony/cache", + "version": "v7.4.13", + "source": { + "type": "git", + "url": "https://github.com/symfony/cache.git", + "reference": "4c09e18a92cce126cc0d1155825279fca8cd0673" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/cache/zipball/4c09e18a92cce126cc0d1155825279fca8cd0673", + "reference": "4c09e18a92cce126cc0d1155825279fca8cd0673", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "psr/cache": "^2.0|^3.0", + "psr/log": "^1.1|^2|^3", + "symfony/cache-contracts": "^3.6", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/service-contracts": "^2.5|^3", + "symfony/var-exporter": "^6.4|^7.0|^8.0" + }, + "conflict": { + "doctrine/dbal": "<3.6", + "ext-redis": "<6.1", + "ext-relay": "<0.12.1", + "symfony/dependency-injection": "<6.4", + "symfony/http-kernel": "<6.4", + "symfony/var-dumper": "<6.4" + }, + "provide": { + "psr/cache-implementation": "2.0|3.0", + "psr/simple-cache-implementation": "1.0|2.0|3.0", + "symfony/cache-implementation": "1.1|2.0|3.0" + }, + "require-dev": { + "cache/integration-tests": "dev-master", + "doctrine/dbal": "^3.6|^4", + "predis/predis": "^1.1|^2.0", + "psr/simple-cache": "^1.0|^2.0|^3.0", + "symfony/clock": "^6.4|^7.0|^8.0", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/filesystem": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/messenger": "^6.4|^7.0|^8.0", + "symfony/var-dumper": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Cache\\": "" + }, + "classmap": [ + "Traits/ValueWrapper.php" + ], + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides extended PSR-6, PSR-16 (and tags) implementations", + "homepage": "https://symfony.com", + "keywords": [ + "caching", + "psr6" + ], + "support": { + "source": "https://github.com/symfony/cache/tree/v7.4.13" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-24T08:43:14+00:00" + }, + { + "name": "symfony/cache-contracts", + "version": "v3.7.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/cache-contracts.git", + "reference": "225e8a254166bd3442e370c6f50145465db63831" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/cache-contracts/zipball/225e8a254166bd3442e370c6f50145465db63831", + "reference": "225e8a254166bd3442e370c6f50145465db63831", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/cache": "^3.0" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\Cache\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to caching", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/cache-contracts/tree/v3.7.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-05T15:33:14+00:00" + }, + { + "name": "symfony/clock", + "version": "v7.4.8", + "source": { + "type": "git", + "url": "https://github.com/symfony/clock.git", + "reference": "674fa3b98e21531dd040e613479f5f6fa8f32111" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/clock/zipball/674fa3b98e21531dd040e613479f5f6fa8f32111", + "reference": "674fa3b98e21531dd040e613479f5f6fa8f32111", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "psr/clock": "^1.0", + "symfony/polyfill-php83": "^1.28" + }, + "provide": { + "psr/clock-implementation": "1.0" + }, + "type": "library", + "autoload": { + "files": [ + "Resources/now.php" + ], + "psr-4": { + "Symfony\\Component\\Clock\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Decouples applications from the system clock", + "homepage": "https://symfony.com", + "keywords": [ + "clock", + "psr20", + "time" + ], + "support": { + "source": "https://github.com/symfony/clock/tree/v7.4.8" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-03-24T13:12:05+00:00" + }, + { + "name": "symfony/config", + "version": "v7.4.10", + "source": { + "type": "git", + "url": "https://github.com/symfony/config.git", + "reference": "d91b6c7cd2a8c9a9c2b8d26c8f5ed48edf99ef57" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/config/zipball/d91b6c7cd2a8c9a9c2b8d26c8f5ed48edf99ef57", + "reference": "d91b6c7cd2a8c9a9c2b8d26c8f5ed48edf99ef57", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/filesystem": "^7.1|^8.0", + "symfony/polyfill-ctype": "~1.8" + }, + "conflict": { + "symfony/finder": "<6.4", + "symfony/service-contracts": "<2.5" + }, + "require-dev": { + "symfony/event-dispatcher": "^6.4|^7.0|^8.0", + "symfony/finder": "^6.4|^7.0|^8.0", + "symfony/messenger": "^6.4|^7.0|^8.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/yaml": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Config\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Helps you find, load, combine, autofill and validate configuration values of any kind", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/config/tree/v7.4.10" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-03T14:20:49+00:00" + }, + { + "name": "symfony/console", + "version": "v7.4.13", + "source": { + "type": "git", + "url": "https://github.com/symfony/console.git", + "reference": "85095d2573eaefaf35e40b9513a9bf09f72cd217" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/console/zipball/85095d2573eaefaf35e40b9513a9bf09f72cd217", + "reference": "85095d2573eaefaf35e40b9513a9bf09f72cd217", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-mbstring": "~1.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/string": "^7.2|^8.0" + }, + "conflict": { + "symfony/dependency-injection": "<6.4", + "symfony/dotenv": "<6.4", + "symfony/event-dispatcher": "<6.4", + "symfony/lock": "<6.4", + "symfony/process": "<6.4" + }, + "provide": { + "psr/log-implementation": "1.0|2.0|3.0" + }, + "require-dev": { + "psr/log": "^1|^2|^3", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/event-dispatcher": "^6.4|^7.0|^8.0", + "symfony/http-foundation": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/lock": "^6.4|^7.0|^8.0", + "symfony/messenger": "^6.4|^7.0|^8.0", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/stopwatch": "^6.4|^7.0|^8.0", + "symfony/var-dumper": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Console\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Eases the creation of beautiful and testable command line interfaces", + "homepage": "https://symfony.com", + "keywords": [ + "cli", + "command-line", + "console", + "terminal" + ], + "support": { + "source": "https://github.com/symfony/console/tree/v7.4.13" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-24T08:56:14+00:00" + }, + { + "name": "symfony/dependency-injection", + "version": "v7.4.13", + "source": { + "type": "git", + "url": "https://github.com/symfony/dependency-injection.git", + "reference": "f299e20ce983be6c0744952533c6dfeaaa1448e2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/dependency-injection/zipball/f299e20ce983be6c0744952533c6dfeaaa1448e2", + "reference": "f299e20ce983be6c0744952533c6dfeaaa1448e2", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "psr/container": "^1.1|^2.0", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/service-contracts": "^3.6", + "symfony/var-exporter": "^6.4.20|^7.2.5|^8.0" + }, + "conflict": { + "ext-psr": "<1.1|>=2", + "symfony/config": "<6.4", + "symfony/finder": "<6.4", + "symfony/yaml": "<6.4" + }, + "provide": { + "psr/container-implementation": "1.1|2.0", + "symfony/service-implementation": "1.1|2.0|3.0" + }, + "require-dev": { + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/yaml": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\DependencyInjection\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Allows you to standardize and centralize the way objects are constructed in your application", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/dependency-injection/tree/v7.4.13" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-20T14:07:29+00:00" + }, + { + "name": "symfony/deprecation-contracts", + "version": "v3.7.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/deprecation-contracts.git", + "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/50f59d1f3ca46d41ac911f97a78626b6756af35b", + "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "files": [ + "function.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "A generic function and convention to trigger deprecation notices", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-13T15:52:40+00:00" + }, + { + "name": "symfony/doctrine-bridge", + "version": "v7.4.9", + "source": { + "type": "git", + "url": "https://github.com/symfony/doctrine-bridge.git", + "reference": "7a87c85853f3069e3657a823c62b02952de46b0a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/doctrine-bridge/zipball/7a87c85853f3069e3657a823c62b02952de46b0a", + "reference": "7a87c85853f3069e3657a823c62b02952de46b0a", + "shasum": "" + }, + "require": { + "doctrine/event-manager": "^2", + "doctrine/persistence": "^3.1|^4", + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-ctype": "~1.8", + "symfony/polyfill-mbstring": "~1.0", + "symfony/service-contracts": "^2.5|^3" + }, + "conflict": { + "doctrine/collections": "<1.8", + "doctrine/dbal": "<3.6", + "doctrine/lexer": "<1.1", + "doctrine/orm": "<2.15", + "symfony/cache": "<6.4", + "symfony/dependency-injection": "<6.4", + "symfony/form": "<6.4.6|>=7,<7.0.6", + "symfony/http-foundation": "<6.4", + "symfony/http-kernel": "<6.4", + "symfony/lock": "<6.4", + "symfony/messenger": "<6.4", + "symfony/property-info": "<6.4", + "symfony/security-bundle": "<6.4", + "symfony/security-core": "<6.4", + "symfony/validator": "<7.4" + }, + "require-dev": { + "doctrine/collections": "^1.8|^2.0", + "doctrine/data-fixtures": "^1.1|^2", + "doctrine/dbal": "^3.6|^4", + "doctrine/orm": "^2.15|^3", + "psr/log": "^1|^2|^3", + "symfony/cache": "^6.4|^7.0|^8.0", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/doctrine-messenger": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/form": "^7.2|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/lock": "^6.4|^7.0|^8.0", + "symfony/messenger": "^6.4|^7.0|^8.0", + "symfony/property-access": "^6.4|^7.0|^8.0", + "symfony/property-info": "^6.4|^7.0|^8.0", + "symfony/security-core": "^6.4|^7.0|^8.0", + "symfony/stopwatch": "^6.4|^7.0|^8.0", + "symfony/translation": "^6.4|^7.0|^8.0", + "symfony/type-info": "^7.1.8|^8.0", + "symfony/uid": "^6.4|^7.0|^8.0", + "symfony/validator": "^7.4|^8.0", + "symfony/var-dumper": "^6.4|^7.0|^8.0" + }, + "type": "symfony-bridge", + "autoload": { + "psr-4": { + "Symfony\\Bridge\\Doctrine\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides integration for Doctrine with various Symfony components", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/doctrine-bridge/tree/v7.4.9" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-29T14:19:39+00:00" + }, + { + "name": "symfony/dotenv", + "version": "v7.4.11", + "source": { + "type": "git", + "url": "https://github.com/symfony/dotenv.git", + "reference": "82e9b1355c68ef7b96397dbd34cc75a92eebae7c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/dotenv/zipball/82e9b1355c68ef7b96397dbd34cc75a92eebae7c", + "reference": "82e9b1355c68ef7b96397dbd34cc75a92eebae7c", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "conflict": { + "symfony/console": "<6.4", + "symfony/process": "<6.4" + }, + "require-dev": { + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/process": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Dotenv\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Registers environment variables from a .env file", + "homepage": "https://symfony.com", + "keywords": [ + "dotenv", + "env", + "environment" + ], + "support": { + "source": "https://github.com/symfony/dotenv/tree/v7.4.11" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-11T13:02:51+00:00" + }, + { + "name": "symfony/error-handler", + "version": "v7.4.8", + "source": { + "type": "git", + "url": "https://github.com/symfony/error-handler.git", + "reference": "8dd79d8af777ee6cba2fd4d98da6ffb839f3c0fa" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/error-handler/zipball/8dd79d8af777ee6cba2fd4d98da6ffb839f3c0fa", + "reference": "8dd79d8af777ee6cba2fd4d98da6ffb839f3c0fa", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "psr/log": "^1|^2|^3", + "symfony/polyfill-php85": "^1.32", + "symfony/var-dumper": "^6.4|^7.0|^8.0" + }, + "conflict": { + "symfony/deprecation-contracts": "<2.5", + "symfony/http-kernel": "<6.4" + }, + "require-dev": { + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/serializer": "^6.4|^7.0|^8.0", + "symfony/webpack-encore-bundle": "^1.0|^2.0" + }, + "bin": [ + "Resources/bin/patch-type-declarations" + ], + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\ErrorHandler\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides tools to manage errors and ease debugging PHP code", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/error-handler/tree/v7.4.8" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-03-24T13:12:05+00:00" + }, + { + "name": "symfony/event-dispatcher", + "version": "v7.4.9", + "source": { + "type": "git", + "url": "https://github.com/symfony/event-dispatcher.git", + "reference": "e4a2e29753c7801f7a8340e066cfa788f3bc8101" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/e4a2e29753c7801f7a8340e066cfa788f3bc8101", + "reference": "e4a2e29753c7801f7a8340e066cfa788f3bc8101", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/event-dispatcher-contracts": "^2.5|^3" + }, + "conflict": { + "symfony/dependency-injection": "<6.4", + "symfony/service-contracts": "<2.5" + }, + "provide": { + "psr/event-dispatcher-implementation": "1.0", + "symfony/event-dispatcher-implementation": "2.0|3.0" + }, + "require-dev": { + "psr/log": "^1|^2|^3", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/error-handler": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/framework-bundle": "^6.4|^7.0|^8.0", + "symfony/http-foundation": "^6.4|^7.0|^8.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/stopwatch": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\EventDispatcher\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/event-dispatcher/tree/v7.4.9" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-18T13:18:21+00:00" + }, + { + "name": "symfony/event-dispatcher-contracts", + "version": "v3.7.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/event-dispatcher-contracts.git", + "reference": "ccba7060602b7fed0b03c85bf025257f76d9ef32" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/ccba7060602b7fed0b03c85bf025257f76d9ef32", + "reference": "ccba7060602b7fed0b03c85bf025257f76d9ef32", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/event-dispatcher": "^1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\EventDispatcher\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to dispatching event", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.7.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-01-05T13:30:16+00:00" + }, + { + "name": "symfony/filesystem", + "version": "v7.4.11", + "source": { + "type": "git", + "url": "https://github.com/symfony/filesystem.git", + "reference": "d721ea61b4a5fba8c5b6e7c1feda19efea144b50" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/d721ea61b4a5fba8c5b6e7c1feda19efea144b50", + "reference": "d721ea61b4a5fba8c5b6e7c1feda19efea144b50", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/polyfill-ctype": "~1.8", + "symfony/polyfill-mbstring": "~1.8" + }, + "require-dev": { + "symfony/process": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Filesystem\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides basic utilities for the filesystem", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/filesystem/tree/v7.4.11" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-11T16:38:44+00:00" + }, + { + "name": "symfony/finder", + "version": "v7.4.8", + "source": { + "type": "git", + "url": "https://github.com/symfony/finder.git", + "reference": "e0be088d22278583a82da281886e8c3592fbf149" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/finder/zipball/e0be088d22278583a82da281886e8c3592fbf149", + "reference": "e0be088d22278583a82da281886e8c3592fbf149", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "symfony/filesystem": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Finder\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Finds files and directories via an intuitive fluent interface", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/finder/tree/v7.4.8" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-03-24T13:12:05+00:00" + }, + { + "name": "symfony/flex", + "version": "v2.11.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/flex.git", + "reference": "4a6d98eea3ebc7f68d82810cb682eedca2649e99" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/flex/zipball/4a6d98eea3ebc7f68d82810cb682eedca2649e99", + "reference": "4a6d98eea3ebc7f68d82810cb682eedca2649e99", + "shasum": "" + }, + "require": { + "composer-plugin-api": "^2.1", + "php": ">=8.1" + }, + "conflict": { + "composer/semver": "<1.7.2", + "symfony/dotenv": "<5.4" + }, + "require-dev": { + "composer/composer": "^2.1", + "phpunit/phpunit": "^12.4", + "symfony/dotenv": "^6.4.41|^7.4.13|^8.0.13", + "symfony/filesystem": "^6.4|^7.4|^8.0", + "symfony/process": "^6.4|^7.4|^8.0" + }, + "type": "composer-plugin", + "extra": { + "class": "Symfony\\Flex\\Flex" + }, + "autoload": { + "psr-4": { + "Symfony\\Flex\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien.potencier@gmail.com" + } + ], + "description": "Composer plugin for Symfony", + "support": { + "issues": "https://github.com/symfony/flex/issues", + "source": "https://github.com/symfony/flex/tree/v2.11.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-29T17:25:22+00:00" + }, + { + "name": "symfony/framework-bundle", + "version": "v7.4.13", + "source": { + "type": "git", + "url": "https://github.com/symfony/framework-bundle.git", + "reference": "8be39c7bf9e6f58fe49c07927572a9df7c961c95" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/framework-bundle/zipball/8be39c7bf9e6f58fe49c07927572a9df7c961c95", + "reference": "8be39c7bf9e6f58fe49c07927572a9df7c961c95", + "shasum": "" + }, + "require": { + "composer-runtime-api": ">=2.1", + "ext-xml": "*", + "php": ">=8.2", + "symfony/cache": "^6.4.12|^7.0|^8.0", + "symfony/config": "^7.4.4|^8.0.4", + "symfony/dependency-injection": "^7.4.4|^8.0.4", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/error-handler": "^7.3|^8.0", + "symfony/event-dispatcher": "^6.4|^7.0|^8.0", + "symfony/filesystem": "^7.1|^8.0", + "symfony/finder": "^6.4|^7.0|^8.0", + "symfony/http-foundation": "^7.4|^8.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/polyfill-mbstring": "~1.0", + "symfony/polyfill-php85": "^1.32", + "symfony/routing": "^7.4|^8.0" + }, + "conflict": { + "doctrine/persistence": "<1.3", + "phpdocumentor/reflection-docblock": "<5.2|>=7", + "phpdocumentor/type-resolver": "<1.5.1", + "symfony/asset": "<6.4", + "symfony/asset-mapper": "<6.4", + "symfony/clock": "<6.4", + "symfony/console": "<6.4", + "symfony/dom-crawler": "<6.4", + "symfony/dotenv": "<6.4", + "symfony/form": "<7.4", + "symfony/http-client": "<6.4", + "symfony/lock": "<6.4", + "symfony/mailer": "<6.4", + "symfony/messenger": "<7.4", + "symfony/mime": "<6.4.37|>=7.0,<7.4.9|>=8.0,<8.0.9", + "symfony/property-access": "<6.4", + "symfony/property-info": "<6.4", + "symfony/runtime": "<6.4.13|>=7.0,<7.1.6", + "symfony/scheduler": "<6.4.4|>=7.0.0,<7.0.4", + "symfony/security-core": "<6.4", + "symfony/security-csrf": "<7.2", + "symfony/serializer": "<7.2.5", + "symfony/stopwatch": "<6.4", + "symfony/translation": "<7.3", + "symfony/twig-bridge": "<6.4", + "symfony/twig-bundle": "<6.4", + "symfony/validator": "<6.4", + "symfony/web-profiler-bundle": "<6.4", + "symfony/webhook": "<7.2", + "symfony/workflow": "<7.4" + }, + "require-dev": { + "doctrine/persistence": "^1.3|^2|^3", + "dragonmantank/cron-expression": "^3.1", + "phpdocumentor/reflection-docblock": "^5.2|^6.0", + "seld/jsonlint": "^1.10", + "symfony/asset": "^6.4|^7.0|^8.0", + "symfony/asset-mapper": "^6.4|^7.0|^8.0", + "symfony/browser-kit": "^6.4|^7.0|^8.0", + "symfony/clock": "^6.4|^7.0|^8.0", + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/css-selector": "^6.4|^7.0|^8.0", + "symfony/dom-crawler": "^6.4|^7.0|^8.0", + "symfony/dotenv": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/form": "^7.4|^8.0", + "symfony/html-sanitizer": "^6.4|^7.0|^8.0", + "symfony/http-client": "^6.4|^7.0|^8.0", + "symfony/json-streamer": "^7.3|^8.0", + "symfony/lock": "^6.4|^7.0|^8.0", + "symfony/mailer": "^6.4|^7.0|^8.0", + "symfony/messenger": "^7.4|^8.0", + "symfony/mime": "^6.4.37|^7.4.9|^8.0.9", + "symfony/notifier": "^6.4|^7.0|^8.0", + "symfony/object-mapper": "^7.3|^8.0", + "symfony/polyfill-intl-icu": "~1.0", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/property-info": "^6.4|^7.0|^8.0", + "symfony/rate-limiter": "^6.4|^7.0|^8.0", + "symfony/runtime": "^6.4.13|^7.1.6|^8.0", + "symfony/scheduler": "^6.4.4|^7.0.4|^8.0", + "symfony/security-bundle": "^6.4|^7.0|^8.0", + "symfony/semaphore": "^6.4|^7.0|^8.0", + "symfony/serializer": "^7.2.5|^8.0", + "symfony/stopwatch": "^6.4|^7.0|^8.0", + "symfony/string": "^6.4|^7.0|^8.0", + "symfony/translation": "^7.3|^8.0", + "symfony/twig-bundle": "^6.4|^7.0|^8.0", + "symfony/type-info": "^7.1.8|^8.0", + "symfony/uid": "^6.4|^7.0|^8.0", + "symfony/validator": "^7.4|^8.0", + "symfony/web-link": "^6.4|^7.0|^8.0", + "symfony/webhook": "^7.2|^8.0", + "symfony/workflow": "^7.4|^8.0", + "symfony/yaml": "^7.3|^8.0", + "twig/twig": "^3.12" + }, + "type": "symfony-bundle", + "autoload": { + "psr-4": { + "Symfony\\Bundle\\FrameworkBundle\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides a tight integration between Symfony components and the Symfony full-stack framework", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/framework-bundle/tree/v7.4.13" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-23T18:04:28+00:00" + }, + { + "name": "symfony/http-client", + "version": "v7.4.13", + "source": { + "type": "git", + "url": "https://github.com/symfony/http-client.git", + "reference": "e8a112b8415707265a7e614278136a9d92989a6a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/http-client/zipball/e8a112b8415707265a7e614278136a9d92989a6a", + "reference": "e8a112b8415707265a7e614278136a9d92989a6a", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "psr/log": "^1|^2|^3", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/http-client-contracts": "~3.4.4|^3.5.2", + "symfony/polyfill-php83": "^1.29", + "symfony/service-contracts": "^2.5|^3" + }, + "conflict": { + "amphp/amp": "<2.5", + "amphp/socket": "<1.1", + "php-http/discovery": "<1.15", + "symfony/http-foundation": "<6.4" + }, + "provide": { + "php-http/async-client-implementation": "*", + "php-http/client-implementation": "*", + "psr/http-client-implementation": "1.0", + "symfony/http-client-implementation": "3.0" + }, + "require-dev": { + "amphp/http-client": "^4.2.1|^5.0", + "amphp/http-tunnel": "^1.0|^2.0", + "guzzlehttp/promises": "^1.4|^2.0", + "nyholm/psr7": "^1.0", + "php-http/httplug": "^1.0|^2.0", + "psr/http-client": "^1.0", + "symfony/amphp-http-client-meta": "^1.0|^2.0", + "symfony/cache": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/messenger": "^6.4|^7.0|^8.0", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/rate-limiter": "^6.4|^7.0|^8.0", + "symfony/stopwatch": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\HttpClient\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides powerful methods to fetch HTTP resources synchronously or asynchronously", + "homepage": "https://symfony.com", + "keywords": [ + "http" + ], + "support": { + "source": "https://github.com/symfony/http-client/tree/v7.4.13" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-24T09:57:54+00:00" + }, + { + "name": "symfony/http-client-contracts", + "version": "v3.7.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/http-client-contracts.git", + "reference": "4a2d00c37651c0bdc2b9e1c773487a8bf4edb12d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/http-client-contracts/zipball/4a2d00c37651c0bdc2b9e1c773487a8bf4edb12d", + "reference": "4a2d00c37651c0bdc2b9e1c773487a8bf4edb12d", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\HttpClient\\": "" + }, + "exclude-from-classmap": [ + "/Test/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to HTTP clients", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/http-client-contracts/tree/v3.7.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-03-06T13:17:50+00:00" + }, + { + "name": "symfony/http-foundation", + "version": "v7.4.13", + "source": { + "type": "git", + "url": "https://github.com/symfony/http-foundation.git", + "reference": "bc354f47c62301e990b7874fa662326368508e2c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/bc354f47c62301e990b7874fa662326368508e2c", + "reference": "bc354f47c62301e990b7874fa662326368508e2c", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-mbstring": "^1.1" + }, + "conflict": { + "doctrine/dbal": "<3.6", + "symfony/cache": "<6.4.12|>=7.0,<7.1.5" + }, + "require-dev": { + "doctrine/dbal": "^3.6|^4", + "predis/predis": "^1.1|^2.0", + "symfony/cache": "^6.4.12|^7.1.5|^8.0", + "symfony/clock": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/mime": "^6.4|^7.0|^8.0", + "symfony/rate-limiter": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\HttpFoundation\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Defines an object-oriented layer for the HTTP specification", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/http-foundation/tree/v7.4.13" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-24T11:20:33+00:00" + }, + { + "name": "symfony/http-kernel", + "version": "v7.4.13", + "source": { + "type": "git", + "url": "https://github.com/symfony/http-kernel.git", + "reference": "9df847980c436451f4f51d1284491bb4356dd989" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/9df847980c436451f4f51d1284491bb4356dd989", + "reference": "9df847980c436451f4f51d1284491bb4356dd989", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "psr/log": "^1|^2|^3", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/error-handler": "^6.4|^7.0|^8.0", + "symfony/event-dispatcher": "^7.3|^8.0", + "symfony/http-foundation": "^7.4|^8.0", + "symfony/polyfill-ctype": "^1.8" + }, + "conflict": { + "symfony/browser-kit": "<6.4", + "symfony/cache": "<6.4", + "symfony/config": "<6.4", + "symfony/console": "<6.4", + "symfony/dependency-injection": "<6.4", + "symfony/doctrine-bridge": "<6.4", + "symfony/flex": "<2.10", + "symfony/form": "<6.4", + "symfony/http-client": "<6.4", + "symfony/http-client-contracts": "<2.5", + "symfony/mailer": "<6.4", + "symfony/messenger": "<6.4", + "symfony/translation": "<6.4", + "symfony/translation-contracts": "<2.5", + "symfony/twig-bridge": "<6.4", + "symfony/validator": "<6.4", + "symfony/var-dumper": "<6.4", + "twig/twig": "<3.12" + }, + "provide": { + "psr/log-implementation": "1.0|2.0|3.0" + }, + "require-dev": { + "psr/cache": "^1.0|^2.0|^3.0", + "symfony/browser-kit": "^6.4|^7.0|^8.0", + "symfony/clock": "^6.4|^7.0|^8.0", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/css-selector": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4.1|^7.0.1|^8.0", + "symfony/dom-crawler": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/finder": "^6.4|^7.0|^8.0", + "symfony/http-client-contracts": "^2.5|^3", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/property-access": "^7.1|^8.0", + "symfony/routing": "^6.4|^7.0|^8.0", + "symfony/serializer": "^7.1|^8.0", + "symfony/stopwatch": "^6.4|^7.0|^8.0", + "symfony/translation": "^6.4|^7.0|^8.0", + "symfony/translation-contracts": "^2.5|^3", + "symfony/uid": "^6.4|^7.0|^8.0", + "symfony/validator": "^6.4|^7.0|^8.0", + "symfony/var-dumper": "^6.4|^7.0|^8.0", + "symfony/var-exporter": "^6.4|^7.0|^8.0", + "twig/twig": "^3.12" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\HttpKernel\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides a structured process for converting a Request into a Response", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/http-kernel/tree/v7.4.13" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-27T08:31:43+00:00" + }, + { + "name": "symfony/messenger", + "version": "v7.4.12", + "source": { + "type": "git", + "url": "https://github.com/symfony/messenger.git", + "reference": "906387986caecc10b2ad2e85f715d834e5133a04" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/messenger/zipball/906387986caecc10b2ad2e85f715d834e5133a04", + "reference": "906387986caecc10b2ad2e85f715d834e5133a04", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "psr/log": "^1|^2|^3", + "symfony/clock": "^6.4|^7.0|^8.0", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "conflict": { + "symfony/console": "<7.2", + "symfony/event-dispatcher": "<6.4", + "symfony/event-dispatcher-contracts": "<2.5", + "symfony/framework-bundle": "<6.4", + "symfony/http-kernel": "<7.3", + "symfony/lock": "<7.4", + "symfony/serializer": "<6.4.32|>=7.3,<7.3.10|>=7.4,<7.4.4|>=8.0,<8.0.4" + }, + "require-dev": { + "psr/cache": "^1.0|^2.0|^3.0", + "symfony/console": "^7.2|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/event-dispatcher": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^7.3|^8.0", + "symfony/lock": "^7.4|^8.0", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/property-access": "^6.4|^7.0|^8.0", + "symfony/rate-limiter": "^6.4|^7.0|^8.0", + "symfony/routing": "^6.4|^7.0|^8.0", + "symfony/serializer": "^6.4.32|~7.3.10|^7.4.4|^8.0.4", + "symfony/service-contracts": "^2.5|^3", + "symfony/stopwatch": "^6.4|^7.0|^8.0", + "symfony/validator": "^6.4|^7.0|^8.0", + "symfony/var-dumper": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Messenger\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Samuel Roze", + "email": "samuel.roze@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Helps applications send and receive messages to/from other applications or via message queues", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/messenger/tree/v7.4.12" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-19T07:02:47+00:00" + }, + { + "name": "symfony/options-resolver", + "version": "v7.4.8", + "source": { + "type": "git", + "url": "https://github.com/symfony/options-resolver.git", + "reference": "2888fcdc4dc2fd5f7c7397be78631e8af12e02b4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/options-resolver/zipball/2888fcdc4dc2fd5f7c7397be78631e8af12e02b4", + "reference": "2888fcdc4dc2fd5f7c7397be78631e8af12e02b4", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\OptionsResolver\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides an improved replacement for the array_replace PHP function", + "homepage": "https://symfony.com", + "keywords": [ + "config", + "configuration", + "options" + ], + "support": { + "source": "https://github.com/symfony/options-resolver/tree/v7.4.8" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-03-24T13:12:05+00:00" + }, + { + "name": "symfony/password-hasher", + "version": "v7.4.8", + "source": { + "type": "git", + "url": "https://github.com/symfony/password-hasher.git", + "reference": "18a7d92126c95962f7efbcc9e421ba710a366847" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/password-hasher/zipball/18a7d92126c95962f7efbcc9e421ba710a366847", + "reference": "18a7d92126c95962f7efbcc9e421ba710a366847", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "conflict": { + "symfony/security-core": "<6.4" + }, + "require-dev": { + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/security-core": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\PasswordHasher\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Robin Chalas", + "email": "robin.chalas@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides password hashing utilities", + "homepage": "https://symfony.com", + "keywords": [ + "hashing", + "password" + ], + "support": { + "source": "https://github.com/symfony/password-hasher/tree/v7.4.8" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-03-24T13:12:05+00:00" + }, + { + "name": "symfony/polyfill-intl-grapheme", + "version": "v1.38.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-grapheme.git", + "reference": "e9247d281d694a5120554d9afaf54e070e88a603" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/e9247d281d694a5120554d9afaf54e070e88a603", + "reference": "e9247d281d694a5120554d9afaf54e070e88a603", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Grapheme\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's grapheme_* functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "grapheme", + "intl", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.38.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-26T05:58:03+00:00" + }, + { + "name": "symfony/polyfill-intl-normalizer", + "version": "v1.38.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-normalizer.git", + "reference": "2d446c214bdbe5b71bde5011b060a05fece3ae6b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/2d446c214bdbe5b71bde5011b060a05fece3ae6b", + "reference": "2d446c214bdbe5b71bde5011b060a05fece3ae6b", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Normalizer\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's Normalizer class and related functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "intl", + "normalizer", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.38.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-25T13:48:31+00:00" + }, + { + "name": "symfony/polyfill-mbstring", + "version": "v1.38.2", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-mbstring.git", + "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6", + "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6", + "shasum": "" + }, + "require": { + "ext-iconv": "*", + "php": ">=7.2" + }, + "provide": { + "ext-mbstring": "*" + }, + "suggest": { + "ext-mbstring": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Mbstring\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for the Mbstring extension", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "mbstring", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.38.2" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-27T06:59:30+00:00" + }, + { + "name": "symfony/polyfill-php83", + "version": "v1.38.2", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php83.git", + "reference": "796a26abb75ce49f3a84433cd81bf1009d73d5f8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/796a26abb75ce49f3a84433cd81bf1009d73d5f8", + "reference": "796a26abb75ce49f3a84433cd81bf1009d73d5f8", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php83\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.3+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php83/tree/v1.38.2" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-27T06:51:48+00:00" + }, + { + "name": "symfony/polyfill-php84", + "version": "v1.38.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php84.git", + "reference": "f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php84/zipball/f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa", + "reference": "f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php84\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.4+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php84/tree/v1.38.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-26T12:51:13+00:00" + }, + { + "name": "symfony/polyfill-php85", + "version": "v1.38.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php85.git", + "reference": "ba2ba04f3352cfa2dcbbcb90aee13ed967f505b1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/ba2ba04f3352cfa2dcbbcb90aee13ed967f505b1", + "reference": "ba2ba04f3352cfa2dcbbcb90aee13ed967f505b1", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php85\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.5+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php85/tree/v1.38.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-26T02:25:22+00:00" + }, + { + "name": "symfony/polyfill-uuid", + "version": "v1.37.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-uuid.git", + "reference": "26dfec253c4cf3e51b541b52ddf7e42cb0908e94" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-uuid/zipball/26dfec253c4cf3e51b541b52ddf7e42cb0908e94", + "reference": "26dfec253c4cf3e51b541b52ddf7e42cb0908e94", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "provide": { + "ext-uuid": "*" + }, + "suggest": { + "ext-uuid": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Uuid\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Grégoire Pineau", + "email": "lyrixx@lyrixx.info" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for uuid functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "uuid" + ], + "support": { + "source": "https://github.com/symfony/polyfill-uuid/tree/v1.37.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-10T16:19:22+00:00" + }, + { + "name": "symfony/property-access", + "version": "v7.4.8", + "source": { + "type": "git", + "url": "https://github.com/symfony/property-access.git", + "reference": "b7dad9dae8b8a47ef7ecc76c8569e7d8c7d90cfc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/property-access/zipball/b7dad9dae8b8a47ef7ecc76c8569e7d8c7d90cfc", + "reference": "b7dad9dae8b8a47ef7ecc76c8569e7d8c7d90cfc", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/property-info": "^6.4.32|~7.3.10|^7.4.4|^8.0.4" + }, + "require-dev": { + "symfony/cache": "^6.4|^7.0|^8.0", + "symfony/var-exporter": "^6.4.1|^7.0.1|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\PropertyAccess\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides functions to read and write from/to an object or array using a simple string notation", + "homepage": "https://symfony.com", + "keywords": [ + "access", + "array", + "extraction", + "index", + "injection", + "object", + "property", + "property-path", + "reflection" + ], + "support": { + "source": "https://github.com/symfony/property-access/tree/v7.4.8" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-03-24T13:12:05+00:00" + }, + { + "name": "symfony/property-info", + "version": "v7.4.8", + "source": { + "type": "git", + "url": "https://github.com/symfony/property-info.git", + "reference": "ac5e82528b986c4f7cfccbf7764b5d2e824d6175" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/property-info/zipball/ac5e82528b986c4f7cfccbf7764b5d2e824d6175", + "reference": "ac5e82528b986c4f7cfccbf7764b5d2e824d6175", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/string": "^6.4|^7.0|^8.0", + "symfony/type-info": "^7.4.7|^8.0.7" + }, + "conflict": { + "phpdocumentor/reflection-docblock": "<5.2|>=7", + "phpdocumentor/type-resolver": "<1.5.1", + "symfony/cache": "<6.4", + "symfony/dependency-injection": "<6.4", + "symfony/serializer": "<6.4" + }, + "require-dev": { + "phpdocumentor/reflection-docblock": "^5.2|^6.0", + "phpstan/phpdoc-parser": "^1.0|^2.0", + "symfony/cache": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/serializer": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\PropertyInfo\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Kévin Dunglas", + "email": "dunglas@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Extracts information about PHP class' properties using metadata of popular sources", + "homepage": "https://symfony.com", + "keywords": [ + "doctrine", + "phpdoc", + "property", + "symfony", + "type", + "validator" + ], + "support": { + "source": "https://github.com/symfony/property-info/tree/v7.4.8" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-03-24T13:12:05+00:00" + }, + { + "name": "symfony/rate-limiter", + "version": "v7.4.13", + "source": { + "type": "git", + "url": "https://github.com/symfony/rate-limiter.git", + "reference": "8b162768544e5a8895c52161d63c999aca91f4a9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/rate-limiter/zipball/8b162768544e5a8895c52161d63c999aca91f4a9", + "reference": "8b162768544e5a8895c52161d63c999aca91f4a9", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/options-resolver": "^7.3|^8.0" + }, + "require-dev": { + "psr/cache": "^1.0|^2.0|^3.0", + "symfony/lock": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\RateLimiter\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Wouter de Jong", + "email": "wouter@wouterj.nl" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides a Token Bucket implementation to rate limit input and output in your application", + "homepage": "https://symfony.com", + "keywords": [ + "limiter", + "rate-limiter" + ], + "support": { + "source": "https://github.com/symfony/rate-limiter/tree/v7.4.13" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-23T16:05:06+00:00" + }, + { + "name": "symfony/routing", + "version": "v7.4.13", + "source": { + "type": "git", + "url": "https://github.com/symfony/routing.git", + "reference": "3a162171bb008e5e0f15dce6581373a4c0e8390d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/routing/zipball/3a162171bb008e5e0f15dce6581373a4c0e8390d", + "reference": "3a162171bb008e5e0f15dce6581373a4c0e8390d", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "conflict": { + "symfony/config": "<6.4", + "symfony/dependency-injection": "<6.4", + "symfony/yaml": "<6.4" + }, + "require-dev": { + "psr/log": "^1|^2|^3", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/http-foundation": "^6.4|^7.0|^8.0", + "symfony/yaml": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Routing\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Maps an HTTP request to a set of configuration variables", + "homepage": "https://symfony.com", + "keywords": [ + "router", + "routing", + "uri", + "url" + ], + "support": { + "source": "https://github.com/symfony/routing/tree/v7.4.13" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-24T11:20:33+00:00" + }, + { + "name": "symfony/runtime", + "version": "v7.4.13", + "source": { + "type": "git", + "url": "https://github.com/symfony/runtime.git", + "reference": "1a24cf8aab3a9378117718b35525c4126ad3adec" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/runtime/zipball/1a24cf8aab3a9378117718b35525c4126ad3adec", + "reference": "1a24cf8aab3a9378117718b35525c4126ad3adec", + "shasum": "" + }, + "require": { + "composer-plugin-api": "^1.0|^2.0", + "php": ">=8.2" + }, + "conflict": { + "symfony/dotenv": "<6.4" + }, + "require-dev": { + "composer/composer": "^2.6", + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/dotenv": "^6.4|^7.0|^8.0", + "symfony/http-foundation": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0" + }, + "type": "composer-plugin", + "extra": { + "class": "Symfony\\Component\\Runtime\\Internal\\ComposerPlugin" + }, + "autoload": { + "psr-4": { + "Symfony\\Component\\Runtime\\": "", + "Symfony\\Runtime\\Symfony\\Component\\": "Internal/" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Enables decoupling PHP applications from global state", + "homepage": "https://symfony.com", + "keywords": [ + "runtime" + ], + "support": { + "source": "https://github.com/symfony/runtime/tree/v7.4.13" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-23T18:04:28+00:00" + }, + { + "name": "symfony/security-bundle", + "version": "v7.4.13", + "source": { + "type": "git", + "url": "https://github.com/symfony/security-bundle.git", + "reference": "0cbc6528aa583795ab44e43b4e92a09acf927c6f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/security-bundle/zipball/0cbc6528aa583795ab44e43b4e92a09acf927c6f", + "reference": "0cbc6528aa583795ab44e43b4e92a09acf927c6f", + "shasum": "" + }, + "require": { + "composer-runtime-api": ">=2.1", + "ext-xml": "*", + "php": ">=8.2", + "symfony/clock": "^6.4|^7.0|^8.0", + "symfony/config": "^7.4|^8.0", + "symfony/dependency-injection": "^6.4.11|^7.1.4|^8.0", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/event-dispatcher": "^6.4|^7.0|^8.0", + "symfony/http-foundation": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4.13|^7.1.6|^8.0", + "symfony/password-hasher": "^6.4|^7.0|^8.0", + "symfony/security-core": "^7.4|^8.0", + "symfony/security-csrf": "^6.4|^7.0|^8.0", + "symfony/security-http": "^7.4|^8.0", + "symfony/service-contracts": "^2.5|^3" + }, + "conflict": { + "symfony/browser-kit": "<6.4", + "symfony/console": "<6.4", + "symfony/framework-bundle": "<6.4", + "symfony/http-client": "<6.4", + "symfony/ldap": "<6.4", + "symfony/serializer": "<6.4", + "symfony/twig-bundle": "<6.4", + "symfony/validator": "<6.4" + }, + "require-dev": { + "symfony/asset": "^6.4|^7.0|^8.0", + "symfony/browser-kit": "^6.4|^7.0|^8.0", + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/css-selector": "^6.4|^7.0|^8.0", + "symfony/dom-crawler": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/form": "^6.4|^7.0|^8.0", + "symfony/framework-bundle": "^6.4.13|^7.1.6|^8.0", + "symfony/http-client": "^6.4|^7.0|^8.0", + "symfony/ldap": "^6.4|^7.0|^8.0", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/rate-limiter": "^6.4|^7.0|^8.0", + "symfony/runtime": "^6.4.13|^7.1.6|^8.0", + "symfony/serializer": "^6.4|^7.0|^8.0", + "symfony/translation": "^6.4|^7.0|^8.0", + "symfony/twig-bridge": "^6.4|^7.0|^8.0", + "symfony/twig-bundle": "^6.4|^7.0|^8.0", + "symfony/validator": "^6.4|^7.0|^8.0", + "symfony/yaml": "^6.4|^7.0|^8.0", + "twig/twig": "^3.15", + "web-token/jwt-library": "^3.3.2|^4.0" + }, + "type": "symfony-bundle", + "autoload": { + "psr-4": { + "Symfony\\Bundle\\SecurityBundle\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides a tight integration of the Security component into the Symfony full-stack framework", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/security-bundle/tree/v7.4.13" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-23T16:05:06+00:00" + }, + { + "name": "symfony/security-core", + "version": "v7.4.13", + "source": { + "type": "git", + "url": "https://github.com/symfony/security-core.git", + "reference": "25db686fcf2a3fe00e1cf6dcab1fcb7aac71ba9b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/security-core/zipball/25db686fcf2a3fe00e1cf6dcab1fcb7aac71ba9b", + "reference": "25db686fcf2a3fe00e1cf6dcab1fcb7aac71ba9b", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/event-dispatcher-contracts": "^2.5|^3", + "symfony/password-hasher": "^6.4|^7.0|^8.0", + "symfony/service-contracts": "^2.5|^3" + }, + "conflict": { + "symfony/dependency-injection": "<6.4", + "symfony/event-dispatcher": "<6.4", + "symfony/http-foundation": "<6.4", + "symfony/ldap": "<6.4", + "symfony/translation": "<6.4.3|>=7.0,<7.0.3", + "symfony/validator": "<6.4" + }, + "require-dev": { + "psr/cache": "^1.0|^2.0|^3.0", + "psr/container": "^1.1|^2.0", + "psr/log": "^1|^2|^3", + "symfony/cache": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/event-dispatcher": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/http-foundation": "^6.4|^7.0|^8.0", + "symfony/ldap": "^6.4|^7.0|^8.0", + "symfony/string": "^6.4|^7.0|^8.0", + "symfony/translation": "^6.4.3|^7.0.3|^8.0", + "symfony/validator": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Security\\Core\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony Security Component - Core Library", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/security-core/tree/v7.4.13" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-23T16:05:06+00:00" + }, + { + "name": "symfony/security-csrf", + "version": "v7.4.8", + "source": { + "type": "git", + "url": "https://github.com/symfony/security-csrf.git", + "reference": "16b3aa2f67d02fb0dbd013a8759bbe90daaa9c5d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/security-csrf/zipball/16b3aa2f67d02fb0dbd013a8759bbe90daaa9c5d", + "reference": "16b3aa2f67d02fb0dbd013a8759bbe90daaa9c5d", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/security-core": "^6.4|^7.0|^8.0" + }, + "conflict": { + "symfony/http-foundation": "<6.4" + }, + "require-dev": { + "psr/log": "^1|^2|^3", + "symfony/http-foundation": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Security\\Csrf\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony Security Component - CSRF Library", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/security-csrf/tree/v7.4.8" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-03-24T13:12:05+00:00" + }, + { + "name": "symfony/security-http", + "version": "v7.4.13", + "source": { + "type": "git", + "url": "https://github.com/symfony/security-http.git", + "reference": "da3c28025a664e6a88e1af104a74457d99301161" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/security-http/zipball/da3c28025a664e6a88e1af104a74457d99301161", + "reference": "da3c28025a664e6a88e1af104a74457d99301161", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/event-dispatcher": "^6.4|^7.0", + "symfony/http-foundation": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/polyfill-mbstring": "~1.0", + "symfony/property-access": "^6.4|^7.0|^8.0", + "symfony/security-core": "^7.3|^8.0", + "symfony/service-contracts": "^2.5|^3" + }, + "conflict": { + "symfony/clock": "<6.4", + "symfony/http-client-contracts": "<3.0", + "symfony/security-bundle": "<6.4", + "symfony/security-csrf": "<6.4" + }, + "require-dev": { + "psr/log": "^1|^2|^3", + "symfony/cache": "^6.4|^7.0|^8.0", + "symfony/clock": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/http-client": "^6.4|^7.0|^8.0", + "symfony/http-client-contracts": "^3.0", + "symfony/rate-limiter": "^6.4|^7.0|^8.0", + "symfony/routing": "^6.4|^7.0|^8.0", + "symfony/security-csrf": "^6.4|^7.0|^8.0", + "symfony/translation": "^6.4|^7.0|^8.0", + "web-token/jwt-library": "^3.3.2|^4.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Security\\Http\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony Security Component - HTTP Integration", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/security-http/tree/v7.4.13" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-25T06:06:12+00:00" + }, + { + "name": "symfony/serializer", + "version": "v7.4.10", + "source": { + "type": "git", + "url": "https://github.com/symfony/serializer.git", + "reference": "268c5aa6c4bd675eddd89348e7ecac292a843ddd" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/serializer/zipball/268c5aa6c4bd675eddd89348e7ecac292a843ddd", + "reference": "268c5aa6c4bd675eddd89348e7ecac292a843ddd", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-ctype": "~1.8", + "symfony/polyfill-php84": "^1.30" + }, + "conflict": { + "phpdocumentor/reflection-docblock": "<5.2|>=7", + "phpdocumentor/type-resolver": "<1.5.1", + "symfony/dependency-injection": "<6.4", + "symfony/property-access": "<6.4.31|>=7.0,<7.4.2|>=8.0,<8.0.2", + "symfony/property-info": "<6.4", + "symfony/type-info": "<7.2.5", + "symfony/uid": "<6.4", + "symfony/validator": "<6.4", + "symfony/yaml": "<6.4" + }, + "require-dev": { + "phpdocumentor/reflection-docblock": "^5.2|^6.0", + "phpstan/phpdoc-parser": "^1.0|^2.0", + "seld/jsonlint": "^1.10", + "symfony/cache": "^6.4|^7.0|^8.0", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^7.2|^8.0", + "symfony/error-handler": "^6.4|^7.0|^8.0", + "symfony/filesystem": "^6.4|^7.0|^8.0", + "symfony/form": "^6.4|^7.0|^8.0", + "symfony/http-foundation": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/messenger": "^6.4|^7.0|^8.0", + "symfony/mime": "^6.4|^7.0|^8.0", + "symfony/property-access": "^6.4.31|^7.4.2|^8.0.2", + "symfony/property-info": "^6.4|^7.0|^8.0", + "symfony/translation-contracts": "^2.5|^3", + "symfony/type-info": "^7.2.5|^8.0", + "symfony/uid": "^6.4|^7.0|^8.0", + "symfony/validator": "^6.4|^7.0|^8.0", + "symfony/var-dumper": "^6.4|^7.0|^8.0", + "symfony/var-exporter": "^6.4|^7.0|^8.0", + "symfony/yaml": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Serializer\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Handles serializing and deserializing data structures, including object graphs, into array structures or other formats like XML and JSON.", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/serializer/tree/v7.4.10" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-03T13:03:28+00:00" + }, + { + "name": "symfony/service-contracts", + "version": "v3.7.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/service-contracts.git", + "reference": "d25d82433a80eba6aa0e6c24b61d7370d99e444a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/d25d82433a80eba6aa0e6c24b61d7370d99e444a", + "reference": "d25d82433a80eba6aa0e6c24b61d7370d99e444a", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/container": "^1.1|^2.0", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "conflict": { + "ext-psr": "<1.1|>=2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\Service\\": "" + }, + "exclude-from-classmap": [ + "/Test/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to writing services", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/service-contracts/tree/v3.7.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-03-28T09:44:51+00:00" + }, + { + "name": "symfony/stopwatch", + "version": "v7.4.8", + "source": { + "type": "git", + "url": "https://github.com/symfony/stopwatch.git", + "reference": "70a852d72fec4d51efb1f48dcd968efcaf5ccb89" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/stopwatch/zipball/70a852d72fec4d51efb1f48dcd968efcaf5ccb89", + "reference": "70a852d72fec4d51efb1f48dcd968efcaf5ccb89", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/service-contracts": "^2.5|^3" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Stopwatch\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides a way to profile code", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/stopwatch/tree/v7.4.8" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-03-24T13:12:05+00:00" + }, + { + "name": "symfony/string", + "version": "v7.4.13", + "source": { + "type": "git", + "url": "https://github.com/symfony/string.git", + "reference": "961683010db3b27ec6ebcd7308e6e1ee8fa7ffde" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/string/zipball/961683010db3b27ec6ebcd7308e6e1ee8fa7ffde", + "reference": "961683010db3b27ec6ebcd7308e6e1ee8fa7ffde", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3.0", + "symfony/polyfill-ctype": "~1.8", + "symfony/polyfill-intl-grapheme": "~1.33", + "symfony/polyfill-intl-normalizer": "~1.0", + "symfony/polyfill-mbstring": "~1.0" + }, + "conflict": { + "symfony/translation-contracts": "<2.5" + }, + "require-dev": { + "symfony/emoji": "^7.1|^8.0", + "symfony/http-client": "^6.4|^7.0|^8.0", + "symfony/intl": "^6.4|^7.0|^8.0", + "symfony/translation-contracts": "^2.5|^3.0", + "symfony/var-exporter": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "files": [ + "Resources/functions.php" + ], + "psr-4": { + "Symfony\\Component\\String\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides an object-oriented API to strings and deals with bytes, UTF-8 code points and grapheme clusters in a unified way", + "homepage": "https://symfony.com", + "keywords": [ + "grapheme", + "i18n", + "string", + "unicode", + "utf-8", + "utf8" + ], + "support": { + "source": "https://github.com/symfony/string/tree/v7.4.13" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-23T15:23:29+00:00" + }, + { + "name": "symfony/translation-contracts", + "version": "v3.7.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/translation-contracts.git", + "reference": "0ab302977a952b42fd51475c4ebac81f8da0a95d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/translation-contracts/zipball/0ab302977a952b42fd51475c4ebac81f8da0a95d", + "reference": "0ab302977a952b42fd51475c4ebac81f8da0a95d", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\Translation\\": "" + }, + "exclude-from-classmap": [ + "/Test/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to translation", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/translation-contracts/tree/v3.7.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-01-05T13:30:16+00:00" + }, + { + "name": "symfony/type-info", + "version": "v7.4.9", + "source": { + "type": "git", + "url": "https://github.com/symfony/type-info.git", + "reference": "cafeedbf157b890e94ac5b83eaed85595106d5d6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/type-info/zipball/cafeedbf157b890e94ac5b83eaed85595106d5d6", + "reference": "cafeedbf157b890e94ac5b83eaed85595106d5d6", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "psr/container": "^1.1|^2.0", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "conflict": { + "phpstan/phpdoc-parser": "<1.30" + }, + "require-dev": { + "phpstan/phpdoc-parser": "^1.30|^2.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\TypeInfo\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mathias Arlaud", + "email": "mathias.arlaud@gmail.com" + }, + { + "name": "Baptiste LEDUC", + "email": "baptiste.leduc@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Extracts PHP types information.", + "homepage": "https://symfony.com", + "keywords": [ + "PHPStan", + "phpdoc", + "symfony", + "type" + ], + "support": { + "source": "https://github.com/symfony/type-info/tree/v7.4.9" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-22T15:21:55+00:00" + }, + { + "name": "symfony/uid", + "version": "v7.4.9", + "source": { + "type": "git", + "url": "https://github.com/symfony/uid.git", + "reference": "2676b524340abcfe4d6151ec698463cebafee439" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/uid/zipball/2676b524340abcfe4d6151ec698463cebafee439", + "reference": "2676b524340abcfe4d6151ec698463cebafee439", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/polyfill-uuid": "^1.15" + }, + "require-dev": { + "symfony/console": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Uid\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Grégoire Pineau", + "email": "lyrixx@lyrixx.info" + }, + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides an object-oriented API to generate and represent UIDs", + "homepage": "https://symfony.com", + "keywords": [ + "UID", + "ulid", + "uuid" + ], + "support": { + "source": "https://github.com/symfony/uid/tree/v7.4.9" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-30T15:19:22+00:00" + }, + { + "name": "symfony/validator", + "version": "v7.4.10", + "source": { + "type": "git", + "url": "https://github.com/symfony/validator.git", + "reference": "c76458623af9a3fe3b2e5b09b36453f334c2a361" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/validator/zipball/c76458623af9a3fe3b2e5b09b36453f334c2a361", + "reference": "c76458623af9a3fe3b2e5b09b36453f334c2a361", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-ctype": "~1.8", + "symfony/polyfill-mbstring": "~1.0", + "symfony/polyfill-php83": "^1.27", + "symfony/translation-contracts": "^2.5|^3" + }, + "conflict": { + "doctrine/lexer": "<1.1", + "symfony/dependency-injection": "<6.4", + "symfony/doctrine-bridge": "<7.0", + "symfony/expression-language": "<6.4", + "symfony/http-kernel": "<6.4", + "symfony/intl": "<6.4", + "symfony/property-info": "<6.4", + "symfony/translation": "<6.4.3|>=7.0,<7.0.3", + "symfony/var-exporter": "<6.4.25|>=7.0,<7.3.3", + "symfony/yaml": "<6.4" + }, + "require-dev": { + "egulias/email-validator": "^2.1.10|^3|^4", + "symfony/cache": "^6.4|^7.0|^8.0", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/finder": "^6.4|^7.0|^8.0", + "symfony/http-client": "^6.4|^7.0|^8.0", + "symfony/http-foundation": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/intl": "^6.4|^7.0|^8.0", + "symfony/mime": "^6.4|^7.0|^8.0", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/property-access": "^6.4|^7.0|^8.0", + "symfony/property-info": "^6.4|^7.0|^8.0", + "symfony/string": "^6.4|^7.0|^8.0", + "symfony/translation": "^6.4.3|^7.0.3|^8.0", + "symfony/type-info": "^7.1.8", + "symfony/yaml": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Validator\\": "" + }, + "exclude-from-classmap": [ + "/Tests/", + "/Resources/bin/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides tools to validate values", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/validator/tree/v7.4.10" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-05T15:30:56+00:00" + }, + { + "name": "symfony/var-dumper", + "version": "v7.4.8", + "source": { + "type": "git", + "url": "https://github.com/symfony/var-dumper.git", + "reference": "9510c3966f749a1d1ff0059e1eabef6cc621e7fd" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/9510c3966f749a1d1ff0059e1eabef6cc621e7fd", + "reference": "9510c3966f749a1d1ff0059e1eabef6cc621e7fd", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-mbstring": "~1.0" + }, + "conflict": { + "symfony/console": "<6.4" + }, + "require-dev": { + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/uid": "^6.4|^7.0|^8.0", + "twig/twig": "^3.12" + }, + "bin": [ + "Resources/bin/var-dump-server" + ], + "type": "library", + "autoload": { + "files": [ + "Resources/functions/dump.php" + ], + "psr-4": { + "Symfony\\Component\\VarDumper\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides mechanisms for walking through any arbitrary PHP variable", + "homepage": "https://symfony.com", + "keywords": [ + "debug", + "dump" + ], + "support": { + "source": "https://github.com/symfony/var-dumper/tree/v7.4.8" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-03-30T13:44:50+00:00" + }, + { + "name": "symfony/var-exporter", + "version": "v7.4.9", + "source": { + "type": "git", + "url": "https://github.com/symfony/var-exporter.git", + "reference": "22e03a49c95ef054a43601cd159b222bfab1c701" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/var-exporter/zipball/22e03a49c95ef054a43601cd159b222bfab1c701", + "reference": "22e03a49c95ef054a43601cd159b222bfab1c701", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "require-dev": { + "symfony/property-access": "^6.4|^7.0|^8.0", + "symfony/serializer": "^6.4|^7.0|^8.0", + "symfony/var-dumper": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\VarExporter\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Allows exporting any serializable PHP data structure to plain PHP code", + "homepage": "https://symfony.com", + "keywords": [ + "clone", + "construct", + "export", + "hydrate", + "instantiate", + "lazy-loading", + "proxy", + "serialize" + ], + "support": { + "source": "https://github.com/symfony/var-exporter/tree/v7.4.9" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-18T13:18:21+00:00" + }, + { + "name": "symfony/yaml", + "version": "v7.4.13", + "source": { + "type": "git", + "url": "https://github.com/symfony/yaml.git", + "reference": "a7ec3b1156faf8815db7683ec7c1e7338e6f977c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/yaml/zipball/a7ec3b1156faf8815db7683ec7c1e7338e6f977c", + "reference": "a7ec3b1156faf8815db7683ec7c1e7338e6f977c", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-ctype": "^1.8" + }, + "conflict": { + "symfony/console": "<6.4" + }, + "require-dev": { + "symfony/console": "^6.4|^7.0|^8.0" + }, + "bin": [ + "Resources/bin/yaml-lint" + ], + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Yaml\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Loads and dumps YAML files", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/yaml/tree/v7.4.13" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-25T06:06:12+00:00" + }, + { + "name": "twig/twig", + "version": "v3.27.1", + "source": { + "type": "git", + "url": "https://github.com/twigphp/Twig.git", + "reference": "ae2071bffb38f04847fc0864d730c94b9cb8ab74" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/twigphp/Twig/zipball/ae2071bffb38f04847fc0864d730c94b9cb8ab74", + "reference": "ae2071bffb38f04847fc0864d730c94b9cb8ab74", + "shasum": "" + }, + "require": { + "php": ">=8.1.0", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-ctype": "^1.8", + "symfony/polyfill-mbstring": "^1.3" + }, + "require-dev": { + "php-cs-fixer/shim": "^3.0@stable", + "phpstan/phpstan": "^2.0@stable", + "psr/container": "^1.0|^2.0", + "symfony/phpunit-bridge": "^5.4.9|^6.4|^7.0" + }, + "type": "library", + "autoload": { + "files": [ + "src/Resources/core.php", + "src/Resources/debug.php", + "src/Resources/escaper.php", + "src/Resources/string_loader.php" + ], + "psr-4": { + "Twig\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com", + "homepage": "http://fabien.potencier.org", + "role": "Lead Developer" + }, + { + "name": "Twig Team", + "role": "Contributors" + }, + { + "name": "Armin Ronacher", + "email": "armin.ronacher@active-4.com", + "role": "Project Founder" + } + ], + "description": "Twig, the flexible, fast, and secure template language for PHP", + "homepage": "https://twig.symfony.com", + "keywords": [ + "templating" + ], + "support": { + "issues": "https://github.com/twigphp/Twig/issues", + "source": "https://github.com/twigphp/Twig/tree/v3.27.1" + }, + "funding": [ + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/twig/twig", + "type": "tidelift" + } + ], + "time": "2026-05-30T17:09:26+00:00" + }, + { + "name": "webmozart/assert", + "version": "2.4.0", + "source": { + "type": "git", + "url": "https://github.com/webmozarts/assert.git", + "reference": "9007ea6f45ecf352a9422b36644e4bfc039b9155" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/webmozarts/assert/zipball/9007ea6f45ecf352a9422b36644e4bfc039b9155", + "reference": "9007ea6f45ecf352a9422b36644e4bfc039b9155", + "shasum": "" + }, + "require": { + "ext-ctype": "*", + "ext-date": "*", + "ext-filter": "*", + "php": "^8.2" + }, + "suggest": { + "ext-intl": "", + "ext-simplexml": "", + "ext-spl": "" + }, + "type": "library", + "extra": { + "psalm": { + "pluginClass": "Webmozart\\Assert\\PsalmPlugin" + }, + "branch-alias": { + "dev-master": "2.0-dev", + "dev-feature/2-0": "2.0-dev" + } + }, + "autoload": { + "psr-4": { + "Webmozart\\Assert\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Bernhard Schussek", + "email": "bschussek@gmail.com" + }, + { + "name": "Woody Gilk", + "email": "woody.gilk@gmail.com" + } + ], + "description": "Assertions to validate method input/output with nice error messages.", + "keywords": [ + "assert", + "check", + "validate" + ], + "support": { + "issues": "https://github.com/webmozarts/assert/issues", + "source": "https://github.com/webmozarts/assert/tree/2.4.0" + }, + "time": "2026-05-20T13:07:01+00:00" + }, + { + "name": "zircote/swagger-php", + "version": "6.1.2", + "source": { + "type": "git", + "url": "https://github.com/zircote/swagger-php.git", + "reference": "f66289ab9c9c3a1cf70222e0bebbe7c6c7109f2f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/zircote/swagger-php/zipball/f66289ab9c9c3a1cf70222e0bebbe7c6c7109f2f", + "reference": "f66289ab9c9c3a1cf70222e0bebbe7c6c7109f2f", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^4.19 || ^5.0", + "php": ">=8.2", + "phpstan/phpdoc-parser": "^2.0", + "psr/log": "^1.1 || ^2.0 || ^3.0", + "radebatz/type-info-extras": "^1.0.2", + "symfony/console": "^7.4 || ^8.0", + "symfony/deprecation-contracts": "^2 || ^3", + "symfony/finder": "^5.0 || ^6.0 || ^7.0 || ^8.0", + "symfony/yaml": "^5.4 || ^6.0 || ^7.0 || ^8.0" + }, + "conflict": { + "symfony/process": ">=6, <6.4.14" + }, + "require-dev": { + "composer/package-versions-deprecated": "^1.11", + "doctrine/annotations": "^2.0", + "friendsofphp/php-cs-fixer": "^3.62.0", + "phpstan/phpstan": "^2.0", + "phpunit/phpunit": "^11.5 || >=12.5.22", + "rector/rector": "^2.3.1" + }, + "bin": [ + "bin/openapi" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "6.x-dev" + } + }, + "autoload": { + "psr-4": { + "OpenApi\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "Robert Allen", + "email": "zircote@gmail.com" + }, + { + "name": "Bob Fanger", + "email": "bfanger@gmail.com", + "homepage": "https://bfanger.nl" + }, + { + "name": "Martin Rademacher", + "email": "mano@radebatz.net", + "homepage": "https://radebatz.net" + } + ], + "description": "Generate interactive documentation for your RESTful API using PHP attributes (preferred) or PHPDoc annotations", + "homepage": "https://github.com/zircote/swagger-php", + "keywords": [ + "api", + "json", + "rest", + "service discovery" + ], + "support": { + "issues": "https://github.com/zircote/swagger-php/issues", + "source": "https://github.com/zircote/swagger-php/tree/6.1.2" + }, + "funding": [ + { + "url": "https://github.com/zircote", + "type": "github" + } + ], + "time": "2026-04-28T04:47:53+00:00" + } + ], + "packages-dev": [ + { + "name": "masterminds/html5", + "version": "2.10.0", + "source": { + "type": "git", + "url": "https://github.com/Masterminds/html5-php.git", + "reference": "fcf91eb64359852f00d921887b219479b4f21251" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Masterminds/html5-php/zipball/fcf91eb64359852f00d921887b219479b4f21251", + "reference": "fcf91eb64359852f00d921887b219479b4f21251", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "php": ">=5.3.0" + }, + "require-dev": { + "phpunit/phpunit": "^4.8.35 || ^5.7.21 || ^6 || ^7 || ^8 || ^9" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.7-dev" + } + }, + "autoload": { + "psr-4": { + "Masterminds\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Matt Butcher", + "email": "technosophos@gmail.com" + }, + { + "name": "Matt Farina", + "email": "matt@mattfarina.com" + }, + { + "name": "Asmir Mustafic", + "email": "goetas@gmail.com" + } + ], + "description": "An HTML5 parser and serializer.", + "homepage": "http://masterminds.github.io/html5-php", + "keywords": [ + "HTML5", + "dom", + "html", + "parser", + "querypath", + "serializer", + "xml" + ], + "support": { + "issues": "https://github.com/Masterminds/html5-php/issues", + "source": "https://github.com/Masterminds/html5-php/tree/2.10.0" + }, + "time": "2025-07-25T09:04:22+00:00" + }, + { + "name": "myclabs/deep-copy", + "version": "1.13.4", + "source": { + "type": "git", + "url": "https://github.com/myclabs/DeepCopy.git", + "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/07d290f0c47959fd5eed98c95ee5602db07e0b6a", + "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "conflict": { + "doctrine/collections": "<1.6.8", + "doctrine/common": "<2.13.3 || >=3 <3.2.2" + }, + "require-dev": { + "doctrine/collections": "^1.6.8", + "doctrine/common": "^2.13.3 || ^3.2.2", + "phpspec/prophecy": "^1.10", + "phpunit/phpunit": "^7.5.20 || ^8.5.23 || ^9.5.13" + }, + "type": "library", + "autoload": { + "files": [ + "src/DeepCopy/deep_copy.php" + ], + "psr-4": { + "DeepCopy\\": "src/DeepCopy/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Create deep copies (clones) of your objects", + "keywords": [ + "clone", + "copy", + "duplicate", + "object", + "object graph" + ], + "support": { + "issues": "https://github.com/myclabs/DeepCopy/issues", + "source": "https://github.com/myclabs/DeepCopy/tree/1.13.4" + }, + "funding": [ + { + "url": "https://tidelift.com/funding/github/packagist/myclabs/deep-copy", + "type": "tidelift" + } + ], + "time": "2025-08-01T08:46:24+00:00" + }, + { + "name": "phar-io/manifest", + "version": "2.0.4", + "source": { + "type": "git", + "url": "https://github.com/phar-io/manifest.git", + "reference": "54750ef60c58e43759730615a392c31c80e23176" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/manifest/zipball/54750ef60c58e43759730615a392c31c80e23176", + "reference": "54750ef60c58e43759730615a392c31c80e23176", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-phar": "*", + "ext-xmlwriter": "*", + "phar-io/version": "^3.0.1", + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Component for reading phar.io manifest information from a PHP Archive (PHAR)", + "support": { + "issues": "https://github.com/phar-io/manifest/issues", + "source": "https://github.com/phar-io/manifest/tree/2.0.4" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2024-03-03T12:33:53+00:00" + }, + { + "name": "phar-io/version", + "version": "3.2.1", + "source": { + "type": "git", + "url": "https://github.com/phar-io/version.git", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/version/zipball/4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Library for handling version information and constraints", + "support": { + "issues": "https://github.com/phar-io/version/issues", + "source": "https://github.com/phar-io/version/tree/3.2.1" + }, + "time": "2022-02-21T01:04:05+00:00" + }, + { + "name": "phpstan/phpstan", + "version": "2.2.2", + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/e5cc34d491a90e79c216d824f60fe21fd4d93bd6", + "reference": "e5cc34d491a90e79c216d824f60fe21fd4d93bd6", + "shasum": "" + }, + "require": { + "php": "^7.4|^8.0" + }, + "conflict": { + "phpstan/phpstan-shim": "*" + }, + "bin": [ + "phpstan", + "phpstan.phar" + ], + "type": "library", + "autoload": { + "files": [ + "bootstrap.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ondřej Mirtes" + }, + { + "name": "Markus Staab" + }, + { + "name": "Vincent Langlet" + } + ], + "description": "PHPStan - PHP Static Analysis Tool", + "keywords": [ + "dev", + "static analysis" + ], + "support": { + "docs": "https://phpstan.org/user-guide/getting-started", + "forum": "https://github.com/phpstan/phpstan/discussions", + "issues": "https://github.com/phpstan/phpstan/issues", + "security": "https://github.com/phpstan/phpstan/security/policy", + "source": "https://github.com/phpstan/phpstan-src" + }, + "funding": [ + { + "url": "https://github.com/ondrejmirtes", + "type": "github" + }, + { + "url": "https://github.com/phpstan", + "type": "github" + } + ], + "time": "2026-06-05T09:00:01+00:00" + }, + { + "name": "phpstan/phpstan-doctrine", + "version": "2.0.25", + "source": { + "type": "git", + "url": "https://github.com/phpstan/phpstan-doctrine.git", + "reference": "e20e8bf3223ae6eba9c4b5987c391d922e094b3c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/phpstan-doctrine/zipball/e20e8bf3223ae6eba9c4b5987c391d922e094b3c", + "reference": "e20e8bf3223ae6eba9c4b5987c391d922e094b3c", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0", + "phpstan/phpstan": "^2.1.34" + }, + "conflict": { + "doctrine/collections": "<1.0", + "doctrine/common": "<2.7", + "doctrine/mongodb-odm": "<1.2", + "doctrine/orm": "<2.5", + "doctrine/persistence": "<1.3" + }, + "require-dev": { + "cache/array-adapter": "^1.1", + "composer/semver": "^3.3.2", + "cweagans/composer-patches": "^1.7.3", + "doctrine/annotations": "^2.0", + "doctrine/collections": "^1.6 || ^2.1", + "doctrine/common": "^2.7 || ^3.0", + "doctrine/dbal": "^3.3.8", + "doctrine/lexer": "^2.0 || ^3.0", + "doctrine/mongodb-odm": "^2.4.3", + "doctrine/orm": "^2.16.0", + "doctrine/persistence": "^2.2.1 || ^3.4.3", + "gedmo/doctrine-extensions": "^3.8", + "nesbot/carbon": "^2.49", + "php-parallel-lint/php-parallel-lint": "^1.2", + "phpstan/phpstan-deprecation-rules": "^2.0.2", + "phpstan/phpstan-phpunit": "^2.0.8", + "phpstan/phpstan-strict-rules": "^2.0", + "phpunit/phpunit": "^9.6.20", + "ramsey/uuid": "^4.2", + "shipmonk/name-collision-detector": "^2.1", + "symfony/cache": "^5.4", + "symfony/uid": "^5.4 || ^6.4 || ^7.3" + }, + "type": "phpstan-extension", + "extra": { + "phpstan": { + "includes": [ + "extension.neon", + "rules.neon" + ] + } + }, + "autoload": { + "psr-4": { + "PHPStan\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Doctrine extensions for PHPStan", + "keywords": [ + "static analysis" + ], + "support": { + "issues": "https://github.com/phpstan/phpstan-doctrine/issues", + "source": "https://github.com/phpstan/phpstan-doctrine/tree/2.0.25" + }, + "time": "2026-06-02T20:27:36+00:00" + }, + { + "name": "phpstan/phpstan-symfony", + "version": "2.0.19", + "source": { + "type": "git", + "url": "https://github.com/phpstan/phpstan-symfony.git", + "reference": "546071ed7f80a89ec30909346eb7cc741800740a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/phpstan-symfony/zipball/546071ed7f80a89ec30909346eb7cc741800740a", + "reference": "546071ed7f80a89ec30909346eb7cc741800740a", + "shasum": "" + }, + "require": { + "ext-simplexml": "*", + "php": "^7.4 || ^8.0", + "phpstan/phpstan": "^2.1.13" + }, + "conflict": { + "symfony/framework-bundle": "<3.0" + }, + "require-dev": { + "php-parallel-lint/php-parallel-lint": "^1.2", + "phpstan/phpstan-phpunit": "^2.0.8", + "phpstan/phpstan-strict-rules": "^2.0", + "phpunit/phpunit": "^9.6", + "psr/container": "1.1.2", + "symfony/config": "^5.4 || ^6.1", + "symfony/console": "^5.4 || ^6.1", + "symfony/dependency-injection": "^5.4 || ^6.1", + "symfony/form": "^5.4 || ^6.1", + "symfony/framework-bundle": "^5.4 || ^6.1", + "symfony/http-foundation": "^5.4 || ^6.1", + "symfony/messenger": "^5.4", + "symfony/polyfill-php80": "^1.24", + "symfony/serializer": "^5.4", + "symfony/service-contracts": "^2.2.0" + }, + "type": "phpstan-extension", + "extra": { + "phpstan": { + "includes": [ + "extension.neon", + "rules.neon" + ] + } + }, + "autoload": { + "psr-4": { + "PHPStan\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Lukáš Unger", + "email": "looky.msc@gmail.com", + "homepage": "https://lookyman.net" + } + ], + "description": "Symfony Framework extensions and rules for PHPStan", + "keywords": [ + "static analysis" + ], + "support": { + "issues": "https://github.com/phpstan/phpstan-symfony/issues", + "source": "https://github.com/phpstan/phpstan-symfony/tree/2.0.19" + }, + "time": "2026-05-29T12:52:44+00:00" + }, + { + "name": "phpunit/php-code-coverage", + "version": "12.5.7", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-code-coverage.git", + "reference": "186dab580576598076de6818596d12b61801880e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/186dab580576598076de6818596d12b61801880e", + "reference": "186dab580576598076de6818596d12b61801880e", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-xmlwriter": "*", + "nikic/php-parser": "^5.7.0", + "php": ">=8.3", + "phpunit/php-text-template": "^5.0", + "sebastian/complexity": "^5.0", + "sebastian/environment": "^8.1.2", + "sebastian/lines-of-code": "^4.0.1", + "sebastian/version": "^6.0", + "theseer/tokenizer": "^2.0.1" + }, + "require-dev": { + "phpunit/phpunit": "^12.5.28" + }, + "suggest": { + "ext-pcov": "PHP extension that provides line coverage", + "ext-xdebug": "PHP extension that provides line coverage as well as branch and path coverage" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "12.5.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that provides collection, processing, and rendering functionality for PHP code coverage information.", + "homepage": "https://github.com/sebastianbergmann/php-code-coverage", + "keywords": [ + "coverage", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", + "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/12.5.7" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-code-coverage", + "type": "tidelift" + } + ], + "time": "2026-06-01T13:24:19+00:00" + }, + { + "name": "phpunit/php-file-iterator", + "version": "6.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-file-iterator.git", + "reference": "3d1cd096ef6bea4bf2762ba586e35dbd317cbfd5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/3d1cd096ef6bea4bf2762ba586e35dbd317cbfd5", + "reference": "3d1cd096ef6bea4bf2762ba586e35dbd317cbfd5", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "FilterIterator implementation that filters files based on a list of suffixes.", + "homepage": "https://github.com/sebastianbergmann/php-file-iterator/", + "keywords": [ + "filesystem", + "iterator" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues", + "security": "https://github.com/sebastianbergmann/php-file-iterator/security/policy", + "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/6.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-file-iterator", + "type": "tidelift" + } + ], + "time": "2026-02-02T14:04:18+00:00" + }, + { + "name": "phpunit/php-invoker", + "version": "6.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-invoker.git", + "reference": "12b54e689b07a25a9b41e57736dfab6ec9ae5406" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/12b54e689b07a25a9b41e57736dfab6ec9ae5406", + "reference": "12b54e689b07a25a9b41e57736dfab6ec9ae5406", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "ext-pcntl": "*", + "phpunit/phpunit": "^12.0" + }, + "suggest": { + "ext-pcntl": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Invoke callables with a timeout", + "homepage": "https://github.com/sebastianbergmann/php-invoker/", + "keywords": [ + "process" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-invoker/issues", + "security": "https://github.com/sebastianbergmann/php-invoker/security/policy", + "source": "https://github.com/sebastianbergmann/php-invoker/tree/6.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-02-07T04:58:58+00:00" + }, + { + "name": "phpunit/php-text-template", + "version": "5.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-text-template.git", + "reference": "e1367a453f0eda562eedb4f659e13aa900d66c53" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/e1367a453f0eda562eedb4f659e13aa900d66c53", + "reference": "e1367a453f0eda562eedb4f659e13aa900d66c53", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Simple template engine.", + "homepage": "https://github.com/sebastianbergmann/php-text-template/", + "keywords": [ + "template" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-text-template/issues", + "security": "https://github.com/sebastianbergmann/php-text-template/security/policy", + "source": "https://github.com/sebastianbergmann/php-text-template/tree/5.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-02-07T04:59:16+00:00" + }, + { + "name": "phpunit/php-timer", + "version": "8.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-timer.git", + "reference": "f258ce36aa457f3aa3339f9ed4c81fc66dc8c2cc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/f258ce36aa457f3aa3339f9ed4c81fc66dc8c2cc", + "reference": "f258ce36aa457f3aa3339f9ed4c81fc66dc8c2cc", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "8.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Utility class for timing", + "homepage": "https://github.com/sebastianbergmann/php-timer/", + "keywords": [ + "timer" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-timer/issues", + "security": "https://github.com/sebastianbergmann/php-timer/security/policy", + "source": "https://github.com/sebastianbergmann/php-timer/tree/8.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-02-07T04:59:38+00:00" + }, + { + "name": "phpunit/phpunit", + "version": "12.5.29", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/phpunit.git", + "reference": "9aa66a47db3ea70f1a468e66dd969f67e594945a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/9aa66a47db3ea70f1a468e66dd969f67e594945a", + "reference": "9aa66a47db3ea70f1a468e66dd969f67e594945a", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-json": "*", + "ext-libxml": "*", + "ext-mbstring": "*", + "ext-xml": "*", + "ext-xmlwriter": "*", + "myclabs/deep-copy": "^1.13.4", + "phar-io/manifest": "^2.0.4", + "phar-io/version": "^3.2.1", + "php": ">=8.3", + "phpunit/php-code-coverage": "^12.5.7", + "phpunit/php-file-iterator": "^6.0.1", + "phpunit/php-invoker": "^6.0.0", + "phpunit/php-text-template": "^5.0.0", + "phpunit/php-timer": "^8.0.0", + "sebastian/cli-parser": "^4.2.1", + "sebastian/comparator": "^7.1.8", + "sebastian/diff": "^7.0.0", + "sebastian/environment": "^8.1.2", + "sebastian/exporter": "^7.0.3", + "sebastian/global-state": "^8.0.3", + "sebastian/object-enumerator": "^7.0.0", + "sebastian/recursion-context": "^7.0.1", + "sebastian/type": "^6.0.4", + "sebastian/version": "^6.0.0", + "staabm/side-effects-detector": "^1.0.5" + }, + "bin": [ + "phpunit" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "12.5-dev" + } + }, + "autoload": { + "files": [ + "src/Framework/Assert/Functions.php" + ], + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "The PHP Unit Testing framework.", + "homepage": "https://phpunit.de/", + "keywords": [ + "phpunit", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/phpunit/issues", + "security": "https://github.com/sebastianbergmann/phpunit/security/policy", + "source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.29" + }, + "funding": [ + { + "url": "https://phpunit.de/sponsoring.html", + "type": "other" + } + ], + "time": "2026-06-04T06:14:42+00:00" + }, + { + "name": "sebastian/cli-parser", + "version": "4.2.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/cli-parser.git", + "reference": "7d05781b13f7dec9043a629a21d086ed74582a15" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/7d05781b13f7dec9043a629a21d086ed74582a15", + "reference": "7d05781b13f7dec9043a629a21d086ed74582a15", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.5.25" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.2-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for parsing CLI options", + "homepage": "https://github.com/sebastianbergmann/cli-parser", + "support": { + "issues": "https://github.com/sebastianbergmann/cli-parser/issues", + "security": "https://github.com/sebastianbergmann/cli-parser/security/policy", + "source": "https://github.com/sebastianbergmann/cli-parser/tree/4.2.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/cli-parser", + "type": "tidelift" + } + ], + "time": "2026-05-17T05:29:34+00:00" + }, + { + "name": "sebastian/comparator", + "version": "7.1.8", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/comparator.git", + "reference": "7c65c1e79836812819705b473a90c12399542485" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/7c65c1e79836812819705b473a90c12399542485", + "reference": "7c65c1e79836812819705b473a90c12399542485", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-mbstring": "*", + "php": ">=8.3", + "sebastian/diff": "^7.0", + "sebastian/exporter": "^7.0.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.5.25" + }, + "suggest": { + "ext-bcmath": "For comparing BcMath\\Number objects" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@2bepublished.at" + } + ], + "description": "Provides the functionality to compare PHP values for equality", + "homepage": "https://github.com/sebastianbergmann/comparator", + "keywords": [ + "comparator", + "compare", + "equality" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/comparator/issues", + "security": "https://github.com/sebastianbergmann/comparator/security/policy", + "source": "https://github.com/sebastianbergmann/comparator/tree/7.1.8" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/comparator", + "type": "tidelift" + } + ], + "time": "2026-05-21T04:45:25+00:00" + }, + { + "name": "sebastian/complexity", + "version": "5.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/complexity.git", + "reference": "bad4316aba5303d0221f43f8cee37eb58d384bbb" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/bad4316aba5303d0221f43f8cee37eb58d384bbb", + "reference": "bad4316aba5303d0221f43f8cee37eb58d384bbb", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^5.0", + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for calculating the complexity of PHP code units", + "homepage": "https://github.com/sebastianbergmann/complexity", + "support": { + "issues": "https://github.com/sebastianbergmann/complexity/issues", + "security": "https://github.com/sebastianbergmann/complexity/security/policy", + "source": "https://github.com/sebastianbergmann/complexity/tree/5.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-02-07T04:55:25+00:00" + }, + { + "name": "sebastian/diff", + "version": "7.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/diff.git", + "reference": "7ab1ea946c012266ca32390913653d844ecd085f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/7ab1ea946c012266ca32390913653d844ecd085f", + "reference": "7ab1ea946c012266ca32390913653d844ecd085f", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.0", + "symfony/process": "^7.2" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Kore Nordmann", + "email": "mail@kore-nordmann.de" + } + ], + "description": "Diff implementation", + "homepage": "https://github.com/sebastianbergmann/diff", + "keywords": [ + "diff", + "udiff", + "unidiff", + "unified diff" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/diff/issues", + "security": "https://github.com/sebastianbergmann/diff/security/policy", + "source": "https://github.com/sebastianbergmann/diff/tree/7.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-02-07T04:55:46+00:00" + }, + { + "name": "sebastian/environment", + "version": "8.1.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/environment.git", + "reference": "9d32c685773823b1983e256ae4ecd48a10d6e439" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/9d32c685773823b1983e256ae4ecd48a10d6e439", + "reference": "9d32c685773823b1983e256ae4ecd48a10d6e439", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.5.26" + }, + "suggest": { + "ext-posix": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "8.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Provides functionality to handle HHVM/PHP environments", + "homepage": "https://github.com/sebastianbergmann/environment", + "keywords": [ + "Xdebug", + "environment", + "hhvm" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/environment/issues", + "security": "https://github.com/sebastianbergmann/environment/security/policy", + "source": "https://github.com/sebastianbergmann/environment/tree/8.1.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/environment", + "type": "tidelift" + } + ], + "time": "2026-05-25T13:40:20+00:00" + }, + { + "name": "sebastian/exporter", + "version": "7.0.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/exporter.git", + "reference": "c5e21b5de653ce0a769fb36f5cdfcb5e7a32cf23" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/c5e21b5de653ce0a769fb36f5cdfcb5e7a32cf23", + "reference": "c5e21b5de653ce0a769fb36f5cdfcb5e7a32cf23", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": ">=8.3", + "sebastian/recursion-context": "^7.0.1" + }, + "require-dev": { + "phpunit/phpunit": "^12.5.25" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@gmail.com" + } + ], + "description": "Provides the functionality to export PHP variables for visualization", + "homepage": "https://www.github.com/sebastianbergmann/exporter", + "keywords": [ + "export", + "exporter" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/exporter/issues", + "security": "https://github.com/sebastianbergmann/exporter/security/policy", + "source": "https://github.com/sebastianbergmann/exporter/tree/7.0.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/exporter", + "type": "tidelift" + } + ], + "time": "2026-05-20T04:37:17+00:00" + }, + { + "name": "sebastian/global-state", + "version": "8.0.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/global-state.git", + "reference": "b164d3274d6537ab462591c5755f76a8f5b1aae9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/b164d3274d6537ab462591c5755f76a8f5b1aae9", + "reference": "b164d3274d6537ab462591c5755f76a8f5b1aae9", + "shasum": "" + }, + "require": { + "php": ">=8.3", + "sebastian/object-reflector": "^5.0", + "sebastian/recursion-context": "^7.0.1" + }, + "require-dev": { + "ext-dom": "*", + "phpunit/phpunit": "^12.5.28" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "8.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Snapshotting of global state", + "homepage": "https://www.github.com/sebastianbergmann/global-state", + "keywords": [ + "global state" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/global-state/issues", + "security": "https://github.com/sebastianbergmann/global-state/security/policy", + "source": "https://github.com/sebastianbergmann/global-state/tree/8.0.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/global-state", + "type": "tidelift" + } + ], + "time": "2026-06-01T15:10:33+00:00" + }, + { + "name": "sebastian/lines-of-code", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/lines-of-code.git", + "reference": "d543b8ef219dcd8da262cbb958639a96bedba10e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/d543b8ef219dcd8da262cbb958639a96bedba10e", + "reference": "d543b8ef219dcd8da262cbb958639a96bedba10e", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^5.7.0", + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.5.25" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for counting the lines of code in PHP source code", + "homepage": "https://github.com/sebastianbergmann/lines-of-code", + "support": { + "issues": "https://github.com/sebastianbergmann/lines-of-code/issues", + "security": "https://github.com/sebastianbergmann/lines-of-code/security/policy", + "source": "https://github.com/sebastianbergmann/lines-of-code/tree/4.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/lines-of-code", + "type": "tidelift" + } + ], + "time": "2026-05-19T16:22:07+00:00" + }, + { + "name": "sebastian/object-enumerator", + "version": "7.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-enumerator.git", + "reference": "1effe8e9b8e068e9ae228e542d5d11b5d16db894" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/1effe8e9b8e068e9ae228e542d5d11b5d16db894", + "reference": "1effe8e9b8e068e9ae228e542d5d11b5d16db894", + "shasum": "" + }, + "require": { + "php": ">=8.3", + "sebastian/object-reflector": "^5.0", + "sebastian/recursion-context": "^7.0" + }, + "require-dev": { + "phpunit/phpunit": "^12.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Traverses array structures and object graphs to enumerate all referenced objects", + "homepage": "https://github.com/sebastianbergmann/object-enumerator/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-enumerator/issues", + "security": "https://github.com/sebastianbergmann/object-enumerator/security/policy", + "source": "https://github.com/sebastianbergmann/object-enumerator/tree/7.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-02-07T04:57:48+00:00" + }, + { + "name": "sebastian/object-reflector", + "version": "5.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-reflector.git", + "reference": "4bfa827c969c98be1e527abd576533293c634f6a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/4bfa827c969c98be1e527abd576533293c634f6a", + "reference": "4bfa827c969c98be1e527abd576533293c634f6a", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Allows reflection of object attributes, including inherited and non-public ones", + "homepage": "https://github.com/sebastianbergmann/object-reflector/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-reflector/issues", + "security": "https://github.com/sebastianbergmann/object-reflector/security/policy", + "source": "https://github.com/sebastianbergmann/object-reflector/tree/5.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-02-07T04:58:17+00:00" + }, + { + "name": "sebastian/recursion-context", + "version": "7.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/recursion-context.git", + "reference": "0b01998a7d5b1f122911a66bebcb8d46f0c82d8c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/0b01998a7d5b1f122911a66bebcb8d46f0c82d8c", + "reference": "0b01998a7d5b1f122911a66bebcb8d46f0c82d8c", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + } + ], + "description": "Provides functionality to recursively process PHP variables", + "homepage": "https://github.com/sebastianbergmann/recursion-context", + "support": { + "issues": "https://github.com/sebastianbergmann/recursion-context/issues", + "security": "https://github.com/sebastianbergmann/recursion-context/security/policy", + "source": "https://github.com/sebastianbergmann/recursion-context/tree/7.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/recursion-context", + "type": "tidelift" + } + ], + "time": "2025-08-13T04:44:59+00:00" + }, + { + "name": "sebastian/type", + "version": "6.0.4", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/type.git", + "reference": "82ff822c2edc46724be9f7411d3163021f602773" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/82ff822c2edc46724be9f7411d3163021f602773", + "reference": "82ff822c2edc46724be9f7411d3163021f602773", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.5.25" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Collection of value objects that represent the types of the PHP type system", + "homepage": "https://github.com/sebastianbergmann/type", + "support": { + "issues": "https://github.com/sebastianbergmann/type/issues", + "security": "https://github.com/sebastianbergmann/type/security/policy", + "source": "https://github.com/sebastianbergmann/type/tree/6.0.4" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/type", + "type": "tidelift" + } + ], + "time": "2026-05-20T06:45:45+00:00" + }, + { + "name": "sebastian/version", + "version": "6.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/version.git", + "reference": "3e6ccf7657d4f0a59200564b08cead899313b53c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/3e6ccf7657d4f0a59200564b08cead899313b53c", + "reference": "3e6ccf7657d4f0a59200564b08cead899313b53c", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that helps with managing the version number of Git-hosted PHP projects", + "homepage": "https://github.com/sebastianbergmann/version", + "support": { + "issues": "https://github.com/sebastianbergmann/version/issues", + "security": "https://github.com/sebastianbergmann/version/security/policy", + "source": "https://github.com/sebastianbergmann/version/tree/6.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-02-07T05:00:38+00:00" + }, + { + "name": "staabm/side-effects-detector", + "version": "1.0.5", + "source": { + "type": "git", + "url": "https://github.com/staabm/side-effects-detector.git", + "reference": "d8334211a140ce329c13726d4a715adbddd0a163" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/staabm/side-effects-detector/zipball/d8334211a140ce329c13726d4a715adbddd0a163", + "reference": "d8334211a140ce329c13726d4a715adbddd0a163", + "shasum": "" + }, + "require": { + "ext-tokenizer": "*", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^1.12.6", + "phpunit/phpunit": "^9.6.21", + "symfony/var-dumper": "^5.4.43", + "tomasvotruba/type-coverage": "1.0.0", + "tomasvotruba/unused-public": "1.0.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "lib/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A static analysis tool to detect side effects in PHP code", + "keywords": [ + "static analysis" + ], + "support": { + "issues": "https://github.com/staabm/side-effects-detector/issues", + "source": "https://github.com/staabm/side-effects-detector/tree/1.0.5" + }, + "funding": [ + { + "url": "https://github.com/staabm", + "type": "github" + } + ], + "time": "2024-10-20T05:08:20+00:00" + }, + { + "name": "symfony/browser-kit", + "version": "v7.4.8", + "source": { + "type": "git", + "url": "https://github.com/symfony/browser-kit.git", + "reference": "41850d8f8ddef9a9cd7314fa9f4902cf48885521" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/browser-kit/zipball/41850d8f8ddef9a9cd7314fa9f4902cf48885521", + "reference": "41850d8f8ddef9a9cd7314fa9f4902cf48885521", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/dom-crawler": "^6.4|^7.0|^8.0" + }, + "require-dev": { + "symfony/css-selector": "^6.4|^7.0|^8.0", + "symfony/http-client": "^6.4|^7.0|^8.0", + "symfony/mime": "^6.4|^7.0|^8.0", + "symfony/process": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\BrowserKit\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Simulates the behavior of a web browser, allowing you to make requests, click on links and submit forms programmatically", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/browser-kit/tree/v7.4.8" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-03-24T13:12:05+00:00" + }, + { + "name": "symfony/css-selector", + "version": "v7.4.9", + "source": { + "type": "git", + "url": "https://github.com/symfony/css-selector.git", + "reference": "b75663ed96cf4756e28e3105476f220f92886cc4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/css-selector/zipball/b75663ed96cf4756e28e3105476f220f92886cc4", + "reference": "b75663ed96cf4756e28e3105476f220f92886cc4", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\CssSelector\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Jean-François Simon", + "email": "jeanfrancois.simon@sensiolabs.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Converts CSS selectors to XPath expressions", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/css-selector/tree/v7.4.9" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-18T13:18:21+00:00" + }, + { + "name": "symfony/debug-bundle", + "version": "v7.4.8", + "source": { + "type": "git", + "url": "https://github.com/symfony/debug-bundle.git", + "reference": "3eb18c1e6cd16da2cea1f1b5162e442af4afee44" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/debug-bundle/zipball/3eb18c1e6cd16da2cea1f1b5162e442af4afee44", + "reference": "3eb18c1e6cd16da2cea1f1b5162e442af4afee44", + "shasum": "" + }, + "require": { + "composer-runtime-api": ">=2.1", + "ext-xml": "*", + "php": ">=8.2", + "symfony/config": "^7.3|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/twig-bridge": "^6.4|^7.0|^8.0", + "symfony/var-dumper": "^6.4|^7.0|^8.0" + }, + "require-dev": { + "symfony/web-profiler-bundle": "^6.4|^7.0|^8.0" + }, + "type": "symfony-bundle", + "autoload": { + "psr-4": { + "Symfony\\Bundle\\DebugBundle\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides a tight integration of the Symfony VarDumper component and the ServerLogCommand from MonologBridge into the Symfony full-stack framework", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/debug-bundle/tree/v7.4.8" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-03-24T13:12:05+00:00" + }, + { + "name": "symfony/dom-crawler", + "version": "v7.4.12", + "source": { + "type": "git", + "url": "https://github.com/symfony/dom-crawler.git", + "reference": "b59b59122690976550fd142c23fab62c84738db6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/dom-crawler/zipball/b59b59122690976550fd142c23fab62c84738db6", + "reference": "b59b59122690976550fd142c23fab62c84738db6", + "shasum": "" + }, + "require": { + "masterminds/html5": "^2.6", + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-ctype": "~1.8", + "symfony/polyfill-mbstring": "~1.0" + }, + "require-dev": { + "symfony/css-selector": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\DomCrawler\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Eases DOM navigation for HTML and XML documents", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/dom-crawler/tree/v7.4.12" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-20T07:20:23+00:00" + }, + { + "name": "symfony/maker-bundle", + "version": "v1.67.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/maker-bundle.git", + "reference": "6ce8b313845f16bcf385ee3cb31d8b24e30d5516" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/maker-bundle/zipball/6ce8b313845f16bcf385ee3cb31d8b24e30d5516", + "reference": "6ce8b313845f16bcf385ee3cb31d8b24e30d5516", + "shasum": "" + }, + "require": { + "composer-runtime-api": "^2.1", + "doctrine/inflector": "^2.0", + "nikic/php-parser": "^5.0", + "php": ">=8.1", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/deprecation-contracts": "^2.2|^3", + "symfony/filesystem": "^6.4|^7.0|^8.0", + "symfony/finder": "^6.4|^7.0|^8.0", + "symfony/framework-bundle": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/process": "^6.4|^7.0|^8.0" + }, + "conflict": { + "doctrine/doctrine-bundle": "<2.10", + "doctrine/orm": "<2.15" + }, + "require-dev": { + "composer/semver": "^3.0", + "doctrine/doctrine-bundle": "^2.10|^3.0", + "doctrine/orm": "^2.15|^3", + "doctrine/persistence": "^3.1|^4.0", + "symfony/http-client": "^6.4|^7.0|^8.0", + "symfony/phpunit-bridge": "^6.4.1|^7.0|^8.0", + "symfony/security-core": "^6.4|^7.0|^8.0", + "symfony/security-http": "^6.4|^7.0|^8.0", + "symfony/yaml": "^6.4|^7.0|^8.0", + "twig/twig": "^3.0|^4.x-dev" + }, + "type": "symfony-bundle", + "extra": { + "branch-alias": { + "dev-main": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Bundle\\MakerBundle\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony Maker helps you create empty commands, controllers, form classes, tests and more so you can forget about writing boilerplate code.", + "homepage": "https://symfony.com/doc/current/bundles/SymfonyMakerBundle/index.html", + "keywords": [ + "code generator", + "dev", + "generator", + "scaffold", + "scaffolding" + ], + "support": { + "issues": "https://github.com/symfony/maker-bundle/issues", + "source": "https://github.com/symfony/maker-bundle/tree/v1.67.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-03-18T13:39:06+00:00" + }, + { + "name": "symfony/process", + "version": "v7.4.13", + "source": { + "type": "git", + "url": "https://github.com/symfony/process.git", + "reference": "f5804be144caceb570f6747519999636b664f24c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/process/zipball/f5804be144caceb570f6747519999636b664f24c", + "reference": "f5804be144caceb570f6747519999636b664f24c", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Process\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Executes commands in sub-processes", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/process/tree/v7.4.13" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-23T16:05:06+00:00" + }, + { + "name": "symfony/twig-bridge", + "version": "v7.4.12", + "source": { + "type": "git", + "url": "https://github.com/symfony/twig-bridge.git", + "reference": "81663873d946531129c76c65e80b681ce99c0e89" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/twig-bridge/zipball/81663873d946531129c76c65e80b681ce99c0e89", + "reference": "81663873d946531129c76c65e80b681ce99c0e89", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/translation-contracts": "^2.5|^3", + "twig/twig": "^3.21" + }, + "conflict": { + "phpdocumentor/reflection-docblock": "<5.2|>=7", + "phpdocumentor/type-resolver": "<1.5.1", + "symfony/console": "<6.4", + "symfony/form": "<6.4.32|>7,<7.3.10|>7.4,<7.4.4|>8.0,<8.0.4", + "symfony/http-foundation": "<6.4", + "symfony/http-kernel": "<6.4", + "symfony/mime": "<6.4.37|>7,<7.4.9|>8.0,<8.0.9", + "symfony/serializer": "<6.4", + "symfony/translation": "<6.4", + "symfony/workflow": "<6.4" + }, + "require-dev": { + "egulias/email-validator": "^2.1.10|^3|^4", + "league/html-to-markdown": "^5.0", + "phpdocumentor/reflection-docblock": "^5.2|^6.0", + "symfony/asset": "^6.4|^7.0|^8.0", + "symfony/asset-mapper": "^6.4|^7.0|^8.0", + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/emoji": "^7.1|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/finder": "^6.4|^7.0|^8.0", + "symfony/form": "^6.4.32|~7.3.10|^7.4.4|^8.0.4", + "symfony/html-sanitizer": "^6.4|^7.0|^8.0", + "symfony/http-foundation": "^7.3|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/intl": "^6.4|^7.0|^8.0", + "symfony/mime": "^6.4.37|^7.4.9|^8.0.9", + "symfony/polyfill-intl-icu": "~1.0", + "symfony/property-info": "^6.4|^7.0|^8.0", + "symfony/routing": "^6.4|^7.0|^8.0", + "symfony/security-acl": "^2.8|^3.0", + "symfony/security-core": "^6.4|^7.0|^8.0", + "symfony/security-csrf": "^6.4|^7.0|^8.0", + "symfony/security-http": "^6.4|^7.0|^8.0", + "symfony/serializer": "^6.4.3|^7.0.3|^8.0", + "symfony/stopwatch": "^6.4|^7.0|^8.0", + "symfony/translation": "^6.4|^7.0|^8.0", + "symfony/validator": "^6.4|^7.0|^8.0", + "symfony/web-link": "^6.4|^7.0|^8.0", + "symfony/workflow": "^6.4|^7.0|^8.0", + "symfony/yaml": "^6.4|^7.0|^8.0", + "twig/cssinliner-extra": "^3", + "twig/inky-extra": "^3", + "twig/markdown-extra": "^3" + }, + "type": "symfony-bridge", + "autoload": { + "psr-4": { + "Symfony\\Bridge\\Twig\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides integration for Twig with various Symfony components", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/twig-bridge/tree/v7.4.12" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-29T17:13:54+00:00" + }, + { + "name": "theseer/tokenizer", + "version": "2.0.1", + "source": { + "type": "git", + "url": "https://github.com/theseer/tokenizer.git", + "reference": "7989e43bf381af0eac72e4f0ca5bcbfa81658be4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/theseer/tokenizer/zipball/7989e43bf381af0eac72e4f0ca5bcbfa81658be4", + "reference": "7989e43bf381af0eac72e4f0ca5bcbfa81658be4", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-tokenizer": "*", + "ext-xmlwriter": "*", + "php": "^8.1" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + } + ], + "description": "A small library for converting tokenized PHP source code into XML and potentially other formats", + "support": { + "issues": "https://github.com/theseer/tokenizer/issues", + "source": "https://github.com/theseer/tokenizer/tree/2.0.1" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2025-12-08T11:19:18+00:00" + } + ], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": {}, + "prefer-stable": true, + "prefer-lowest": false, + "platform": { + "php": ">=8.2", + "ext-ctype": "*", + "ext-iconv": "*" + }, + "platform-dev": {}, + "plugin-api-version": "2.9.0" +} diff --git a/config/bundles.php b/config/bundles.php new file mode 100644 index 00000000..6ab62b6b --- /dev/null +++ b/config/bundles.php @@ -0,0 +1,13 @@ + ['all' => true], + Doctrine\Bundle\DoctrineBundle\DoctrineBundle::class => ['all' => true], + Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle::class => ['all' => true], + Symfony\Bundle\SecurityBundle\SecurityBundle::class => ['all' => true], + Lexik\Bundle\JWTAuthenticationBundle\LexikJWTAuthenticationBundle::class => ['all' => true], + Nelmio\CorsBundle\NelmioCorsBundle::class => ['all' => true], + Symfony\Bundle\DebugBundle\DebugBundle::class => ['dev' => true], + Symfony\Bundle\MakerBundle\MakerBundle::class => ['dev' => true], + Nelmio\ApiDocBundle\NelmioApiDocBundle::class => ['all' => true], +]; diff --git a/config/packages/cache.yaml b/config/packages/cache.yaml new file mode 100644 index 00000000..dee3c809 --- /dev/null +++ b/config/packages/cache.yaml @@ -0,0 +1,4 @@ +framework: + cache: + app: cache.adapter.redis + default_redis_provider: '%env(REDIS_URL)%' diff --git a/config/packages/debug.yaml b/config/packages/debug.yaml new file mode 100644 index 00000000..ad874afd --- /dev/null +++ b/config/packages/debug.yaml @@ -0,0 +1,5 @@ +when@dev: + debug: + # Forwards VarDumper Data clones to a centralized server allowing to inspect dumps on CLI or in your browser. + # See the "server:dump" command to start a new server. + dump_destination: "tcp://%env(VAR_DUMPER_SERVER)%" diff --git a/config/packages/doctrine.yaml b/config/packages/doctrine.yaml new file mode 100644 index 00000000..4ccc3a24 --- /dev/null +++ b/config/packages/doctrine.yaml @@ -0,0 +1,45 @@ +doctrine: + dbal: + url: '%env(resolve:DATABASE_URL)%' + profiling_collect_backtrace: '%kernel.debug%' + use_savepoints: true + orm: + auto_generate_proxy_classes: true + enable_lazy_ghost_objects: true + report_fields_where_declared: true + naming_strategy: doctrine.orm.naming_strategy.underscore_number_aware + auto_mapping: true + mappings: + App: + type: attribute + is_bundle: false + dir: '%kernel.project_dir%/src' + prefix: 'App' + alias: App + controller_resolver: + auto_mapping: false + +when@test: + doctrine: + dbal: + dbname_suffix: '_test%env(default::TEST_TOKEN)%' + +when@prod: + doctrine: + orm: + auto_generate_proxy_classes: false + proxy_dir: '%kernel.build_dir%/doctrine/orm/Proxies' + query_cache_driver: + type: pool + pool: doctrine.system_cache_pool + result_cache_driver: + type: pool + pool: doctrine.result_cache_pool + + framework: + cache: + pools: + doctrine.result_cache_pool: + adapter: cache.app + doctrine.system_cache_pool: + adapter: cache.system diff --git a/config/packages/doctrine_migrations.yaml b/config/packages/doctrine_migrations.yaml new file mode 100644 index 00000000..29231d94 --- /dev/null +++ b/config/packages/doctrine_migrations.yaml @@ -0,0 +1,6 @@ +doctrine_migrations: + migrations_paths: + # namespace is arbitrary but should be different from App\Migrations + # as migrations classes should NOT be autoloaded + 'DoctrineMigrations': '%kernel.project_dir%/migrations' + enable_profiler: false diff --git a/config/packages/framework.yaml b/config/packages/framework.yaml new file mode 100644 index 00000000..7e1ee1f1 --- /dev/null +++ b/config/packages/framework.yaml @@ -0,0 +1,15 @@ +# see https://symfony.com/doc/current/reference/configuration/framework.html +framework: + secret: '%env(APP_SECRET)%' + + # Note that the session will be started ONLY if you read or write from it. + session: true + + #esi: true + #fragments: true + +when@test: + framework: + test: true + session: + storage_factory_id: session.storage.factory.mock_file diff --git a/config/packages/lexik_jwt_authentication.yaml b/config/packages/lexik_jwt_authentication.yaml new file mode 100644 index 00000000..dd7a3aa5 --- /dev/null +++ b/config/packages/lexik_jwt_authentication.yaml @@ -0,0 +1,5 @@ +lexik_jwt_authentication: + secret_key: '%env(resolve:JWT_SECRET_KEY)%' + public_key: '%env(resolve:JWT_PUBLIC_KEY)%' + pass_phrase: '%env(JWT_PASSPHRASE)%' + token_ttl: 3600 diff --git a/config/packages/messenger.yaml b/config/packages/messenger.yaml new file mode 100644 index 00000000..d9cf407e --- /dev/null +++ b/config/packages/messenger.yaml @@ -0,0 +1,22 @@ +framework: + messenger: + failure_transport: failed + + transports: + async: + dsn: '%env(MESSENGER_TRANSPORT_DSN)%' + retry_strategy: + max_retries: 3 + delay: 5000 + multiplier: 2 + failed: 'doctrine://default?queue_name=failed' + sync: 'sync://' + + routing: + 'App\Shared\Message\SendSmsMessage': async + +when@test: + framework: + messenger: + transports: + async: 'in-memory://' diff --git a/config/packages/nelmio_api_doc.yaml b/config/packages/nelmio_api_doc.yaml new file mode 100644 index 00000000..52637961 --- /dev/null +++ b/config/packages/nelmio_api_doc.yaml @@ -0,0 +1,19 @@ +nelmio_api_doc: + documentation: + info: + title: ClinicPro API + description: مستندات API سیستم کلینیک‌پرو + version: 1.0.0 + components: + securitySchemes: + bearerAuth: + type: http + scheme: bearer + bearerFormat: JWT + security: + - bearerAuth: [] + areas: + path_patterns: + - ^/api + - ^/oauth + - ^/health diff --git a/config/packages/nelmio_cors.yaml b/config/packages/nelmio_cors.yaml new file mode 100644 index 00000000..52f45949 --- /dev/null +++ b/config/packages/nelmio_cors.yaml @@ -0,0 +1,16 @@ +nelmio_cors: + defaults: + origin_regex: true + allow_origin: ['%env(CORS_ALLOW_ORIGIN)%'] + allow_methods: ['GET', 'OPTIONS', 'POST', 'PATCH', 'DELETE'] + allow_headers: ['Content-Type', 'Authorization', 'X-CSRF-Token', 'Content-Disposition'] + expose_headers: ['X-RateLimit-Limit', 'X-RateLimit-Remaining', 'X-RateLimit-Reset'] + max_age: 3600 + allow_credentials: false + paths: + '^/api/': + allow_origin: ['%env(CORS_ALLOW_ORIGIN)%'] + '^/oauth/': + allow_origin: ['%env(CORS_ALLOW_ORIGIN)%'] + '^/health': + allow_origin: ['%env(CORS_ALLOW_ORIGIN)%'] diff --git a/config/packages/property_info.yaml b/config/packages/property_info.yaml new file mode 100644 index 00000000..dd31b9da --- /dev/null +++ b/config/packages/property_info.yaml @@ -0,0 +1,3 @@ +framework: + property_info: + with_constructor_extractor: true diff --git a/config/packages/rate_limiter.yaml b/config/packages/rate_limiter.yaml new file mode 100644 index 00000000..b0c52efd --- /dev/null +++ b/config/packages/rate_limiter.yaml @@ -0,0 +1,13 @@ +framework: + rate_limiter: + # OTP send-code: max 5 requests per hour per IP (prevents SMS flood) + send_code: + policy: 'sliding_window' + limit: 5 + interval: '60 minutes' + + # Login: max 10 attempts per minute per IP (brute force protection) + login: + policy: 'fixed_window' + limit: 10 + interval: '1 minute' diff --git a/config/packages/routing.yaml b/config/packages/routing.yaml new file mode 100644 index 00000000..0f34f872 --- /dev/null +++ b/config/packages/routing.yaml @@ -0,0 +1,10 @@ +framework: + router: + # Configure how to generate URLs in non-HTTP contexts, such as CLI commands. + # See https://symfony.com/doc/current/routing.html#generating-urls-in-commands + default_uri: '%env(DEFAULT_URI)%' + +when@prod: + framework: + router: + strict_requirements: null diff --git a/config/packages/security.yaml b/config/packages/security.yaml new file mode 100644 index 00000000..846c5a3c --- /dev/null +++ b/config/packages/security.yaml @@ -0,0 +1,79 @@ +security: + password_hashers: + App\Auth\Entity\User: + algorithm: auto + + providers: + app_user_provider: + entity: + class: App\Auth\Entity\User + property: mobileNumber + + firewalls: + dev: + pattern: ^/(_profiler|_wdt|assets|build)/ + security: false + + health: + pattern: ^/health$ + security: false + + 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 + security: false + + payment_callback: + pattern: ^/api/v1/(payment|subscription-payment)/callback/ + stateless: true + security: false + + api: + pattern: ^/(api|oauth)/ + stateless: true + provider: app_user_provider + custom_authenticators: + - App\Auth\Security\PasswordAuthenticator + jwt: ~ + + access_control: + - { path: ^/health$, roles: PUBLIC_ACCESS } + - { 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 } + - { path: ^/api/v1/user/login, roles: PUBLIC_ACCESS } + - { path: ^/api/v1/appointment-slots, roles: PUBLIC_ACCESS } + - { path: ^/api/v1/comments/, roles: PUBLIC_ACCESS } + - { path: ^/api/v1/rate/, roles: PUBLIC_ACCESS } + - { path: ^/api/v1/blogs$, roles: PUBLIC_ACCESS } + - path: '^/api/v1/blog/[^/]+$' + methods: [GET] + roles: PUBLIC_ACCESS + - { path: ^/oauth/token$, roles: PUBLIC_ACCESS } + - { path: ^/oauth/token/refresh$, roles: PUBLIC_ACCESS } + - { 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/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/[^/]+$' + methods: [GET] + roles: PUBLIC_ACCESS + - { path: ^/api/v1/user/\d+$, methods: [DELETE], roles: ROLE_ADMIN } + - { path: ^/api, roles: IS_AUTHENTICATED_FULLY } + - { path: ^/oauth/userinfo, roles: IS_AUTHENTICATED_FULLY } + - { path: ^/oauth/logout, roles: IS_AUTHENTICATED_FULLY } + +when@test: + security: + password_hashers: + App\Auth\Entity\User: + algorithm: auto + cost: 4 diff --git a/config/packages/validator.yaml b/config/packages/validator.yaml new file mode 100644 index 00000000..dd47a6ad --- /dev/null +++ b/config/packages/validator.yaml @@ -0,0 +1,11 @@ +framework: + validation: + # Enables validator auto-mapping support. + # For instance, basic validation constraints will be inferred from Doctrine's metadata. + #auto_mapping: + # App\Entity\: [] + +when@test: + framework: + validation: + not_compromised_password: false diff --git a/config/preload.php b/config/preload.php new file mode 100644 index 00000000..5ebcdb21 --- /dev/null +++ b/config/preload.php @@ -0,0 +1,5 @@ + [ + * 'App\\' => [ + * 'resource' => '../src/', + * ], + * ], + * ]); + * ``` + * + * @psalm-type ImportsConfig = list + * @psalm-type ParametersConfig = array|Param|null>|Param|null> + * @psalm-type ArgumentsType = list|array + * @psalm-type CallType = array|array{0:string, 1?:ArgumentsType, 2?:bool}|array{method:string, arguments?:ArgumentsType, returns_clone?:bool} + * @psalm-type TagsType = list>> // arrays inside the list must have only one element, with the tag name as the key + * @psalm-type CallbackType = string|array{0:string|ReferenceConfigurator,1:string}|\Closure|ReferenceConfigurator + * @psalm-type DeprecationType = array{package: string, version: string, message?: string} + * @psalm-type DefaultsType = array{ + * public?: bool, + * tags?: TagsType, + * resource_tags?: TagsType, + * autowire?: bool, + * autoconfigure?: bool, + * bind?: array, + * } + * @psalm-type InstanceofType = array{ + * shared?: bool, + * lazy?: bool|string, + * public?: bool, + * properties?: array, + * configurator?: CallbackType, + * calls?: list, + * tags?: TagsType, + * resource_tags?: TagsType, + * autowire?: bool, + * bind?: array, + * constructor?: string, + * } + * @psalm-type DefinitionType = array{ + * class?: string, + * file?: string, + * parent?: string, + * shared?: bool, + * synthetic?: bool, + * lazy?: bool|string, + * public?: bool, + * abstract?: bool, + * deprecated?: DeprecationType, + * factory?: CallbackType, + * configurator?: CallbackType, + * arguments?: ArgumentsType, + * properties?: array, + * calls?: list, + * tags?: TagsType, + * resource_tags?: TagsType, + * decorates?: string, + * decoration_inner_name?: string, + * decoration_priority?: int, + * decoration_on_invalid?: 'exception'|'ignore'|null, + * autowire?: bool, + * autoconfigure?: bool, + * bind?: array, + * constructor?: string, + * from_callable?: CallbackType, + * } + * @psalm-type AliasType = string|array{ + * alias: string, + * public?: bool, + * deprecated?: DeprecationType, + * } + * @psalm-type PrototypeType = array{ + * resource: string, + * namespace?: string, + * exclude?: string|list, + * parent?: string, + * shared?: bool, + * lazy?: bool|string, + * public?: bool, + * abstract?: bool, + * deprecated?: DeprecationType, + * factory?: CallbackType, + * arguments?: ArgumentsType, + * properties?: array, + * configurator?: CallbackType, + * calls?: list, + * tags?: TagsType, + * resource_tags?: TagsType, + * autowire?: bool, + * autoconfigure?: bool, + * bind?: array, + * constructor?: string, + * } + * @psalm-type StackType = array{ + * stack: list>, + * public?: bool, + * deprecated?: DeprecationType, + * } + * @psalm-type ServicesConfig = array{ + * _defaults?: DefaultsType, + * _instanceof?: InstanceofType, + * ... + * } + * @psalm-type ExtensionType = array + * @psalm-type FrameworkConfig = array{ + * secret?: scalar|Param|null, + * http_method_override?: bool|Param, // Set true to enable support for the '_method' request parameter to determine the intended HTTP method on POST requests. // Default: false + * allowed_http_method_override?: null|list, + * trust_x_sendfile_type_header?: scalar|Param|null, // Set true to enable support for xsendfile in binary file responses. // Default: "%env(bool:default::SYMFONY_TRUST_X_SENDFILE_TYPE_HEADER)%" + * ide?: scalar|Param|null, // Default: "%env(default::SYMFONY_IDE)%" + * test?: bool|Param, + * default_locale?: scalar|Param|null, // Default: "en" + * set_locale_from_accept_language?: bool|Param, // Whether to use the Accept-Language HTTP header to set the Request locale (only when the "_locale" request attribute is not passed). // Default: false + * set_content_language_from_locale?: bool|Param, // Whether to set the Content-Language HTTP header on the Response using the Request locale. // Default: false + * enabled_locales?: list, + * trusted_hosts?: string|list, + * trusted_proxies?: mixed, // Default: ["%env(default::SYMFONY_TRUSTED_PROXIES)%"] + * trusted_headers?: string|list, + * error_controller?: scalar|Param|null, // Default: "error_controller" + * handle_all_throwables?: bool|Param, // HttpKernel will handle all kinds of \Throwable. // Default: true + * csrf_protection?: bool|array{ + * enabled?: scalar|Param|null, // Default: null + * stateless_token_ids?: list, + * check_header?: scalar|Param|null, // Whether to check the CSRF token in a header in addition to a cookie when using stateless protection. // Default: false + * cookie_name?: scalar|Param|null, // The name of the cookie to use when using stateless protection. // Default: "csrf-token" + * }, + * form?: bool|array{ // Form configuration + * enabled?: bool|Param, // Default: false + * csrf_protection?: bool|array{ + * enabled?: scalar|Param|null, // Default: null + * token_id?: scalar|Param|null, // Default: null + * field_name?: scalar|Param|null, // Default: "_token" + * field_attr?: array, + * }, + * }, + * http_cache?: bool|array{ // HTTP cache configuration + * enabled?: bool|Param, // Default: false + * debug?: bool|Param, // Default: "%kernel.debug%" + * trace_level?: "none"|"short"|"full"|Param, + * trace_header?: scalar|Param|null, + * default_ttl?: int|Param, + * private_headers?: list, + * skip_response_headers?: list, + * allow_reload?: bool|Param, + * allow_revalidate?: bool|Param, + * stale_while_revalidate?: int|Param, + * stale_if_error?: int|Param, + * terminate_on_cache_hit?: bool|Param, + * }, + * esi?: bool|array{ // ESI configuration + * enabled?: bool|Param, // Default: false + * }, + * ssi?: bool|array{ // SSI configuration + * enabled?: bool|Param, // Default: false + * }, + * fragments?: bool|array{ // Fragments configuration + * enabled?: bool|Param, // Default: false + * hinclude_default_template?: scalar|Param|null, // Default: null + * path?: scalar|Param|null, // Default: "/_fragment" + * }, + * profiler?: bool|array{ // Profiler configuration + * enabled?: bool|Param, // Default: false + * collect?: bool|Param, // Default: true + * collect_parameter?: scalar|Param|null, // The name of the parameter to use to enable or disable collection on a per request basis. // Default: null + * only_exceptions?: bool|Param, // Default: false + * only_main_requests?: bool|Param, // Default: false + * dsn?: scalar|Param|null, // Default: "file:%kernel.cache_dir%/profiler" + * collect_serializer_data?: bool|Param, // Enables the serializer data collector and profiler panel. // Default: false + * }, + * workflows?: bool|array{ + * enabled?: bool|Param, // Default: false + * workflows?: array, + * definition_validators?: list, + * support_strategy?: scalar|Param|null, + * initial_marking?: \BackedEnum|string|list, + * events_to_dispatch?: null|list, + * places?: string|list, + * }>, + * transitions?: list, + * to?: \BackedEnum|string|list, + * weight?: int|Param, // Default: 1 + * metadata?: array, + * }>, + * metadata?: array, + * }>, + * }, + * router?: bool|array{ // Router configuration + * enabled?: bool|Param, // Default: false + * resource?: scalar|Param|null, + * type?: scalar|Param|null, + * cache_dir?: scalar|Param|null, // Deprecated: Setting the "framework.router.cache_dir.cache_dir" configuration option is deprecated. It will be removed in version 8.0. // Default: "%kernel.build_dir%" + * default_uri?: scalar|Param|null, // The default URI used to generate URLs in a non-HTTP context. // Default: null + * http_port?: scalar|Param|null, // Default: 80 + * https_port?: scalar|Param|null, // Default: 443 + * strict_requirements?: scalar|Param|null, // set to true to throw an exception when a parameter does not match the requirements set to false to disable exceptions when a parameter does not match the requirements (and return null instead) set to null to disable parameter checks against requirements 'true' is the preferred configuration in development mode, while 'false' or 'null' might be preferred in production // Default: true + * utf8?: bool|Param, // Default: true + * }, + * session?: bool|array{ // Session configuration + * enabled?: bool|Param, // Default: false + * storage_factory_id?: scalar|Param|null, // Default: "session.storage.factory.native" + * handler_id?: scalar|Param|null, // Defaults to using the native session handler, or to the native *file* session handler if "save_path" is not null. + * name?: scalar|Param|null, + * cookie_lifetime?: scalar|Param|null, + * cookie_path?: scalar|Param|null, + * cookie_domain?: scalar|Param|null, + * cookie_secure?: true|false|"auto"|Param, // Default: "auto" + * cookie_httponly?: bool|Param, // Default: true + * cookie_samesite?: null|"lax"|"strict"|"none"|Param, // Default: "lax" + * use_cookies?: bool|Param, + * gc_divisor?: scalar|Param|null, + * gc_probability?: scalar|Param|null, + * gc_maxlifetime?: scalar|Param|null, + * save_path?: scalar|Param|null, // Defaults to "%kernel.cache_dir%/sessions" if the "handler_id" option is not null. + * metadata_update_threshold?: int|Param, // Seconds to wait between 2 session metadata updates. // Default: 0 + * sid_length?: int|Param, // Deprecated: Setting the "framework.session.sid_length.sid_length" configuration option is deprecated. It will be removed in version 8.0. No alternative is provided as PHP 8.4 has deprecated the related option. + * sid_bits_per_character?: int|Param, // Deprecated: Setting the "framework.session.sid_bits_per_character.sid_bits_per_character" configuration option is deprecated. It will be removed in version 8.0. No alternative is provided as PHP 8.4 has deprecated the related option. + * }, + * request?: bool|array{ // Request configuration + * enabled?: bool|Param, // Default: false + * formats?: array>, + * }, + * assets?: bool|array{ // Assets configuration + * enabled?: bool|Param, // Default: true + * strict_mode?: bool|Param, // Throw an exception if an entry is missing from the manifest.json. // Default: false + * version_strategy?: scalar|Param|null, // Default: null + * version?: scalar|Param|null, // Default: null + * version_format?: scalar|Param|null, // Default: "%%s?%%s" + * json_manifest_path?: scalar|Param|null, // Default: null + * base_path?: scalar|Param|null, // Default: "" + * base_urls?: string|list, + * packages?: array, + * }>, + * }, + * asset_mapper?: bool|array{ // Asset Mapper configuration + * enabled?: bool|Param, // Default: false + * paths?: string|array, + * excluded_patterns?: list, + * exclude_dotfiles?: bool|Param, // If true, any files starting with "." will be excluded from the asset mapper. // Default: true + * server?: bool|Param, // If true, a "dev server" will return the assets from the public directory (true in "debug" mode only by default). // Default: true + * public_prefix?: scalar|Param|null, // The public path where the assets will be written to (and served from when "server" is true). // Default: "/assets/" + * missing_import_mode?: "strict"|"warn"|"ignore"|Param, // Behavior if an asset cannot be found when imported from JavaScript or CSS files - e.g. "import './non-existent.js'". "strict" means an exception is thrown, "warn" means a warning is logged, "ignore" means the import is left as-is. // Default: "warn" + * extensions?: array, + * importmap_path?: scalar|Param|null, // The path of the importmap.php file. // Default: "%kernel.project_dir%/importmap.php" + * importmap_polyfill?: scalar|Param|null, // The importmap name that will be used to load the polyfill. Set to false to disable. // Default: "es-module-shims" + * importmap_script_attributes?: array, + * vendor_dir?: scalar|Param|null, // The directory to store JavaScript vendors. // Default: "%kernel.project_dir%/assets/vendor" + * precompress?: bool|array{ // Precompress assets with Brotli, Zstandard and gzip. + * enabled?: bool|Param, // Default: false + * formats?: list, + * extensions?: list, + * }, + * }, + * translator?: bool|array{ // Translator configuration + * enabled?: bool|Param, // Default: false + * fallbacks?: string|list, + * logging?: bool|Param, // Default: false + * formatter?: scalar|Param|null, // Default: "translator.formatter.default" + * cache_dir?: scalar|Param|null, // Default: "%kernel.cache_dir%/translations" + * default_path?: scalar|Param|null, // The default path used to load translations. // Default: "%kernel.project_dir%/translations" + * paths?: list, + * pseudo_localization?: bool|array{ + * enabled?: bool|Param, // Default: false + * accents?: bool|Param, // Default: true + * expansion_factor?: float|Param, // Default: 1.0 + * brackets?: bool|Param, // Default: true + * parse_html?: bool|Param, // Default: false + * localizable_html_attributes?: list, + * }, + * providers?: array, + * locales?: list, + * }>, + * globals?: array, + * domain?: string|Param, + * }>, + * }, + * validation?: bool|array{ // Validation configuration + * enabled?: bool|Param, // Default: true + * cache?: scalar|Param|null, // Deprecated: Setting the "framework.validation.cache.cache" configuration option is deprecated. It will be removed in version 8.0. + * enable_attributes?: bool|Param, // Default: true + * static_method?: string|list, + * translation_domain?: scalar|Param|null, // Default: "validators" + * email_validation_mode?: "html5"|"html5-allow-no-tld"|"strict"|"loose"|Param, // Default: "html5" + * mapping?: array{ + * paths?: list, + * }, + * not_compromised_password?: bool|array{ + * enabled?: bool|Param, // When disabled, compromised passwords will be accepted as valid. // Default: true + * endpoint?: scalar|Param|null, // API endpoint for the NotCompromisedPassword Validator. // Default: null + * }, + * disable_translation?: bool|Param, // Default: false + * auto_mapping?: array, + * }>, + * }, + * annotations?: bool|array{ + * enabled?: bool|Param, // Default: false + * }, + * serializer?: bool|array{ // Serializer configuration + * enabled?: bool|Param, // Default: true + * enable_attributes?: bool|Param, // Default: true + * name_converter?: scalar|Param|null, + * circular_reference_handler?: scalar|Param|null, + * max_depth_handler?: scalar|Param|null, + * mapping?: array{ + * paths?: list, + * }, + * default_context?: array, + * named_serializers?: array, + * include_built_in_normalizers?: bool|Param, // Whether to include the built-in normalizers // Default: true + * include_built_in_encoders?: bool|Param, // Whether to include the built-in encoders // Default: true + * }>, + * }, + * property_access?: bool|array{ // Property access configuration + * enabled?: bool|Param, // Default: true + * magic_call?: bool|Param, // Default: false + * magic_get?: bool|Param, // Default: true + * magic_set?: bool|Param, // Default: true + * throw_exception_on_invalid_index?: bool|Param, // Default: false + * throw_exception_on_invalid_property_path?: bool|Param, // Default: true + * }, + * type_info?: bool|array{ // Type info configuration + * enabled?: bool|Param, // Default: true + * aliases?: array, + * }, + * property_info?: bool|array{ // Property info configuration + * enabled?: bool|Param, // Default: true + * with_constructor_extractor?: bool|Param, // Registers the constructor extractor. + * }, + * cache?: array{ // Cache configuration + * prefix_seed?: scalar|Param|null, // Used to namespace cache keys when using several apps with the same shared backend. // Default: "_%kernel.project_dir%.%kernel.container_class%" + * app?: scalar|Param|null, // App related cache pools configuration. // Default: "cache.adapter.filesystem" + * system?: scalar|Param|null, // System related cache pools configuration. // Default: "cache.adapter.system" + * directory?: scalar|Param|null, // Default: "%kernel.share_dir%/pools/app" + * default_psr6_provider?: scalar|Param|null, + * default_redis_provider?: scalar|Param|null, // Default: "redis://localhost" + * default_valkey_provider?: scalar|Param|null, // Default: "valkey://localhost" + * default_memcached_provider?: scalar|Param|null, // Default: "memcached://localhost" + * default_doctrine_dbal_provider?: scalar|Param|null, // Default: "database_connection" + * default_pdo_provider?: scalar|Param|null, // Default: null + * pools?: array, + * tags?: scalar|Param|null, // Default: null + * public?: bool|Param, // Default: false + * default_lifetime?: scalar|Param|null, // Default lifetime of the pool. + * provider?: scalar|Param|null, // Overwrite the setting from the default provider for this adapter. + * early_expiration_message_bus?: scalar|Param|null, + * clearer?: scalar|Param|null, + * }>, + * }, + * php_errors?: array{ // PHP errors handling configuration + * log?: mixed, // Use the application logger instead of the PHP logger for logging PHP errors. // Default: true + * throw?: bool|Param, // Throw PHP errors as \ErrorException instances. // Default: true + * }, + * exceptions?: array, + * web_link?: bool|array{ // Web links configuration + * enabled?: bool|Param, // Default: false + * }, + * lock?: bool|string|array{ // Lock configuration + * enabled?: bool|Param, // Default: false + * resources?: string|array>, + * }, + * semaphore?: bool|string|array{ // Semaphore configuration + * enabled?: bool|Param, // Default: false + * resources?: string|array, + * }, + * messenger?: bool|array{ // Messenger configuration + * enabled?: bool|Param, // Default: true + * routing?: array, + * }>, + * serializer?: array{ + * default_serializer?: scalar|Param|null, // Service id to use as the default serializer for the transports. // Default: "messenger.transport.native_php_serializer" + * symfony_serializer?: array{ + * format?: scalar|Param|null, // Serialization format for the messenger.transport.symfony_serializer service (which is not the serializer used by default). // Default: "json" + * context?: array, + * }, + * }, + * transports?: array, + * failure_transport?: scalar|Param|null, // Transport name to send failed messages to (after all retries have failed). // Default: null + * retry_strategy?: string|array{ + * service?: scalar|Param|null, // Service id to override the retry strategy entirely. // Default: null + * max_retries?: int|Param, // Default: 3 + * delay?: int|Param, // Time in ms to delay (or the initial value when multiplier is used). // Default: 1000 + * multiplier?: float|Param, // If greater than 1, delay will grow exponentially for each retry: this delay = (delay * (multiple ^ retries)). // Default: 2 + * max_delay?: int|Param, // Max time in ms that a retry should ever be delayed (0 = infinite). // Default: 0 + * jitter?: float|Param, // Randomness to apply to the delay (between 0 and 1). // Default: 0.1 + * }, + * rate_limiter?: scalar|Param|null, // Rate limiter name to use when processing messages. // Default: null + * }>, + * failure_transport?: scalar|Param|null, // Transport name to send failed messages to (after all retries have failed). // Default: null + * stop_worker_on_signals?: int|string|list, + * default_bus?: scalar|Param|null, // Default: null + * buses?: array, + * }>, + * }>, + * }, + * scheduler?: bool|array{ // Scheduler configuration + * enabled?: bool|Param, // Default: false + * }, + * disallow_search_engine_index?: bool|Param, // Enabled by default when debug is enabled. // Default: true + * http_client?: bool|array{ // HTTP Client configuration + * enabled?: bool|Param, // Default: true + * max_host_connections?: int|Param, // The maximum number of connections to a single host. + * default_options?: array{ + * headers?: array, + * vars?: array, + * max_redirects?: int|Param, // The maximum number of redirects to follow. + * http_version?: scalar|Param|null, // The default HTTP version, typically 1.1 or 2.0, leave to null for the best version. + * resolve?: array, + * proxy?: scalar|Param|null, // The URL of the proxy to pass requests through or null for automatic detection. + * no_proxy?: scalar|Param|null, // A comma separated list of hosts that do not require a proxy to be reached. + * timeout?: float|Param, // The idle timeout, defaults to the "default_socket_timeout" ini parameter. + * max_duration?: float|Param, // The maximum execution time for the request+response as a whole. + * bindto?: scalar|Param|null, // A network interface name, IP address, a host name or a UNIX socket to bind to. + * verify_peer?: bool|Param, // Indicates if the peer should be verified in a TLS context. + * verify_host?: bool|Param, // Indicates if the host should exist as a certificate common name. + * cafile?: scalar|Param|null, // A certificate authority file. + * capath?: scalar|Param|null, // A directory that contains multiple certificate authority files. + * local_cert?: scalar|Param|null, // A PEM formatted certificate file. + * local_pk?: scalar|Param|null, // A private key file. + * passphrase?: scalar|Param|null, // The passphrase used to encrypt the "local_pk" file. + * ciphers?: scalar|Param|null, // A list of TLS ciphers separated by colons, commas or spaces (e.g. "RC3-SHA:TLS13-AES-128-GCM-SHA256"...) + * peer_fingerprint?: array{ // Associative array: hashing algorithm => hash(es). + * sha1?: mixed, + * pin-sha256?: mixed, + * md5?: mixed, + * }, + * crypto_method?: scalar|Param|null, // The minimum version of TLS to accept; must be one of STREAM_CRYPTO_METHOD_TLSv*_CLIENT constants. + * extra?: array, + * rate_limiter?: scalar|Param|null, // Rate limiter name to use for throttling requests. // Default: null + * caching?: bool|array{ // Caching configuration. + * enabled?: bool|Param, // Default: false + * cache_pool?: string|Param, // The taggable cache pool to use for storing the responses. // Default: "cache.http_client" + * shared?: bool|Param, // Indicates whether the cache is shared (public) or private. // Default: true + * max_ttl?: int|Param, // The maximum TTL (in seconds) allowed for cached responses. Null means no cap. // Default: null + * }, + * retry_failed?: bool|array{ + * enabled?: bool|Param, // Default: false + * retry_strategy?: scalar|Param|null, // service id to override the retry strategy. // Default: null + * http_codes?: int|string|array, + * }>, + * max_retries?: int|Param, // Default: 3 + * delay?: int|Param, // Time in ms to delay (or the initial value when multiplier is used). // Default: 1000 + * multiplier?: float|Param, // If greater than 1, delay will grow exponentially for each retry: delay * (multiple ^ retries). // Default: 2 + * max_delay?: int|Param, // Max time in ms that a retry should ever be delayed (0 = infinite). // Default: 0 + * jitter?: float|Param, // Randomness in percent (between 0 and 1) to apply to the delay. // Default: 0.1 + * }, + * }, + * mock_response_factory?: scalar|Param|null, // The id of the service that should generate mock responses. It should be either an invokable or an iterable. + * scoped_clients?: array, + * headers?: array, + * max_redirects?: int|Param, // The maximum number of redirects to follow. + * http_version?: scalar|Param|null, // The default HTTP version, typically 1.1 or 2.0, leave to null for the best version. + * resolve?: array, + * proxy?: scalar|Param|null, // The URL of the proxy to pass requests through or null for automatic detection. + * no_proxy?: scalar|Param|null, // A comma separated list of hosts that do not require a proxy to be reached. + * timeout?: float|Param, // The idle timeout, defaults to the "default_socket_timeout" ini parameter. + * max_duration?: float|Param, // The maximum execution time for the request+response as a whole. + * bindto?: scalar|Param|null, // A network interface name, IP address, a host name or a UNIX socket to bind to. + * verify_peer?: bool|Param, // Indicates if the peer should be verified in a TLS context. + * verify_host?: bool|Param, // Indicates if the host should exist as a certificate common name. + * cafile?: scalar|Param|null, // A certificate authority file. + * capath?: scalar|Param|null, // A directory that contains multiple certificate authority files. + * local_cert?: scalar|Param|null, // A PEM formatted certificate file. + * local_pk?: scalar|Param|null, // A private key file. + * passphrase?: scalar|Param|null, // The passphrase used to encrypt the "local_pk" file. + * ciphers?: scalar|Param|null, // A list of TLS ciphers separated by colons, commas or spaces (e.g. "RC3-SHA:TLS13-AES-128-GCM-SHA256"...). + * peer_fingerprint?: array{ // Associative array: hashing algorithm => hash(es). + * sha1?: mixed, + * pin-sha256?: mixed, + * md5?: mixed, + * }, + * crypto_method?: scalar|Param|null, // The minimum version of TLS to accept; must be one of STREAM_CRYPTO_METHOD_TLSv*_CLIENT constants. + * extra?: array, + * rate_limiter?: scalar|Param|null, // Rate limiter name to use for throttling requests. // Default: null + * caching?: bool|array{ // Caching configuration. + * enabled?: bool|Param, // Default: false + * cache_pool?: string|Param, // The taggable cache pool to use for storing the responses. // Default: "cache.http_client" + * shared?: bool|Param, // Indicates whether the cache is shared (public) or private. // Default: true + * max_ttl?: int|Param, // The maximum TTL (in seconds) allowed for cached responses. Null means no cap. // Default: null + * }, + * retry_failed?: bool|array{ + * enabled?: bool|Param, // Default: false + * retry_strategy?: scalar|Param|null, // service id to override the retry strategy. // Default: null + * http_codes?: int|string|array, + * }>, + * max_retries?: int|Param, // Default: 3 + * delay?: int|Param, // Time in ms to delay (or the initial value when multiplier is used). // Default: 1000 + * multiplier?: float|Param, // If greater than 1, delay will grow exponentially for each retry: delay * (multiple ^ retries). // Default: 2 + * max_delay?: int|Param, // Max time in ms that a retry should ever be delayed (0 = infinite). // Default: 0 + * jitter?: float|Param, // Randomness in percent (between 0 and 1) to apply to the delay. // Default: 0.1 + * }, + * }>, + * }, + * mailer?: bool|array{ // Mailer configuration + * enabled?: bool|Param, // Default: false + * message_bus?: scalar|Param|null, // The message bus to use. Defaults to the default bus if the Messenger component is installed. // Default: null + * dsn?: scalar|Param|null, // Default: null + * transports?: array, + * envelope?: array{ // Mailer Envelope configuration + * sender?: scalar|Param|null, + * recipients?: string|list, + * allowed_recipients?: string|list, + * }, + * headers?: array, + * dkim_signer?: bool|array{ // DKIM signer configuration + * enabled?: bool|Param, // Default: false + * key?: scalar|Param|null, // Key content, or path to key (in PEM format with the `file://` prefix) // Default: "" + * domain?: scalar|Param|null, // Default: "" + * select?: scalar|Param|null, // Default: "" + * passphrase?: scalar|Param|null, // The private key passphrase // Default: "" + * options?: array, + * }, + * smime_signer?: bool|array{ // S/MIME signer configuration + * enabled?: bool|Param, // Default: false + * key?: scalar|Param|null, // Path to key (in PEM format) // Default: "" + * certificate?: scalar|Param|null, // Path to certificate (in PEM format without the `file://` prefix) // Default: "" + * passphrase?: scalar|Param|null, // The private key passphrase // Default: null + * extra_certificates?: scalar|Param|null, // Default: null + * sign_options?: int|Param, // Default: null + * }, + * smime_encrypter?: bool|array{ // S/MIME encrypter configuration + * enabled?: bool|Param, // Default: false + * repository?: scalar|Param|null, // S/MIME certificate repository service. This service shall implement the `Symfony\Component\Mailer\EventListener\SmimeCertificateRepositoryInterface`. // Default: "" + * cipher?: int|Param, // A set of algorithms used to encrypt the message // Default: null + * }, + * }, + * secrets?: bool|array{ + * enabled?: bool|Param, // Default: true + * vault_directory?: scalar|Param|null, // Default: "%kernel.project_dir%/config/secrets/%kernel.runtime_environment%" + * local_dotenv_file?: scalar|Param|null, // Default: "%kernel.project_dir%/.env.%kernel.environment%.local" + * decryption_env_var?: scalar|Param|null, // Default: "base64:default::SYMFONY_DECRYPTION_SECRET" + * }, + * notifier?: bool|array{ // Notifier configuration + * enabled?: bool|Param, // Default: false + * message_bus?: scalar|Param|null, // The message bus to use. Defaults to the default bus if the Messenger component is installed. // Default: null + * chatter_transports?: array, + * texter_transports?: array, + * notification_on_failed_messages?: bool|Param, // Default: false + * channel_policy?: array>, + * admin_recipients?: list, + * }, + * rate_limiter?: bool|array{ // Rate limiter configuration + * enabled?: bool|Param, // Default: true + * limiters?: array, + * limit?: int|Param, // The maximum allowed hits in a fixed interval or burst. + * interval?: scalar|Param|null, // Configures the fixed interval if "policy" is set to "fixed_window" or "sliding_window". The value must be a number followed by "second", "minute", "hour", "day", "week" or "month" (or their plural equivalent). + * rate?: array{ // Configures the fill rate if "policy" is set to "token_bucket". + * interval?: scalar|Param|null, // Configures the rate interval. The value must be a number followed by "second", "minute", "hour", "day", "week" or "month" (or their plural equivalent). + * amount?: int|Param, // Amount of tokens to add each interval. // Default: 1 + * }, + * }>, + * }, + * uid?: bool|array{ // Uid configuration + * enabled?: bool|Param, // Default: true + * default_uuid_version?: 7|6|4|1|Param, // Default: 7 + * name_based_uuid_version?: 5|3|Param, // Default: 5 + * name_based_uuid_namespace?: scalar|Param|null, + * time_based_uuid_version?: 7|6|1|Param, // Default: 7 + * time_based_uuid_node?: scalar|Param|null, + * }, + * html_sanitizer?: bool|array{ // HtmlSanitizer configuration + * enabled?: bool|Param, // Default: false + * sanitizers?: array, + * block_elements?: string|list, + * drop_elements?: string|list, + * allow_attributes?: array, + * drop_attributes?: array, + * force_attributes?: array>, + * force_https_urls?: bool|Param, // Transforms URLs using the HTTP scheme to use the HTTPS scheme instead. // Default: false + * allowed_link_schemes?: string|list, + * allowed_link_hosts?: null|string|list, + * allow_relative_links?: bool|Param, // Allows relative URLs to be used in links href attributes. // Default: false + * allowed_media_schemes?: string|list, + * allowed_media_hosts?: null|string|list, + * allow_relative_medias?: bool|Param, // Allows relative URLs to be used in media source attributes (img, audio, video, ...). // Default: false + * with_attribute_sanitizers?: string|list, + * without_attribute_sanitizers?: string|list, + * max_input_length?: int|Param, // The maximum length allowed for the sanitized input. // Default: 0 + * }>, + * }, + * webhook?: bool|array{ // Webhook configuration + * enabled?: bool|Param, // Default: false + * message_bus?: scalar|Param|null, // The message bus to use. // Default: "messenger.default_bus" + * routing?: array, + * }, + * remote-event?: bool|array{ // RemoteEvent configuration + * enabled?: bool|Param, // Default: false + * }, + * json_streamer?: bool|array{ // JSON streamer configuration + * enabled?: bool|Param, // Default: false + * }, + * } + * @psalm-type DoctrineConfig = array{ + * dbal?: array{ + * default_connection?: scalar|Param|null, + * types?: array, + * driver_schemes?: array, + * connections?: array, + * mapping_types?: array, + * default_table_options?: array, + * schema_manager_factory?: scalar|Param|null, // Default: "doctrine.dbal.default_schema_manager_factory" + * result_cache?: scalar|Param|null, + * slaves?: array, + * replicas?: array, + * }>, + * }, + * orm?: array{ + * default_entity_manager?: scalar|Param|null, + * auto_generate_proxy_classes?: scalar|Param|null, // Auto generate mode possible values are: "NEVER", "ALWAYS", "FILE_NOT_EXISTS", "EVAL", "FILE_NOT_EXISTS_OR_CHANGED", this option is ignored when the "enable_native_lazy_objects" option is true // Default: false + * enable_lazy_ghost_objects?: bool|Param, // Enables the new implementation of proxies based on lazy ghosts instead of using the legacy implementation // Default: true + * enable_native_lazy_objects?: bool|Param, // Enables the new native implementation of PHP lazy objects instead of generated proxies // Default: false + * proxy_dir?: scalar|Param|null, // Configures the path where generated proxy classes are saved when using non-native lazy objects, this option is ignored when the "enable_native_lazy_objects" option is true // Default: "%kernel.build_dir%/doctrine/orm/Proxies" + * proxy_namespace?: scalar|Param|null, // Defines the root namespace for generated proxy classes when using non-native lazy objects, this option is ignored when the "enable_native_lazy_objects" option is true // Default: "Proxies" + * controller_resolver?: bool|array{ + * enabled?: bool|Param, // Default: true + * auto_mapping?: bool|Param|null, // Set to false to disable using route placeholders as lookup criteria when the primary key doesn't match the argument name // Default: null + * evict_cache?: bool|Param, // Set to true to fetch the entity from the database instead of using the cache, if any // Default: false + * }, + * entity_managers?: array, + * }>, + * }>, + * }, + * connection?: scalar|Param|null, + * class_metadata_factory_name?: scalar|Param|null, // Default: "Doctrine\\ORM\\Mapping\\ClassMetadataFactory" + * default_repository_class?: scalar|Param|null, // Default: "Doctrine\\ORM\\EntityRepository" + * auto_mapping?: scalar|Param|null, // Default: false + * naming_strategy?: scalar|Param|null, // Default: "doctrine.orm.naming_strategy.default" + * quote_strategy?: scalar|Param|null, // Default: "doctrine.orm.quote_strategy.default" + * typed_field_mapper?: scalar|Param|null, // Default: "doctrine.orm.typed_field_mapper.default" + * entity_listener_resolver?: scalar|Param|null, // Default: null + * fetch_mode_subselect_batch_size?: scalar|Param|null, + * repository_factory?: scalar|Param|null, // Default: "doctrine.orm.container_repository_factory" + * schema_ignore_classes?: list, + * report_fields_where_declared?: bool|Param, // Set to "true" to opt-in to the new mapping driver mode that was added in Doctrine ORM 2.16 and will be mandatory in ORM 3.0. See https://github.com/doctrine/orm/pull/10455. // Default: true + * validate_xml_mapping?: bool|Param, // Set to "true" to opt-in to the new mapping driver mode that was added in Doctrine ORM 2.14. See https://github.com/doctrine/orm/pull/6728. // Default: false + * second_level_cache?: array{ + * region_cache_driver?: string|array{ + * type?: scalar|Param|null, // Default: null + * id?: scalar|Param|null, + * pool?: scalar|Param|null, + * }, + * region_lock_lifetime?: scalar|Param|null, // Default: 60 + * log_enabled?: bool|Param, // Default: true + * region_lifetime?: scalar|Param|null, // Default: 3600 + * enabled?: bool|Param, // Default: true + * factory?: scalar|Param|null, + * regions?: array, + * loggers?: array, + * }, + * hydrators?: array, + * mappings?: array, + * dql?: array{ + * string_functions?: array, + * numeric_functions?: array, + * datetime_functions?: array, + * }, + * filters?: array, + * }>, + * identity_generation_preferences?: array, + * }>, + * resolve_target_entities?: array, + * }, + * } + * @psalm-type DoctrineMigrationsConfig = array{ + * enable_service_migrations?: bool|Param, // Whether to enable fetching migrations from the service container. // Default: false + * migrations_paths?: array, + * services?: array, + * factories?: array, + * storage?: array{ // Storage to use for migration status metadata. + * table_storage?: array{ // The default metadata storage, implemented as a table in the database. + * table_name?: scalar|Param|null, // Default: null + * version_column_name?: scalar|Param|null, // Default: null + * version_column_length?: scalar|Param|null, // Default: null + * executed_at_column_name?: scalar|Param|null, // Default: null + * execution_time_column_name?: scalar|Param|null, // Default: null + * }, + * }, + * migrations?: list, + * connection?: scalar|Param|null, // Connection name to use for the migrations database. // Default: null + * em?: scalar|Param|null, // Entity manager name to use for the migrations database (available when doctrine/orm is installed). // Default: null + * all_or_nothing?: scalar|Param|null, // Run all migrations in a transaction. // Default: false + * check_database_platform?: scalar|Param|null, // Adds an extra check in the generated migrations to allow execution only on the same platform as they were initially generated on. // Default: true + * custom_template?: scalar|Param|null, // Custom template path for generated migration classes. // Default: null + * organize_migrations?: scalar|Param|null, // Organize migrations mode. Possible values are: "BY_YEAR", "BY_YEAR_AND_MONTH", false // Default: false + * enable_profiler?: bool|Param, // Whether or not to enable the profiler collector to calculate and visualize migration status. This adds some queries overhead. // Default: false + * transactional?: bool|Param, // Whether or not to wrap migrations in a single transaction. // Default: true + * } + * @psalm-type SecurityConfig = array{ + * access_denied_url?: scalar|Param|null, // Default: null + * session_fixation_strategy?: "none"|"migrate"|"invalidate"|Param, // Default: "migrate" + * hide_user_not_found?: bool|Param, // Deprecated: The "hide_user_not_found" option is deprecated and will be removed in 8.0. Use the "expose_security_errors" option instead. + * expose_security_errors?: \Symfony\Component\Security\Http\Authentication\ExposeSecurityLevel::None|\Symfony\Component\Security\Http\Authentication\ExposeSecurityLevel::AccountStatus|\Symfony\Component\Security\Http\Authentication\ExposeSecurityLevel::All|Param, // Default: "none" + * erase_credentials?: bool|Param, // Default: true + * access_decision_manager?: array{ + * strategy?: "affirmative"|"consensus"|"unanimous"|"priority"|Param, + * service?: scalar|Param|null, + * strategy_service?: scalar|Param|null, + * allow_if_all_abstain?: bool|Param, // Default: false + * allow_if_equal_granted_denied?: bool|Param, // Default: true + * }, + * password_hashers?: array, + * hash_algorithm?: scalar|Param|null, // Name of hashing algorithm for PBKDF2 (i.e. sha256, sha512, etc..) See hash_algos() for a list of supported algorithms. // Default: "sha512" + * key_length?: scalar|Param|null, // Default: 40 + * ignore_case?: bool|Param, // Default: false + * encode_as_base64?: bool|Param, // Default: true + * iterations?: scalar|Param|null, // Default: 5000 + * cost?: int|Param, // Default: null + * memory_cost?: scalar|Param|null, // Default: null + * time_cost?: scalar|Param|null, // Default: null + * id?: scalar|Param|null, + * }>, + * providers?: array, + * }, + * entity?: array{ + * class?: scalar|Param|null, // The full entity class name of your user class. + * property?: scalar|Param|null, // Default: null + * manager_name?: scalar|Param|null, // Default: null + * }, + * memory?: array{ + * users?: array, + * }>, + * }, + * ldap?: array{ + * service?: scalar|Param|null, + * base_dn?: scalar|Param|null, + * search_dn?: scalar|Param|null, // Default: null + * search_password?: scalar|Param|null, // Default: null + * extra_fields?: list, + * default_roles?: string|list, + * role_fetcher?: scalar|Param|null, // Default: null + * uid_key?: scalar|Param|null, // Default: "sAMAccountName" + * filter?: scalar|Param|null, // Default: "({uid_key}={user_identifier})" + * password_attribute?: scalar|Param|null, // Default: null + * }, + * lexik_jwt?: array{ + * class?: scalar|Param|null, // Default: "Lexik\\Bundle\\JWTAuthenticationBundle\\Security\\User\\JWTUser" + * }, + * }>, + * firewalls?: array, + * security?: bool|Param, // Default: true + * user_checker?: scalar|Param|null, // The UserChecker to use when authenticating users in this firewall. // Default: "security.user_checker" + * request_matcher?: scalar|Param|null, + * access_denied_url?: scalar|Param|null, + * access_denied_handler?: scalar|Param|null, + * entry_point?: scalar|Param|null, // An enabled authenticator name or a service id that implements "Symfony\Component\Security\Http\EntryPoint\AuthenticationEntryPointInterface". + * provider?: scalar|Param|null, + * stateless?: bool|Param, // Default: false + * lazy?: bool|Param, // Default: false + * context?: scalar|Param|null, + * logout?: array{ + * enable_csrf?: bool|Param|null, // Default: null + * csrf_token_id?: scalar|Param|null, // Default: "logout" + * csrf_parameter?: scalar|Param|null, // Default: "_csrf_token" + * csrf_token_manager?: scalar|Param|null, + * path?: scalar|Param|null, // Default: "/logout" + * target?: scalar|Param|null, // Default: "/" + * invalidate_session?: bool|Param, // Default: true + * clear_site_data?: string|list<"*"|"cache"|"cookies"|"storage"|"executionContexts"|Param>, + * delete_cookies?: string|array, + * }, + * switch_user?: array{ + * provider?: scalar|Param|null, + * parameter?: scalar|Param|null, // Default: "_switch_user" + * role?: scalar|Param|null, // Default: "ROLE_ALLOWED_TO_SWITCH" + * target_route?: scalar|Param|null, // Default: null + * }, + * required_badges?: list, + * custom_authenticators?: list, + * login_throttling?: array{ + * limiter?: scalar|Param|null, // A service id implementing "Symfony\Component\HttpFoundation\RateLimiter\RequestRateLimiterInterface". + * max_attempts?: int|Param, // Default: 5 + * interval?: scalar|Param|null, // Default: "1 minute" + * lock_factory?: scalar|Param|null, // The service ID of the lock factory used by the login rate limiter (or null to disable locking). // Default: null + * cache_pool?: string|Param, // The cache pool to use for storing the limiter state // Default: "cache.rate_limiter" + * storage_service?: string|Param, // The service ID of a custom storage implementation, this precedes any configured "cache_pool" // Default: null + * }, + * x509?: array{ + * provider?: scalar|Param|null, + * user?: scalar|Param|null, // Default: "SSL_CLIENT_S_DN_Email" + * credentials?: scalar|Param|null, // Default: "SSL_CLIENT_S_DN" + * user_identifier?: scalar|Param|null, // Default: "emailAddress" + * }, + * remote_user?: array{ + * provider?: scalar|Param|null, + * user?: scalar|Param|null, // Default: "REMOTE_USER" + * }, + * jwt?: array{ + * provider?: scalar|Param|null, // Default: null + * authenticator?: scalar|Param|null, // Default: "lexik_jwt_authentication.security.jwt_authenticator" + * }, + * login_link?: array{ + * check_route?: scalar|Param|null, // Route that will validate the login link - e.g. "app_login_link_verify". + * check_post_only?: scalar|Param|null, // If true, only HTTP POST requests to "check_route" will be handled by the authenticator. // Default: false + * signature_properties?: list, + * lifetime?: int|Param, // The lifetime of the login link in seconds. // Default: 600 + * max_uses?: int|Param, // Max number of times a login link can be used - null means unlimited within lifetime. // Default: null + * used_link_cache?: scalar|Param|null, // Cache service id used to expired links of max_uses is set. + * success_handler?: scalar|Param|null, // A service id that implements Symfony\Component\Security\Http\Authentication\AuthenticationSuccessHandlerInterface. + * failure_handler?: scalar|Param|null, // A service id that implements Symfony\Component\Security\Http\Authentication\AuthenticationFailureHandlerInterface. + * provider?: scalar|Param|null, // The user provider to load users from. + * secret?: scalar|Param|null, // Default: "%kernel.secret%" + * always_use_default_target_path?: bool|Param, // Default: false + * default_target_path?: scalar|Param|null, // Default: "/" + * login_path?: scalar|Param|null, // Default: "/login" + * target_path_parameter?: scalar|Param|null, // Default: "_target_path" + * use_referer?: bool|Param, // Default: false + * failure_path?: scalar|Param|null, // Default: null + * failure_forward?: bool|Param, // Default: false + * failure_path_parameter?: scalar|Param|null, // Default: "_failure_path" + * }, + * form_login?: array{ + * provider?: scalar|Param|null, + * remember_me?: bool|Param, // Default: true + * success_handler?: scalar|Param|null, + * failure_handler?: scalar|Param|null, + * check_path?: scalar|Param|null, // Default: "/login_check" + * use_forward?: bool|Param, // Default: false + * login_path?: scalar|Param|null, // Default: "/login" + * username_parameter?: scalar|Param|null, // Default: "_username" + * password_parameter?: scalar|Param|null, // Default: "_password" + * csrf_parameter?: scalar|Param|null, // Default: "_csrf_token" + * csrf_token_id?: scalar|Param|null, // Default: "authenticate" + * enable_csrf?: bool|Param, // Default: false + * post_only?: bool|Param, // Default: true + * form_only?: bool|Param, // Default: false + * always_use_default_target_path?: bool|Param, // Default: false + * default_target_path?: scalar|Param|null, // Default: "/" + * target_path_parameter?: scalar|Param|null, // Default: "_target_path" + * use_referer?: bool|Param, // Default: false + * failure_path?: scalar|Param|null, // Default: null + * failure_forward?: bool|Param, // Default: false + * failure_path_parameter?: scalar|Param|null, // Default: "_failure_path" + * }, + * form_login_ldap?: array{ + * provider?: scalar|Param|null, + * remember_me?: bool|Param, // Default: true + * success_handler?: scalar|Param|null, + * failure_handler?: scalar|Param|null, + * check_path?: scalar|Param|null, // Default: "/login_check" + * use_forward?: bool|Param, // Default: false + * login_path?: scalar|Param|null, // Default: "/login" + * username_parameter?: scalar|Param|null, // Default: "_username" + * password_parameter?: scalar|Param|null, // Default: "_password" + * csrf_parameter?: scalar|Param|null, // Default: "_csrf_token" + * csrf_token_id?: scalar|Param|null, // Default: "authenticate" + * enable_csrf?: bool|Param, // Default: false + * post_only?: bool|Param, // Default: true + * form_only?: bool|Param, // Default: false + * always_use_default_target_path?: bool|Param, // Default: false + * default_target_path?: scalar|Param|null, // Default: "/" + * target_path_parameter?: scalar|Param|null, // Default: "_target_path" + * use_referer?: bool|Param, // Default: false + * failure_path?: scalar|Param|null, // Default: null + * failure_forward?: bool|Param, // Default: false + * failure_path_parameter?: scalar|Param|null, // Default: "_failure_path" + * service?: scalar|Param|null, // Default: "ldap" + * dn_string?: scalar|Param|null, // Default: "{user_identifier}" + * query_string?: scalar|Param|null, + * search_dn?: scalar|Param|null, // Default: "" + * search_password?: scalar|Param|null, // Default: "" + * }, + * json_login?: array{ + * provider?: scalar|Param|null, + * remember_me?: bool|Param, // Default: true + * success_handler?: scalar|Param|null, + * failure_handler?: scalar|Param|null, + * check_path?: scalar|Param|null, // Default: "/login_check" + * use_forward?: bool|Param, // Default: false + * login_path?: scalar|Param|null, // Default: "/login" + * username_path?: scalar|Param|null, // Default: "username" + * password_path?: scalar|Param|null, // Default: "password" + * }, + * json_login_ldap?: array{ + * provider?: scalar|Param|null, + * remember_me?: bool|Param, // Default: true + * success_handler?: scalar|Param|null, + * failure_handler?: scalar|Param|null, + * check_path?: scalar|Param|null, // Default: "/login_check" + * use_forward?: bool|Param, // Default: false + * login_path?: scalar|Param|null, // Default: "/login" + * username_path?: scalar|Param|null, // Default: "username" + * password_path?: scalar|Param|null, // Default: "password" + * service?: scalar|Param|null, // Default: "ldap" + * dn_string?: scalar|Param|null, // Default: "{user_identifier}" + * query_string?: scalar|Param|null, + * search_dn?: scalar|Param|null, // Default: "" + * search_password?: scalar|Param|null, // Default: "" + * }, + * access_token?: array{ + * provider?: scalar|Param|null, + * remember_me?: bool|Param, // Default: true + * success_handler?: scalar|Param|null, + * failure_handler?: scalar|Param|null, + * realm?: scalar|Param|null, // Default: null + * token_extractors?: string|list, + * token_handler?: string|array{ + * id?: scalar|Param|null, + * oidc_user_info?: string|array{ + * base_uri?: scalar|Param|null, // Base URI of the userinfo endpoint on the OIDC server, or the OIDC server URI to use the discovery (require "discovery" to be configured). + * discovery?: array{ // Enable the OIDC discovery. + * cache?: array{ + * id?: scalar|Param|null, // Cache service id to use to cache the OIDC discovery configuration. + * }, + * }, + * claim?: scalar|Param|null, // Claim which contains the user identifier (e.g. sub, email, etc.). // Default: "sub" + * client?: scalar|Param|null, // HttpClient service id to use to call the OIDC server. + * }, + * oidc?: array{ + * discovery?: array{ // Enable the OIDC discovery. + * base_uri?: string|list, + * cache?: array{ + * id?: scalar|Param|null, // Cache service id to use to cache the OIDC discovery configuration. + * }, + * }, + * claim?: scalar|Param|null, // Claim which contains the user identifier (e.g.: sub, email..). // Default: "sub" + * audience?: scalar|Param|null, // Audience set in the token, for validation purpose. + * issuers?: list, + * algorithm?: array, + * algorithms?: list, + * key?: scalar|Param|null, // Deprecated: The "key" option is deprecated and will be removed in 8.0. Use the "keyset" option instead. // JSON-encoded JWK used to sign the token (must contain a "kty" key). + * keyset?: scalar|Param|null, // JSON-encoded JWKSet used to sign the token (must contain a list of valid public keys). + * encryption?: bool|array{ + * enabled?: bool|Param, // Default: false + * enforce?: bool|Param, // When enabled, the token shall be encrypted. // Default: false + * algorithms?: list, + * keyset?: scalar|Param|null, // JSON-encoded JWKSet used to decrypt the token (must contain a list of valid private keys). + * }, + * }, + * cas?: array{ + * validation_url?: scalar|Param|null, // CAS server validation URL + * prefix?: scalar|Param|null, // CAS prefix // Default: "cas" + * http_client?: scalar|Param|null, // HTTP Client service // Default: null + * }, + * oauth2?: scalar|Param|null, + * }, + * }, + * http_basic?: array{ + * provider?: scalar|Param|null, + * realm?: scalar|Param|null, // Default: "Secured Area" + * }, + * http_basic_ldap?: array{ + * provider?: scalar|Param|null, + * realm?: scalar|Param|null, // Default: "Secured Area" + * service?: scalar|Param|null, // Default: "ldap" + * dn_string?: scalar|Param|null, // Default: "{user_identifier}" + * query_string?: scalar|Param|null, + * search_dn?: scalar|Param|null, // Default: "" + * search_password?: scalar|Param|null, // Default: "" + * }, + * remember_me?: array{ + * secret?: scalar|Param|null, // Default: "%kernel.secret%" + * service?: scalar|Param|null, + * user_providers?: string|list, + * catch_exceptions?: bool|Param, // Default: true + * signature_properties?: list, + * token_provider?: string|array{ + * service?: scalar|Param|null, // The service ID of a custom remember-me token provider. + * doctrine?: bool|array{ + * enabled?: bool|Param, // Default: false + * connection?: scalar|Param|null, // Default: null + * }, + * }, + * token_verifier?: scalar|Param|null, // The service ID of a custom rememberme token verifier. + * name?: scalar|Param|null, // Default: "REMEMBERME" + * lifetime?: int|Param, // Default: 31536000 + * path?: scalar|Param|null, // Default: "/" + * domain?: scalar|Param|null, // Default: null + * secure?: true|false|"auto"|Param, // Default: false + * httponly?: bool|Param, // Default: true + * samesite?: null|"lax"|"strict"|"none"|Param, // Default: null + * always_remember_me?: bool|Param, // Default: false + * remember_me_parameter?: scalar|Param|null, // Default: "_remember_me" + * }, + * }>, + * access_control?: list, + * attributes?: array, + * route?: scalar|Param|null, // Default: null + * methods?: string|list, + * allow_if?: scalar|Param|null, // Default: null + * roles?: string|list, + * }>, + * role_hierarchy?: array>, + * } + * @psalm-type LexikJwtAuthenticationConfig = array{ + * public_key?: scalar|Param|null, // The key used to sign tokens (useless for HMAC). If not set, the key will be automatically computed from the secret key. // Default: null + * additional_public_keys?: list, + * secret_key?: scalar|Param|null, // The key used to sign tokens. It can be a raw secret (for HMAC), a raw RSA/ECDSA key or the path to a file itself being plaintext or PEM. // Default: null + * pass_phrase?: scalar|Param|null, // The key passphrase (useless for HMAC) // Default: "" + * token_ttl?: scalar|Param|null, // Default: 3600 + * allow_no_expiration?: bool|Param, // Allow tokens without "exp" claim (i.e. indefinitely valid, no lifetime) to be considered valid. Caution: usage of this should be rare. // Default: false + * clock_skew?: scalar|Param|null, // Default: 0 + * encoder?: array{ + * service?: scalar|Param|null, // Default: "lexik_jwt_authentication.encoder.lcobucci" + * signature_algorithm?: scalar|Param|null, // Default: "RS256" + * }, + * user_id_claim?: scalar|Param|null, // Default: "username" + * token_extractors?: array{ + * authorization_header?: bool|array{ + * enabled?: bool|Param, // Default: true + * prefix?: scalar|Param|null, // Default: "Bearer" + * name?: scalar|Param|null, // Default: "Authorization" + * }, + * cookie?: bool|array{ + * enabled?: bool|Param, // Default: false + * name?: scalar|Param|null, // Default: "BEARER" + * }, + * query_parameter?: bool|array{ + * enabled?: bool|Param, // Default: false + * name?: scalar|Param|null, // Default: "bearer" + * }, + * split_cookie?: bool|array{ + * enabled?: bool|Param, // Default: false + * cookies?: list, + * }, + * }, + * remove_token_from_body_when_cookies_used?: scalar|Param|null, // Default: true + * set_cookies?: array, + * }>, + * api_platform?: bool|array{ // API Platform compatibility: add check_path in OpenAPI documentation. + * enabled?: bool|Param, // Default: false + * check_path?: scalar|Param|null, // The login check path to add in OpenAPI. // Default: null + * username_path?: scalar|Param|null, // The path to the username in the JSON body. // Default: null + * password_path?: scalar|Param|null, // The path to the password in the JSON body. // Default: null + * }, + * access_token_issuance?: bool|array{ + * enabled?: bool|Param, // Default: false + * signature?: array{ + * algorithm?: scalar|Param|null, // The algorithm use to sign the access tokens. + * key?: scalar|Param|null, // The signature key. It shall be JWK encoded. + * }, + * encryption?: bool|array{ + * enabled?: bool|Param, // Default: false + * key_encryption_algorithm?: scalar|Param|null, // The key encryption algorithm is used to encrypt the token. + * content_encryption_algorithm?: scalar|Param|null, // The key encryption algorithm is used to encrypt the token. + * key?: scalar|Param|null, // The encryption key. It shall be JWK encoded. + * }, + * }, + * access_token_verification?: bool|array{ + * enabled?: bool|Param, // Default: false + * signature?: array{ + * header_checkers?: list, + * claim_checkers?: list, + * mandatory_claims?: list, + * allowed_algorithms?: list, + * keyset?: scalar|Param|null, // The signature keyset. It shall be JWKSet encoded. + * }, + * encryption?: bool|array{ + * enabled?: bool|Param, // Default: false + * continue_on_decryption_failure?: bool|Param, // If enable, non-encrypted tokens or tokens that failed during decryption or verification processes are accepted. // Default: false + * header_checkers?: list, + * allowed_key_encryption_algorithms?: list, + * allowed_content_encryption_algorithms?: list, + * keyset?: scalar|Param|null, // The encryption keyset. It shall be JWKSet encoded. + * }, + * }, + * blocklist_token?: bool|array{ + * enabled?: bool|Param, // Default: false + * cache?: scalar|Param|null, // Storage to track blocked tokens // Default: "cache.app" + * }, + * } + * @psalm-type NelmioCorsConfig = array{ + * defaults?: array{ + * allow_credentials?: bool|Param, // Default: false + * allow_origin?: list, + * allow_headers?: list, + * allow_methods?: list, + * allow_private_network?: bool|Param, // Default: false + * expose_headers?: list, + * max_age?: scalar|Param|null, // Default: 0 + * hosts?: list, + * origin_regex?: bool|Param, // Default: false + * forced_allow_origin_value?: scalar|Param|null, // Default: null + * skip_same_as_origin?: bool|Param, // Default: true + * }, + * paths?: array, + * allow_headers?: list, + * allow_methods?: list, + * allow_private_network?: bool|Param, + * expose_headers?: list, + * max_age?: scalar|Param|null, // Default: 0 + * hosts?: list, + * origin_regex?: bool|Param, + * forced_allow_origin_value?: scalar|Param|null, // Default: null + * skip_same_as_origin?: bool|Param, + * }>, + * } + * @psalm-type DebugConfig = array{ + * max_items?: int|Param, // Max number of displayed items past the first level, -1 means no limit. // Default: 2500 + * min_depth?: int|Param, // Minimum tree depth to clone all the items, 1 is default. // Default: 1 + * max_string_length?: int|Param, // Max length of displayed strings, -1 means no limit. // Default: -1 + * dump_destination?: scalar|Param|null, // A stream URL where dumps should be written to. // Default: null + * theme?: "dark"|"light"|Param, // Changes the color of the dump() output when rendered directly on the templating. "dark" (default) or "light". // Default: "dark" + * } + * @psalm-type MakerConfig = array{ + * root_namespace?: scalar|Param|null, // Default: "App" + * generate_final_classes?: bool|Param, // Default: true + * generate_final_entities?: bool|Param, // Default: false + * } + * @psalm-type NelmioApiDocConfig = array{ + * type_info?: bool|Param, // Use the symfony/type-info component for determining types. // Default: true + * use_validation_groups?: bool|Param, // If true, `groups` passed to #[Model] attributes will be used to limit validation constraints // Default: false + * operation_id_generation?: \Nelmio\ApiDocBundle\Describer\OperationIdGeneration::ALWAYS_PREPEND|\Nelmio\ApiDocBundle\Describer\OperationIdGeneration::CONDITIONALLY_PREPEND|\Nelmio\ApiDocBundle\Describer\OperationIdGeneration::NO_PREPEND|"always_prepend"|"conditionally_prepend"|"no_prepend"|Param, // How to generate operation ids // Default: "always_prepend" + * cache?: array{ + * pool?: scalar|Param|null, // define cache pool to use // Default: null + * item_id?: scalar|Param|null, // define cache item id // Default: null + * }, + * documentation?: array, + * media_types?: list, + * html_config?: array{ // UI configuration options + * assets_mode?: scalar|Param|null, // Default: "cdn" + * swagger_ui_config?: array, + * redocly_config?: array, + * scalar_config?: array, + * stoplight_config?: array, + * }, + * areas?: array, + * host_patterns?: list, + * name_patterns?: list, + * security?: array + * }>, + * with_attribute?: bool|Param, // whether to filter by attributes // Default: false + * disable_default_routes?: bool|Param, // if set disables default routes without attributes // Default: false + * documentation?: array, + * cache?: array{ + * pool?: scalar|Param|null, // define cache pool to use // Default: null + * item_id?: scalar|Param|null, // define cache item id // Default: null + * }, + * }>, + * models?: array{ + * use_jms?: bool|Param, // Default: false + * names?: list, + * areas?: list, + * }>, + * }, + * } + * @psalm-type ConfigType = array{ + * imports?: ImportsConfig, + * parameters?: ParametersConfig, + * services?: ServicesConfig, + * framework?: FrameworkConfig, + * doctrine?: DoctrineConfig, + * doctrine_migrations?: DoctrineMigrationsConfig, + * security?: SecurityConfig, + * lexik_jwt_authentication?: LexikJwtAuthenticationConfig, + * nelmio_cors?: NelmioCorsConfig, + * nelmio_api_doc?: NelmioApiDocConfig, + * "when@dev"?: array{ + * imports?: ImportsConfig, + * parameters?: ParametersConfig, + * services?: ServicesConfig, + * framework?: FrameworkConfig, + * doctrine?: DoctrineConfig, + * doctrine_migrations?: DoctrineMigrationsConfig, + * security?: SecurityConfig, + * lexik_jwt_authentication?: LexikJwtAuthenticationConfig, + * nelmio_cors?: NelmioCorsConfig, + * debug?: DebugConfig, + * maker?: MakerConfig, + * nelmio_api_doc?: NelmioApiDocConfig, + * }, + * "when@prod"?: array{ + * imports?: ImportsConfig, + * parameters?: ParametersConfig, + * services?: ServicesConfig, + * framework?: FrameworkConfig, + * doctrine?: DoctrineConfig, + * doctrine_migrations?: DoctrineMigrationsConfig, + * security?: SecurityConfig, + * lexik_jwt_authentication?: LexikJwtAuthenticationConfig, + * nelmio_cors?: NelmioCorsConfig, + * nelmio_api_doc?: NelmioApiDocConfig, + * }, + * "when@test"?: array{ + * imports?: ImportsConfig, + * parameters?: ParametersConfig, + * services?: ServicesConfig, + * framework?: FrameworkConfig, + * doctrine?: DoctrineConfig, + * doctrine_migrations?: DoctrineMigrationsConfig, + * security?: SecurityConfig, + * lexik_jwt_authentication?: LexikJwtAuthenticationConfig, + * nelmio_cors?: NelmioCorsConfig, + * nelmio_api_doc?: NelmioApiDocConfig, + * }, + * ..., + * }> + * } + */ +final class App +{ + /** + * @param ConfigType $config + * + * @psalm-return ConfigType + */ + public static function config(array $config): array + { + /** @var ConfigType $config */ + $config = AppReference::config($config); + + return $config; + } +} + +namespace Symfony\Component\Routing\Loader\Configurator; + +/** + * This class provides array-shapes for configuring the routes of an application. + * + * Example: + * + * ```php + * // config/routes.php + * namespace Symfony\Component\Routing\Loader\Configurator; + * + * return Routes::config([ + * 'controllers' => [ + * 'resource' => 'routing.controllers', + * ], + * ]); + * ``` + * + * @psalm-type RouteConfig = array{ + * path: string|array, + * controller?: string, + * methods?: string|list, + * requirements?: array, + * defaults?: array, + * options?: array, + * host?: string|array, + * schemes?: string|list, + * condition?: string, + * locale?: string, + * format?: string, + * utf8?: bool, + * stateless?: bool, + * } + * @psalm-type ImportConfig = array{ + * resource: string, + * type?: string, + * exclude?: string|list, + * prefix?: string|array, + * name_prefix?: string, + * trailing_slash_on_root?: bool, + * controller?: string, + * methods?: string|list, + * requirements?: array, + * defaults?: array, + * options?: array, + * host?: string|array, + * schemes?: string|list, + * condition?: string, + * locale?: string, + * format?: string, + * utf8?: bool, + * stateless?: bool, + * } + * @psalm-type AliasConfig = array{ + * alias: string, + * deprecated?: array{package:string, version:string, message?:string}, + * } + * @psalm-type RoutesConfig = array{ + * "when@dev"?: array, + * "when@prod"?: array, + * "when@test"?: array, + * ... + * } + */ +final class Routes +{ + /** + * @param RoutesConfig $config + * + * @psalm-return RoutesConfig + */ + public static function config(array $config): array + { + return $config; + } +} diff --git a/config/routes.yaml b/config/routes.yaml new file mode 100644 index 00000000..cef258cd --- /dev/null +++ b/config/routes.yaml @@ -0,0 +1,11 @@ +# yaml-language-server: $schema=../vendor/symfony/routing/Loader/schema/routing.schema.json + +# This file is the entry point to configure the routes of your app. +# Methods with the #[Route] attribute are automatically imported. +# See also https://symfony.com/doc/current/routing.html + +# To list all registered routes, run the following command: +# bin/console debug:router + +controllers: + resource: routing.controllers diff --git a/config/routes/framework.yaml b/config/routes/framework.yaml new file mode 100644 index 00000000..bc1feace --- /dev/null +++ b/config/routes/framework.yaml @@ -0,0 +1,4 @@ +when@dev: + _errors: + resource: '@FrameworkBundle/Resources/config/routing/errors.php' + prefix: /_error diff --git a/config/routes/nelmio_api_doc.yaml b/config/routes/nelmio_api_doc.yaml new file mode 100644 index 00000000..f6301245 --- /dev/null +++ b/config/routes/nelmio_api_doc.yaml @@ -0,0 +1,11 @@ +app.swagger_ui: + path: /api/doc + methods: GET + defaults: + _controller: nelmio_api_doc.controller.swagger_ui + +app.swagger_json: + path: /api/doc.json + methods: GET + defaults: + _controller: nelmio_api_doc.controller.swagger diff --git a/config/routes/security.yaml b/config/routes/security.yaml new file mode 100644 index 00000000..f853be15 --- /dev/null +++ b/config/routes/security.yaml @@ -0,0 +1,3 @@ +_security_logout: + resource: security.route_loader.logout + type: service diff --git a/config/services.yaml b/config/services.yaml new file mode 100644 index 00000000..b4b17b87 --- /dev/null +++ b/config/services.yaml @@ -0,0 +1,75 @@ +# yaml-language-server: $schema=../vendor/symfony/dependency-injection/Loader/schema/services.schema.json + +# This file is the entry point to configure your own services. +# Files in the packages/ subdirectory configure your dependencies. +# See also https://symfony.com/doc/current/service_container/import.html + +# Put parameters here that don't need to change on each machine where the app is deployed +# https://symfony.com/doc/current/best_practices.html#use-parameters-for-application-configuration +parameters: {} + +services: + # default configuration for services in *this* file + _defaults: + autowire: true # Automatically injects dependencies in your services. + autoconfigure: true # Automatically registers your services as commands, event subscribers, etc. + + # makes classes in src/ available to be used as services + # this creates a service per class whose id is the fully-qualified class name + App\: + resource: '../src/' + + App\Doctor\Controller\DoctorController: + arguments: + $projectDir: '%kernel.project_dir%' + + App\Clinic\Controller\ClinicController: + arguments: + $projectDir: '%kernel.project_dir%' + + App\Auth\Service\OtpService: + arguments: + $otpTtl: '%env(int:OTP_TTL)%' + $appEnv: '%kernel.environment%' + + App\Auth\Service\TokenService: + arguments: + $refreshTokenTtl: '%env(int:REFRESH_TOKEN_TTL)%' + + App\Auth\Security\PasswordAuthenticator: + arguments: + $refreshTokenTtl: '%env(int:REFRESH_TOKEN_TTL)%' + $loginLimiter: '@limiter.login' + + App\Auth\Controller\AuthController: + arguments: + $sendCodeLimiter: '@limiter.send_code' + + App\Payment\Gateway\MellatGateway: + arguments: + $terminalId: '%env(MELLAT_TERMINAL_ID)%' + $username: '%env(MELLAT_USERNAME)%' + $password: '%env(MELLAT_PASSWORD)%' + + App\Payment\Gateway\SepGateway: + arguments: + $terminalId: '%env(SEP_TERMINAL_ID)%' + + App\Payment\Controller\PaymentController: + arguments: + $appBaseUrl: '%env(APP_BASE_URL)%' + $allowedFrontendHosts: '%env(ALLOWED_FRONTEND_HOSTS)%' + + App\Sms\Provider\KavehNegarProvider: + arguments: + $apiKey: '%env(KAVENEGAR_API_KEY)%' + $sender: '%env(KAVENEGAR_SENDER)%' + + App\Sms\Provider\RanginehProvider: + arguments: + $apiKey: '%env(RANGINEH_API_KEY)%' + $sender: '%env(RANGINEH_SENDER)%' + + App\Blog\Controller\BlogController: + arguments: + $projectDir: '%kernel.project_dir%' diff --git a/docs/Architecture_Audit.md b/docs/Architecture_Audit.md new file mode 100644 index 00000000..15b713f4 --- /dev/null +++ b/docs/Architecture_Audit.md @@ -0,0 +1,823 @@ +# Architecture Audit — ClinicPro Symfony 7 Migration + +**تاریخ:** ۱۴۰۵/۰۳/۱۸ +**بررسی‌کننده:** Senior Software Architect +**نسخه مستند:** ۱.۰ + +--- + +## Executive Summary + +پروژه **ClinicPro** یک مهاجرت از Drupal به Symfony 7 است. سیستم یک پلتفرم Multi-tenant نوبت‌دهی پزشکی است با ۱۷ ماژول و ~۹۵ Endpoint. + +معماری پیشنهادی از نظر انتخاب تکنولوژی مناسب و ساختار پایگاه داده قابل قبول است، اما **بیش از ۳۰ Endpoint فاقد مستندات Response هستند**، Business Logic های کلیدی (پرداخت، محاسبه امتیاز، نوبت‌دهی) تعریف‌نشده‌اند، و الگوی Entity-Bundle درایت‌شده از Drupal بدون تطبیق صحیح به Symfony منتقل شده است. + +--- + +## Architecture Score (بعد از اصلاحات) + +``` +Overall Score: 78 / 100 ↑ از 61 +``` + +| بُعد | امتیاز قبل | امتیاز بعد | تغییرات | +|------|-----------|-----------|---------| +| Scalability | 55/100 | 60/100 | Messenger async، Redis cache پیش‌بینی شد | +| Security | 65/100 | **82/100** | Refresh Token، hash_equals، CORS fix، Security Headers، Audit Log، Open Redirect fix | +| Maintainability | 60/100 | 80/100 | Domain-Driven structure، DTO، Error Codes، Response format یکپارچه | +| Performance | 58/100 | 65/100 | Eager loading، Redis cache plan | +| Reliability | 55/100 | 72/100 | Circuit Breaker، Idempotency، Payment flow کامل | + +--- + +## ۱. تطابق معماری با PRD + +### ماژول‌های شناسایی‌شده در PRD + +| # | ماژول | Endpoint ها | وضعیت در معماری | +|---|--------|------------|-----------------| +| ۱ | Authentication (احراز هویت) | ۸ | ✅ تعریف شده | +| ۲ | User Profile (پروفایل کاربر) | ۴ | ✅ تعریف شده | +| ۳ | Blog (وبلاگ) | ۷ | ✅ تعریف شده | +| ۴ | Doctor (دکتر) | ۱۰ | ✅ تعریف شده | +| ۵ | Clinic (کلینیک) | ۷ | ✅ تعریف شده | +| ۶ | Agent (نماینده) | ۳ | ✅ تعریف شده | +| ۷ | Categories (دسته‌بندی‌ها) | ۱۰ | ✅ تعریف شده | +| ۸ | Doctor Insurance (بیمه دکتر) | ۴ | ✅ تعریف شده | +| ۹ | Appointment Settings (تنظیمات نوبت) | ۱۱ | ✅ تعریف شده | +| ۱۰ | Appointment (نوبت‌دهی) | ۴ | ✅ تعریف شده | +| ۱۱ | Payment (پرداخت) | ۳ | ✅ تعریف شده | +| ۱۲ | Rating & Comments (امتیاز و نظرات) | ۱۲ | ✅ تعریف شده | +| ۱۳ | Likes (لایک) | ۲ | ✅ تعریف شده | +| ۱۴ | Secretary (منشی) | ۵ | ✅ تعریف شده | +| ۱۵ | Representation Dashboard (داشبورد) | ۵ | ✅ تعریف شده | +| ۱۶ | SMS | — | ⚠️ بدون task.md | +| ۱۷ | File Upload | — | ⚠️ Embedded در سایر ماژول‌ها | +| — | Subscription Payments | — | ❌ Endpoint تعریف نشده | +| — | Health Check | — | ❌ اصلاً وجود ندارد | +| — | Refresh Token | — | ❌ وجود ندارد | + +### موارد پوشش‌داده‌نشده از PRD + +- **Subscription Payment Endpoints** — جدول `subscription_payments` وجود دارد اما هیچ endpoint برای مدیریت آن نیست +- **City-specific management** — City bundle دارای ۸+ فیلد توسعه‌یافته (domain، SEO، footer) است که هیچ endpoint ای برای مدیریت آنها نیست +- **Comment Nesting** — فیلد `field_parent` در DB وجود دارد اما در API تعریف نشده +- **Appointment Cancellation Flow** — لغو نوبت و refund پرداخت مستند نشده + +--- + +## ۲. تحلیل معماری فعلی + +### Scalability + +**نقاط قوت:** +- UUID در public API — امکان sharding در آینده را حفظ می‌کند +- Redis برای OTP — scalable و stateless + +**نقاط ضعف:** +- Single MySQL instance — هیچ read replica تعریف نشده؛ با رشد کاربر، query های سنگین لیست دکتر/کلینیک با فیلتر چندگانه روی master اجرا می‌شوند +- JSON columns بدون index — فیلدهایی مثل `weekly_schedules.setting` و `appointments.slot` با JSON ذخیره می‌شوند اما قابل index نیستند؛ جستجو روی آنها Full Table Scan است +- هیچ cache strategy فراتر از OTP وجود ندارد — لیست دسته‌بندی‌ها، تخصص‌ها، استان‌ها با هر request از DB خوانده می‌شوند + +### Maintainability + +**نقاط قوت:** +- Task decomposition منطقی با dependency graph مشخص +- UUID، timestamps، naming convention یکپارچه + +**نقاط ضعف:** +- ۳۰+ endpoint بدون response schema — هر developer می‌تواند خروجی متفاوتی بسازد +- نام‌گذاری ناسازگار: `img` در doctor، `images_clinic` در clinic، `field_image` در blog +- Business logic (فرمول rating، منطق free_turn) مستند نشده + +### Security + +**نقاط قوت:** +- OTP-based login — بدون password در پیام +- JWT با TTL مشخص (3600s) +- Rate limiting با Redis (50 req/hr per IP، 30 req/hr per mobile) +- MIME type validation در file upload + +**نقاط ضعف:** +- بدون Refresh Token — کاربر هر ساعت باید re-login کند یا OTP مجدد دریافت کند +- CSRF inconsistent — در بعضی endpoint ها الزامی، در بعضی خیر +- بدون audit log — هیچ‌جا ثبت نمی‌شود چه کسی چه تغییری داده +- Secrets در `.env` — برای production باید Vault یا محیط CI/CD مدیریت شود + +### Performance + +**نقاط قوت:** +- Index های مناسب روی uuid، mobile_number، created_at، doctor_id + start_time +- Redis برای OTP (نه DB) + +**نقاط ضعف:** +- N+1 Query احتمالی — response دکتر شامل specialties، expertise، address، state، city است؛ بدون eager loading، هر doctor یک batch جداگانه query ایجاد می‌کند +- بدون Query Result Cache — categories، lookups با هر request از DB خوانده می‌شوند + +### Reliability + +**نقاط قوت:** +- Status machine واضح برای appointments و payments +- Soft delete با deleted_at + +**نقاط ضعف:** +- Payment gateway single point of failure — اگر Mellat یا SEP در دسترس نباشد، سیستم نوبت‌دهی متوقف می‌شود +- SMS sync — اگر KavehNegar/Rangineh fail شود، OTP ارسال نمی‌شود و کاربر مسدود می‌شود +- بدون Circuit Breaker برای external services + +### Testability + +**نقاط ضعف:** +- هیچ اشاره‌ای به test strategy نشده +- Business logic در کجا؟ اگر در Controller باشد، unit test غیرممکن می‌شود +- هیچ fixture/seeder برای category data (۳۱ استان + شهرها) تعریف نشده + +### Observability + +**ضعف کامل:** +- بدون logging strategy +- بدون health check endpoint +- بدون metrics (Prometheus/Grafana) +- بدون distributed tracing +- بدون alerting + +--- + +## ۳. تحلیل Design Patterns + +### Pattern هایی که استفاده شده‌اند + +| Pattern | کجا | ارزیابی | +|---------|-----|---------| +| Repository Pattern | ضمنی از Doctrine | ✅ درست اما باید صریح تعریف شود | +| DTO | اشاره نشده | ❌ باید اضافه شود | +| Strategy | payment gateways (Mellat/SEP) | ⚠️ تعریف نشده اما ضروری است | +| Observer/Event | SMS async | ❌ وجود ندارد — باید با Symfony Messenger پیاده شود | +| Status Machine | appointments/payments | ✅ خوب تعریف شده | +| Multi-tenant (Representation) | داشبورد | ✅ معقول | + +### Anti-Pattern هایی که مشاهده می‌شوند + +**۱. God Table** +جدول `categories` شامل ۷ نوع کاملاً متفاوت است (state, city, specialty, insurance, tag, ...). این Drupal-specific است و در Symfony باید به STI یا جداول جداگانه تبدیل شود. + +**۲. Anemic Domain Model** +Entity ها فقط data holder هستند. هیچ Business Logic در آنها نیست. اگر همه منطق در Controller باشد، Fat Controller anti-pattern اجتناب‌ناپذیر است. + +**۳. Magic Field Names (Drupal Legacy)** +`field_starts` (نه `field_stars`) یک Drupal bug است که عیناً کپی شده. در Symfony باید در mapping layer تبدیل شود، نه مستقیم در Entity. + +**۴. Implicit API Contract** +هیچ DTO برای Input/Output تعریف نشده. هر Controller می‌تواند هر فرمتی برگرداند. + +--- + +## ۴. تحلیل Domain Design + +### Domain Model ها + +| Domain | Entity ها | وضعیت | +|--------|----------|--------| +| Identity | User | ✅ خوب — uuid، mobile، realname، roles | +| Medical | Doctor، Clinic، DoctorAddress | ✅ معقول | +| Scheduling | WeeklySchedule، DateOverride، Holiday | ✅ خوب | +| Booking | Appointment، Slot | ⚠️ فلوی کامل مستند نشده | +| Financial | Payment، SubscriptionPayment | ⚠️ subscription endpoints مفقود | +| Community | Rating، Comment، Like | ✅ ساختار خوب | +| Catalog | Category (god table) | ❌ باید refactor شود | +| Tenancy | Representation، Agent | ✅ معقول | + +### Bounded Context ها + +مشکل اصلی: **Bounded Context های صریح تعریف نشده‌اند.** + +در Drupal، همه چیز در یک entity type است (`clinic_pro`). در Symfony باید مرزهای مشخص بین: +- **Identity Context** (User، Auth، OTP) +- **Clinical Context** (Doctor، Clinic، Address) +- **Scheduling Context** (WeeklySchedule، Appointment) +- **Financial Context** (Payment، Subscription) +- **Community Context** (Rating، Comment، Like) +- **Catalog Context** (Categories، Lookups) +- **Tenant Context** (Representation، Agent) + +### Separation of Concerns + +**مشکل:** در هیچ‌جا تعریف نشده Business Logic کجا قرار می‌گیرد: +- محاسبه `free_turn` — Controller؟ Service؟ Entity؟ +- محاسبه `experience` از `activity_time` — کجا؟ +- فرمول rating — کجا؟ + +بدون تعریف صریح این، هر developer به سلیقه خود عمل می‌کند. + +--- + +## ۵. تحلیل Database + +### جداول شناسایی‌شده (۳۲ جدول) + +**User Management:** +- `users` — uuid، mobile، password، realname، roles (JSON)، status (TINYINT)، created_at/updated_at (INT) +- `user_profiles` — سوابق پزشکی، آلرژی، دارو، جراحی + +**Clinical Entities:** +- `doctors`، `doctor_addresses`، `doctor_specialties`، `doctor_services`، `doctor_states`، `doctor_cities` +- `clinics`، `clinic_doctors`، `clinic_specialties`، `clinic_services`، `clinic_insurances`، `clinic_images` +- `representations`، `doctor_secretaries` + +**Scheduling & Booking:** +- `weekly_schedules`، `date_overrides`، `holidays`، `appointments` + +**Financial:** +- `payments`، `subscription_payments`، `doctor_insurance` + +**Content & Community:** +- `blogs`، `ratings`، `comments`، `likes` + +**Catalog:** +- `categories` (god table با bundle field) + +**System:** +- `files`، `sms_logs` + +### مشکلات Database + +**۱. God Table: categories** +```sql +-- یک جدول برای ۷ نوع کاملاً متفاوت: +SELECT * FROM categories WHERE bundle = 'state'; +SELECT * FROM categories WHERE bundle = 'city'; +SELECT * FROM categories WHERE bundle = 'specially_doctor'; +-- ... +``` +پیشنهاد: STI با Doctrine Inheritance یا جداول جداگانه برای هر نوع + +**۲. JSON Columns بدون Index** +```sql +-- weekly_schedules.setting → JSON (7-day schedule) +-- appointments.slot → JSON (time، duration، location_id) +-- doctor_secretaries.permission → JSON (undefined structure) +``` +این فیلدها قابل index نیستند. جستجو روی آنها Full Table Scan است. + +**۳. Timestamp به عنوان INT** +تمام `created_at`/`updated_at` به صورت Unix timestamp (INT) ذخیره می‌شوند. +این درست اما مستعد اشتباه است — باید در همه جا consistent باشد. + +**۴. Bottleneck احتمالی** +- `appointments` جدول داغ است (read/write زیاد) — index composite روی `(doctor_id, start_time)` لازم است +- `ratings` باید aggregate view داشته باشد برای `average_rate` تا N+1 نشود + +### ایندکس‌های مناسب + +```sql +-- اضافه کردن این ایندکس‌ها توصیه می‌شود: +CREATE INDEX idx_appointments_doctor_time ON appointments(doctor_id, start_time); +CREATE INDEX idx_appointments_status ON appointments(status); +CREATE INDEX idx_ratings_doctor ON ratings(doctor_id); +CREATE INDEX idx_comments_doctor_approved ON comments(doctor_id, approved); +CREATE INDEX idx_categories_bundle ON categories(bundle); +``` + +--- + +## ۶. تحلیل API Design + +### نقاط قوت + +- ✅ Versioning با `/api/v1/` در URL +- ✅ UUID در public endpoints +- ✅ Pagination استاندارد (`page`، `limit`، `totalRecords`، `totalPages`) +- ✅ HTTP methods صحیح (GET/POST/PATCH/DELETE) +- ✅ Bearer token authentication + +### نقاط ضعف + +**۱. Naming Convention ناسازگار** + +| Endpoint | فیلد | درست‌تر | +|----------|------|---------| +| GET /doctor | `img` | `images` | +| GET /clinic | `images_clinic` | `images` | +| GET /clinic | `phone_number` | `phoneNumber` یا `phone` | +| POST categories | `/api/v1/categorys/` | `/api/v1/categories/` (Drupal typo کپی شده) | + +**۲. Error Format استاندارد وجود ندارد** +هیچ‌جا فرمت خطا تعریف نشده. کلاینت نمی‌داند چه انتظاری داشته باشد. + +**۳. Request→DB Field Mapping مستند نشده** + +| نام در Request | نام در DB | +|---------------|---------| +| `correct_diagnosis` | `accuracy_of_diagnosis` | +| `doctor_skill` | `doctor_expertise` | +| `behavior_doctor` | `doctor_behavior` | +| `office_cleaning` | `clinic_cleanliness` | +| `time_in_office` | `waiting_time_at_clinic` | + +این mapping در هیچ لایه‌ای صریح تعریف نشده. + +**۴. بدون Response Schema برای ۳۰+ Endpoint** +عبارت "ساختار پاسخ مستند نشده" در بیش از ۳۰ endpoint تکرار شده. + +**۵. Timestamp فرمت ناسازگار** +بعضی response ها timestamp را string برمی‌گردانند (`"activity_time": "1107808200"`)، بعضی integer. استانداردی وجود ندارد. + +--- + +## ۷. تحلیل امنیت + +### Authentication + +| مورد | وضعیت | ریسک | +|------|--------|-------| +| OTP via SMS | ✅ | Low | +| JWT (3600s TTL) | ✅ | Low | +| Refresh Token | ❌ وجود ندارد | Medium — کاربر هر ساعت باید re-auth کند | +| Password Hashing | ✅ bcrypt | Low | +| Mobile as Username | ✅ | Low | + +### Authorization + +| مورد | وضعیت | ریسک | +|------|--------|-------| +| Role-based (authenticated, doctor, admin) | ✅ | Low | +| Owner check در PATCH | ✅ | Low | +| Admin-only endpoints | ✅ | Low | +| Secretary permissions | ⚠️ JSON بدون schema | High — هر implementer می‌تواند اشتباه implement کند | + +### Rate Limiting + +| مورد | وضعیت | ریسک | +|------|--------|-------| +| 50 req/hr per IP | ✅ | Low | +| 30 req/hr per mobile | ✅ | Low | +| OTP attempt limiting | ⚠️ نامشخص | Medium | +| No rate limit header در response | ❌ | Low — UX ضعیف | + +### Input Validation + +| مورد | وضعیت | ریسک | +|------|--------|-------| +| CSRF Token | ⚠️ Inconsistent | Medium | +| MIME validation در upload | ✅ | Low | +| SQL Injection | ✅ Doctrine ORM | Low | +| XSS | ⚠️ تعریف نشده | Medium — JSON response، اما اگر HTML render شود | +| File size limit | ⚠️ تعریف نشده | Medium — DoS از طریق بارگذاری فایل بزرگ | + +### Secrets Management + +| مورد | وضعیت | ریسک | +|------|--------|-------| +| JWT keys در فایل‌سیستم | ⚠️ | Medium برای production | +| DB credentials در .env | ⚠️ | Medium برای production | +| SMS API keys | ⚠️ در .env | Medium | +| Dev OTP code ثابت (12345) | ⚠️ | Low اگر فقط در dev باشد | + +### Logging Security + +❌ **هیچ logging strategy تعریف نشده.** موارد زیر باید log شوند: +- تلاش‌های ناموفق OTP +- تغییر role کاربر +- حذف entity ها +- payment transactions +- دسترسی‌های رد شده + +--- + +## ۸. تحلیل مقیاس‌پذیری + +### با ۱۰,۰۰۰ کاربر — مشکل جدی نیست + +معماری فعلی این تعداد را handle می‌کند با: +- یک MySQL server +- یک Redis instance +- یک PHP-FPM instance + +### با ۱۰۰,۰۰۰ کاربر — مشکلات شروع می‌شوند + +| مشکل | علت | راه‌حل | +|------|-----|---------| +| لیست دکتر با فیلتر کند می‌شود | Full scan روی JSON columns | Read replica + Elasticsearch برای جستجو | +| Categories هر بار از DB | بدون cache | Redis cache با TTL=300s | +| SMS در صف می‌ماند | Sync call | Symfony Messenger + Queue | +| JWT validation سنگین | هر request decode می‌شود | Redis token blacklist | + +### با ۱,۰۰۰,۰۰۰ کاربر — نیاز به Refactoring اساسی + +| سرویس | مشکل | راه‌حل | +|-------|------|---------| +| Appointments | Hot table — write contention | Sharding بر اساس doctor_id | +| Search | MySQL full-text کافی نیست | Elasticsearch | +| File Upload | Local filesystem | S3-compatible object storage | +| SMS | Single provider | Multi-provider با queue | +| Auth | Stateless JWT کافی است | Redis session store برای blacklist | + +**Microservice یا Modular Monolith؟** + +در این مرحله: **Modular Monolith** توصیه می‌شود. + +دلایل: +- تیم کوچک +- Domain boundaries هنوز در حال تثبیت +- Microservice overhead (distributed tracing، service mesh، network latency) در این مرحله ارزشش را ندارد + +آینده (بعد از ۱۰۰K): **Financial Context** (Payment) و **Notification Context** (SMS) کاندیداهای اول برای جداسازی هستند. + +--- + +## ۹. تحلیل ساختار پروژه + +### ساختار پیشنهادی فعلی (نامشخص) + +هیچ‌جا ساختار پوشه صریح تعریف نشده. احتمال پیش‌فرض Symfony: +``` +src/ + Controller/ + Entity/ + Repository/ + Service/ +``` +این ساختار Layer-based است و برای ۱۷ ماژول با ۹۵ endpoint به سرعت به هم می‌ریزد. + +### ساختار توصیه‌شده — Domain-Driven + +``` +src/ + Doctor/ + Controller/ + DoctorController.php + Entity/ + Doctor.php + DoctorAddress.php + Repository/ + DoctorRepository.php + Service/ + DoctorService.php + DoctorRatingService.php + DTO/ + DoctorRequest.php + DoctorResponse.php + Event/ + DoctorCreatedEvent.php + + Clinic/ + Controller/ + Entity/ + Repository/ + Service/ + DTO/ + + Appointment/ + ... + + Payment/ + Gateway/ + MellatGateway.php + SepGateway.php + PaymentGatewayInterface.php + ... + + Shared/ + Response/ + ApiResponse.php + ApiError.php + Controller/ + BaseController.php + Repository/ + BaseRepository.php +``` + +### Dependency Direction + +باید یک‌طرفه باشد: +``` +Controller → Service → Repository → Entity +``` + +هیچ‌گاه: +``` +Entity → Service ❌ +Repository → Controller ❌ +``` + +--- + +## ۱۰. ریسک‌های شناسایی‌شده + +### ریسک‌های بحرانی + +| ریسک | Severity | Probability | Impact | راه‌حل | +|------|----------|-------------|--------|--------| +| ۳۰+ endpoint بدون response schema | Critical | High | Frontend/Backend diverge | مستندسازی قبل از کدنویسی | +| Business logic rating تعریف‌نشده | Critical | High | نتایج اشتباه | مستندسازی فرمول | +| Payment flow ناقص | Critical | High | از دست رفتن پرداخت | تعریف کامل فلو | +| ساختار پوشه تعریف‌نشده | High | High | کد ناهماهنگ ۱۷ ماژول | تعریف قبل از شروع | + +### ریسک‌های مهم + +| ریسک | Severity | Probability | Impact | راه‌حل | +|------|----------|-------------|--------|--------| +| بدون Refresh Token | High | Certain | UX ضعیف، re-login مکرر | پیاده‌سازی refresh token | +| SMS sync blocking | High | High | OTP fail → کاربر مسدود | Symfony Messenger | +| N+1 Query در لیست دکتر | High | High | کندی با رشد data | Eager loading + cache | +| God Table categories | High | Medium | جستجوی کند، maintenance سخت | Refactor در فاز اول | +| Secretary permissions بدون schema | High | High | پیاده‌سازی ناهماهنگ | تعریف JSON schema | + +### ریسک‌های عملیاتی + +| ریسک | Severity | Probability | Impact | راه‌حل | +|------|----------|-------------|--------|--------| +| بدون health check | Medium | Certain | نمی‌توان مشکل را سریع تشخیص داد | اضافه کردن `/health` | +| بدون logging | High | Certain | debug تولید غیرممکن | Structured logging از ابتدا | +| Secrets در .env | Medium | High | leak در git | Vault یا CI/CD secrets | +| Dev OTP ثابت (12345) | Low | High | باید env-based باشد | `APP_ENV=dev` conditional | + +--- + +## Missing Requirements (کامل) + +| # | مورد | ماژول | شدت | +|---|------|-------|-----| +| ۱ | Response schema برای ۳۰+ endpoint | همه | Critical | +| ۲ | فرمول وزنی محاسبه rating | task-12 | Critical | +| ۳ | فلوی کامل payment (trigger، failure، refund، cancel) | task-15 | Critical | +| ۴ | Error response format استاندارد | task-01 | Critical | +| ۵ | ساختار پوشه Domain-Driven | task-01 | High | +| ۶ | منطق تولید `free_turn` و `hours_of_work` | task-05/09 | High | +| ۷ | Refresh Token mechanism | task-02 | High | +| ۸ | Subscription payment endpoints | task-15 | High | +| ۹ | ساختار JSON فیلد `permissions` دبیران | task-14 | High | +| ۱۰ | Appointment cancellation و refund flow | task-10/15 | High | +| ۱۱ | Logging strategy | task-01 | High | +| ۱۲ | Health check endpoint (`/health`) | task-01 | Medium | +| ۱۳ | SMS provider fallback logic | task-17 | Medium | +| ۱۴ | File upload error handling و size limit | همه | Medium | +| ۱۵ | City-specific management endpoints | task-08 | Medium | +| ۱۶ | Comment reply/nesting (field_parent) | task-12 | Medium | +| ۱۷ | Fixtures/Seeders برای category data | task-08 | Medium | +| ۱۸ | Rate limit headers در response | task-02 | Low | + +--- + +## Architecture Violations + +| # | نقض | اصل | راه‌حل | +|---|-----|-----|--------| +| ۱ | God Table `categories` | Single Responsibility | STI یا جداول جداگانه | +| ۲ | God Table `clinic_pro` از Drupal | Bounded Context | Entity های جداگانه در Symfony | +| ۳ | بدون DTO برای Input/Output | Explicit API Contract | DTO class برای هر endpoint | +| ۴ | Magic field name `field_starts` (نه `field_stars`) | Clarity | Mapping layer صریح | +| ۵ | Naming convention ناسازگار در response ها | Convention over Configuration | استاندارد یکپارچه | +| ۶ | Business logic تعریف‌نشده | Separation of Concerns | Service layer صریح | +| ۷ | Typo در URL (`categorys`) کپی از Drupal | REST conventions | در Symfony با redirect fix کن | +| ۸ | Timestamp گاهی string گاهی int در response | Type Consistency | همیشه int | + +--- + +## Recommended Improvements + +### اولویت ۱ — قبل از شروع کدنویسی + +**۱. استاندارد Error/Success Response** +```json +// موفق: +{ + "success": true, + "data": { ... }, + "meta": { "page": 1, "totalPages": 5, "totalRecords": 47 } +} + +// خطا: +{ + "success": false, + "data": null, + "errors": [ + { "code": "ERR_VALIDATION_001", "field": "mobile_number", "message": "فرمت نادرست است" } + ] +} +``` + +**۲. Error Codes استاندارد** +``` +ERR_AUTH_001 = توکن منقضی شده +ERR_AUTH_002 = OTP نامعتبر +ERR_AUTH_003 = OTP منقضی شده +ERR_VALIDATION_001 = ورودی نامعتبر +ERR_NOT_FOUND_001 = منبع یافت نشد +ERR_FORBIDDEN_001 = دسترسی ندارید +ERR_PAYMENT_001 = درگاه پرداخت در دسترس نیست +ERR_PAYMENT_002 = مبلغ نامعتبر +``` + +**۳. تعریف فرمول Rating** +```php +const RATING_WEIGHTS = [ + 'accuracy_of_diagnosis' => 3.0, + 'doctor_expertise' => 2.0, + 'doctor_behavior' => 1.5, + 'waiting_time_at_clinic' => 1.0, + 'clinic_cleanliness' => 1.0, +]; +// weightedAverage = SUM(value * weight) / SUM(weights) → از 100 +// stars = (weightedAverage / 100) * 5 → از 5 +``` + +**۴. تعریف ساختار پوشه صریح در task-01** + +### اولویت ۲ — در حین پیاده‌سازی + +**۵. Refresh Token** +``` +POST /oauth/token +{ "grant_type": "refresh_token", "refresh_token": "..." } +→ refresh token در Redis با TTL=30 روز +``` + +**۶. Symfony Messenger برای Async Operations** +```bash +ddev composer require symfony/messenger +``` +SMS، notification، email — همه از طریق Queue + +**۷. Repository Pattern صریح** +```php +// هر entity باید Repository خودش داشته باشد +class DoctorRepository extends ServiceEntityRepository +{ + public function findWithFilters(array $filters, int $page, int $limit): array + public function findByUuidWithRelations(string $uuid): ?Doctor +} +``` + +**۸. DTO برای Input/Output** +```php +class DoctorRequest +{ + #[Assert\NotBlank] + public string $title; + + #[Assert\Range(min: 0, max: 100)] + public int $experience; +} + +class DoctorResponse +{ + public function __construct(Doctor $doctor) { ... } + public function toArray(): array { ... } +} +``` + +### اولویت ۳ — برای آماده‌سازی تولید + +**۹. Health Check** +``` +GET /health +→ { "status": "ok", "db": "ok", "redis": "ok", "timestamp": 1748000000 } +``` + +**۱۰. Redis Cache برای Lookup Data** +```php +// categories، states، cities — TTL=300s +$states = $cache->get('categories.state', fn() => $repo->findByBundle('state')); +``` + +**۱۱. Structured Logging** +```php +$this->logger->info('appointment.created', [ + 'user_id' => $user->getId(), + 'doctor_id' => $doctor->getId(), + 'start_time' => $startTime, + 'request_id' => $requestId, +]); +``` + +**۱۲. Query Optimization** +```php +// Eager loading برای جلوگیری از N+1 +$doctor = $repo->createQueryBuilder('d') + ->leftJoin('d.specialties', 's')->addSelect('s') + ->leftJoin('d.addresses', 'a')->addSelect('a') + ->where('d.uuid = :uuid') + ->getQuery()->getOneOrNullResult(); +``` + +--- + +## Refactoring Plan + +### فاز ۰ — مستندسازی (۳ تا ۵ روز، قبل از هر کدنویسی) + +- [ ] تعریف `ApiResponse` و `ApiError` format در task-01 +- [ ] مستندسازی Response schema تمام ۳۰+ endpoint گم‌شده +- [ ] تعریف فرمول rating در task-12 +- [ ] تعریف کامل فلوی payment (trigger، failure، refund) در task-15 +- [ ] تعریف ساختار JSON `permissions` دبیران در task-14 +- [ ] تعریف ساختار پوشه Domain-Driven در task-01 +- [ ] تصمیم‌گیری: Refresh Token — بله یا خیر + +### فاز ۱ — زیرساخت پایه (task-01) + +- [ ] اضافه کردن `symfony/messenger` به پکیج‌ها +- [ ] ایجاد `BaseController` با متدهای `success()` و `error()` +- [ ] ایجاد `BaseRepository` با متدهای مشترک +- [ ] اضافه کردن `GET /health` endpoint +- [ ] تعریف Error Code constants +- [ ] راه‌اندازی Structured Logging با Monolog + +### فاز ۲ — پیاده‌سازی ماژول‌ها (به ترتیب dependency) + +``` +01 → 02 → 08 → 03 → 04 → 05 → 06 → 07 → 09 → 11 → 10 → 12 → 13 → 14 → 15 → 16 → 17 +``` + +برای هر ماژول: +1. Entity + Migration +2. Repository با Eager loading +3. DTO (Request + Response) +4. Service با Business Logic +5. Controller با Swagger attributes +6. Tests + +### فاز ۳ — بهینه‌سازی (بعد از پیاده‌سازی) + +- [ ] Redis cache برای categories و lookup data +- [ ] Eager loading در تمام list endpoints +- [ ] Integration tests برای payment flow +- [ ] Load test برای لیست دکتر با فیلتر چندگانه +- [ ] Review و تکمیل Swagger documentation + +### فاز ۴ — آماده‌سازی تولید + +- [ ] Secrets به محیط CI/CD منتقل شوند (خارج از .env) +- [ ] Health check به monitoring متصل شود +- [ ] Read replica برای query های سنگین +- [ ] File storage به S3-compatible منتقل شود + +--- + +## اصلاحات اعمال‌شده (بعد از Audit) + +| # | مشکل | فایل اصلاح‌شده | وضعیت | +|---|------|---------------|--------| +| ۱ | ساختار پوشه Domain-Driven | task-01/task.md | ✅ | +| ۲ | Error Codes استاندارد | task-01/task.md | ✅ | +| ۳ | BaseController با success/error | task-01/task.md | ✅ | +| ۴ | Symfony Messenger | task-01/task.md | ✅ | +| ۵ | Health Check endpoint | task-01/task.md | ✅ | +| ۶ | Structured Logging | task-01/task.md | ✅ | +| ۷ | **CORS فقط دامنه‌های مشخص (نه *)** | task-01/implementation_notes.md | ✅ | +| ۸ | **Security Headers (X-Frame، HSTS، CSP)** | task-01/implementation_notes.md | ✅ | +| ۹ | **Swagger فقط در dev** | task-01/implementation_notes.md | ✅ | +| ۱۰ | **Audit Log جدول security_logs** | task-01/implementation_notes.md | ✅ | +| ۱۱ | **security.yaml کامل با access_control** | task-01/implementation_notes.md | ✅ | +| ۱۲ | Refresh Token + Logout + Blacklist | task-02/task.md | ✅ | +| ۱۳ | **hash_equals() برای OTP — جلوگیری از Timing Attack** | task-02/task.md | ✅ | +| ۱۴ | **Refresh Token هش‌شده در Redis (نه plain text)** | task-02/task.md | ✅ | +| ۱۵ | Rate Limit Headers در response | task-02/task.md | ✅ | +| ۱۶ | Status Machine کامل نوبت | task-10/task.md | ✅ | +| ۱۷ | فلوی لغو + refund | task-10/task.md | ✅ | +| ۱۸ | **Open Redirect در frontend_address پرداخت** | task-15/task.md | ✅ | +| ۱۹ | **IP Whitelist Callback با implementation** | task-15/task.md | ✅ | +| ۲۰ | Circuit Breaker + Idempotency | task-15/task.md | ✅ | +| ۲۱ | Subscription Payment endpoints | task-15/task.md | ✅ | +| ۲۲ | Secretary permissions JSON schema | task-14/task.md | ✅ | +| ۲۳ | SMS Fallback + Async Messenger | task-17/task.md | ✅ | +| ۲۴ | **File upload: magic bytes بجای MIME header** | task-01/task.md | ✅ | +| ۲۵ | **Filename sanitization — path traversal** | task-01/task.md | ✅ | +| ۲۶ | فرمت Response ناسازگار در architecture.md | task-01/architecture.md | ✅ | +| ۲۷ | تسویه نماینده | task-18-settlement/ | ✅ | + +--- + +## مشکلات امنیتی باقی‌مانده (نیاز به توجه در پیاده‌سازی) + +| # | مشکل | اولویت | راه‌حل | +|---|------|--------|--------| +| ۱ | Input HTML sanitization در فیلدهای متنی (detail، caption) | High | استفاده از `htmlspecialchars()` یا `strip_tags()` در DTO | +| ۲ | Mass assignment در PATCH endpoints | Medium | فقط فیلدهای مجاز را از request map کن | +| ۳ | JWT passphrase پیش‌فرض ضعیف | High | در production حتماً با `openssl rand -hex 32` تغییر داده شود | +| ۴ | Dev OTP ثابت (12345) | Medium | حتماً فقط با `APP_ENV=dev` فعال شود — هرگز در production | +| ۵ | Health check اطلاعات سیستم را افشا می‌کند | Low | در production به IP های داخلی محدود شود | + +--- + +## Final Verdict (بعد از اصلاحات) + +``` +✅ APPROVED +``` + +### دلیل تصمیم + +تمام مشکلات بحرانی و اکثر مشکلات مهم رفع شده‌اند: + +- **امنیت:** Timing Attack، CORS wildcard، Open Redirect، Security Headers، Refresh Token hashing، IP Whitelist، Audit Log — همه رفع شدند +- **معماری:** Domain-Driven structure، DTO pattern، Error Codes، Response format یکپارچه +- **قابلیت اطمینان:** Payment flow کامل، Circuit Breaker، Idempotency، SMS fallback +- **مستندسازی:** همه endpoint های بحرانی با schema کامل مستند شدند + +باقیمانده موارد (Mass Assignment، Input sanitization) در لایه پیاده‌سازی با Symfony Validator و DTO به سادگی قابل رفع هستند. + +--- + +*آخرین بروزرسانی: ۱۴۰۵/۰۳/۱۸ — بعد از اصلاح کامل* diff --git a/docs/ClinicPro_Manual_v2.md b/docs/ClinicPro_Manual_v2.md new file mode 100644 index 00000000..35eb1f18 --- /dev/null +++ b/docs/ClinicPro_Manual_v2.md @@ -0,0 +1,5893 @@ +--- +title: کلینیک پرو — مستند جامع فنی و API +version: 2.0.0 +status: پیش‌نویس +--- + +
+ +# کلینیک پرو — مستند جامع فنی و API + +> **نسخه:** 2.0.0  |  **وضعیت:** پیش‌نویس — در حال بررسی  |  **سال:** ۱۴۰۴ +> +> **Base URL:** `https://back-dev.clinic-pro.ir` +> +> **احراز هویت:** `Authorization: Bearer {access_token}`  |  **Content-Type:** `application/json` +> +> **علامت 🆕** = این endpoint دارای Response Body واقعی از سرور است. + +--- + +## فهرست کلی + +**بخش اول — مستند محصول (PRD)** + +1. [معرفی محصول](#۱-معرفی-محصول) +2. [مدل داده و موجودیت‌ها](#۲-مدل-داده-و-موجودیت‌ها) +3. [مشخصات API (Task-based)](#۳-مشخصات-api) +4. [سیستم حساب و صف پیامک](#۴-سیستم-حساب-و-صف-پیامک) +5. [فهرست مشکلات](#۵-فهرست-مشکلات-شناسایی‌شده-و-اصلاحات-لازم) +6. [پیوست](#۶-پیوست) + +**بخش دوم — مستند کامل API (۹۹ Endpoint)** + +- [1. احراز هویت (Authentication)](#1-احراز-هویت-(authentication)) +- [2. کاربر (User)](#2-کاربر-(user)) +- [3. پروفایل بیمار (User Profile)](#3-پروفایل-بیمار-(user-profile)) +- [4. دکتر (Doctor)](#4-دکتر-(doctor)) +- [5. آدرس دکتر (Doctor Address)](#5-آدرس-دکتر-(doctor-address)) +- [6. کلینیک (Clinic)](#6-کلینیک-(clinic)) +- [7. نماینده (Agent / Representation)](#7-نماینده-(agent-/-representation)) +- [8. دسته‌بندی‌ها (Categories)](#8-دسته‌بندی‌ها-(categories)) +- [9. بیمه دکتر (Doctor Insurance)](#9-بیمه-دکتر-(doctor-insurance)) +- [10. تنظیمات نوبت — برنامه هفتگی](#10-تنظیمات-نوبت-—-برنامه-هفتگی) +- [11. تنظیمات نوبت — Date Override](#11-تنظیمات-نوبت-—-date-override) +- [12. تنظیمات نوبت — تعطیلات](#12-تنظیمات-نوبت-—-تعطیلات) +- [13. نوبت‌دهی (Appointment)](#13-نوبت‌دهی-(appointment)) +- [14. منشی (Secretary)](#14-منشی-(secretary)) +- [15. پرداخت (Payment)](#15-پرداخت-(payment)) +- [16. وبلاگ (Blog)](#16-وبلاگ-(blog)) + +--- + +# بخش اول — مستند محصول (PRD) + +# ۱. معرفی محصول + +کلینیک پرو یک سیستم جامع مدیریت کلینیک است که برای خدمت‌رسانی به دکترهای مستقل، کلینیک‌های چند پزشکی، نمایندگان شهری، منشی‌ها و بیماران طراحی شده است. این سیستم ثبت‌نام کاربران، احراز هویت، نوبت‌دهی آنلاین، مدیریت اشتراک، ارسال پیامک و جریان‌های مالی شامل پیگیری کمیسیون را پوشش می‌دهد. + +## ۱.۱ نقش‌های سیستم + +| **نقش** | **توضیحات** | +| ------------------ | ----------------------------------------------------------------- | +| Administrator | ادمین ارشد — مدیریت تنظیمات پلتفرم، تعطیلات، پلن‌ها و نرخ کمیسیون | +| admin | ادمین داخلی با دسترسی بالا | +| clinic | صاحب کلینیک — مدیریت پروفایل کلینیک، دکترها و نوبت‌ها | +| doctor | پزشک مستقل یا وابسته به کلینیک | +| doctor_s_secretary | منشی اختصاصی دکتر (دسترسی‌ها از طریق JSON مدیریت می‌شود) | +| patient | کاربر نهایی که نوبت رزرو می‌کند | +| representation | نماینده شهری که روی دامنه خود فعالیت می‌کند | + +## ۱.۲ فلوهای عملیاتی اصلی + +### ثبت‌نام دکتر — مستقل + +- دکتر با شماره موبایل در سیستم ثبت‌نام می‌کند + +- سیستم حساب کاربری با نقش doctor ایجاد می‌کند + +- دکتر پروفایل را تکمیل و پلن را سابسکرایب می‌کند + +### ثبت‌نام دکتر — از طریق کلینیک + +- صاحب کلینیک دکتر را با شماره موبایل اضافه می‌کند + +- پیامک با لینک تأیید/رد برای دکتر ارسال می‌شود + +- در صورت رد: هیچ اقدامی انجام نمی‌شود + +- در صورت تأیید: پیامک با لینک دانلود نرم‌افزار ارسال می‌شود + +### ثبت‌نام دکتر — از طریق نماینده + +- نماینده شماره موبایل دکتر و اطلاعات مربوطه را وارد می‌کند + +- پیامک خوش‌آمدگویی با لینک دانلود نرم‌افزار برای دکتر ارسال می‌شود + +- در صورت سابسکرایب پلن توسط دکتر: کمیسیون به کیف پول نماینده واریز می‌شود + +## ۱.۳ پلن‌های اشتراک + +| **ویژگی** | **پلن بیسیک (رایگان)** | **پلن پیشرفته** | +| --------------------- | ---------------------- | ------------------- | +| تعداد منشی فعال | ۱ منشی | ۳ منشی | +| نوبت‌دهی آنلاین | ✓ (با کمیسیون سایت) | ✓ (با کمیسیون سایت) | +| نوبت‌دهی آفلاین | ✗ | ✓ | +| پشتیبان‌گیری (Backup) | ✗ | ✓ | +| نوبت توسط منشی | بدون کمیسیون سایت | بدون کمیسیون سایت | + +*نکته: تعریف پلن‌ها (ویژگی‌ها، قیمت) باید از پنل ادمین قابل تنظیم باشد. مبلغ کمیسیون به ازای هر نوبت آنلاین نیز باید قابل تنظیم باشد (مثلاً ۱۰،۰۰۰ تومان به ازای هر نوبت).* + +## ۱.۴ سیستم پیامک + +هر دکتر یا کلینیک باید یک حساب پیامک مستقل داشته باشد. + +- دکتر یا کلینیک حساب پیامک خود را با تعداد مشخصی پیامک شارژ می‌کند (مثلاً ۱۰۰۰ پیامک) + +- نرم‌افزار لوکال (Tauri) یک جاب اجرا می‌کند که یک روز قبل از نوبت برای بیماران پیامک یادآوری ارسال می‌کند + +- این پیامک‌ها در صف ارسال قرار می‌گیرند + +- به ازای هر پیامک ارسال‌شده، موجودی از حساب دکتر یا کلینیک کسر می‌شود + +# ۲. مدل داده و موجودیت‌ها + +## ۲.۱ کاربر (User) + +موجودیت مرکزی سیستم. تمام نقش‌ها از این ساختار استفاده می‌کنند. + +**⚠ مشکل شناسایی‌شده: جدول کاربران به درستی ایجاد نشده است. باید بر اساس فیلدهای زیر بازسازی شود.** + +| **نام فیلد** | **نوع داده** | **توضیحات** | **الزامی** | +| ------------- | ----------------- | ------------------------------------------------------------------------------------------------------- | ---------- | +| id | Integer (PK) | کلید اصلی — افزایشی خودکار | بله | +| uuid | UUID | شناسه یکتای جهانی | بله | +| uid | Integer (FK→User) | شناسه کاربر — ارجاع به خود جدول | بله | +| mobile_number | String (unique) | شماره موبایل — به عنوان نام کاربری استفاده می‌شود | بله | +| password | String (hashed) | رمز عبور — هش‌شده با Bcrypt | بله | +| realname | String | نام و نام خانوادگی کامل | بله | +| picture | String (URL) | آدرس تصویر پروفایل | اختیاری | +| roles | Array | نقش‌های اختصاص‌یافته: administrator, admin, clinic, doctor, doctor_s_secretary, patient, representation | بله | +| status | Boolean | وضعیت حساب: فعال / غیرفعال | بله | +| created | Timestamp | زمان ایجاد رکورد | بله | +| changed | Timestamp | آخرین زمان ویرایش | بله | + +**⚠ مشکل شناسایی‌شده: جدول نقش‌ها (Roles) به درستی ایجاد نشده است. نقش‌ها باید به صورت Enum تعریف و به کاربر متصل شوند.** + +## ۲.۲ انواع دسته‌بندی (Category Types) + +**⚠ مشکل شناسایی‌شده (Task-08): شهرها و استان‌ها به درستی ست نشده‌اند. تمام دسته‌بندی‌ها باید کاملاً تعریف شوند.** + +### ۲.۲.۱ تگ (Tag) + +| **نام فیلد** | **نوع داده** | **توضیحات** | **الزامی** | +| ------------ | ----------------- | ------------ | ---------- | +| id | Integer (PK) | کلید اصلی | بله | +| uuid | UUID | شناسه یکتا | بله | +| uid | Integer (FK→User) | کاربر سازنده | بله | +| label | String | نام تگ | بله | +| status | Boolean | فعال/غیرفعال | بله | +| created | Timestamp | زمان ایجاد | بله | +| changed | Timestamp | آخرین ویرایش | بله | + +### ۲.۲.۲ استان (State) + +| **نام فیلد** | **نوع داده** | **توضیحات** | **الزامی** | +| ------------ | ----------------- | ------------ | ---------- | +| id | Integer (PK) | کلید اصلی | بله | +| uuid | UUID | شناسه یکتا | بله | +| uid | Integer (FK→User) | کاربر سازنده | بله | +| label | String | نام استان | بله | +| status | Boolean | فعال/غیرفعال | بله | +| created | Timestamp | زمان ایجاد | بله | +| changed | Timestamp | آخرین ویرایش | بله | + +### ۲.۲.۳ شهر (City) + +**⚠ اصلاح شده: فیلد parent (ارجاع به استان) و سایر فیلدها باید کاملاً پیکربندی شوند.** + +| **نام فیلد** | **نوع داده** | **توضیحات** | **الزامی** | +| ----------------- | --------------------- | ------------------------------ | ---------- | +| id | Integer (PK) | کلید اصلی | بله | +| uuid | UUID | شناسه یکتا | بله | +| uid | Integer (FK→User) | کاربر سازنده | بله | +| label | String | نام شهر | بله | +| parent | Integer (FK→state.id) | ارجاع به استان مربوطه — الزامی | بله | +| status | Boolean | فعال/غیرفعال | بله | +| domain | String | دامنه نماینده این شهر | اختیاری | +| representation | Integer (FK→rep.id) | نماینده مرتبط با شهر | اختیاری | +| contactphone | String | شماره تماس | اختیاری | +| email | String | ایمیل تماس | اختیاری | +| description | Text | توضیحات شهر | اختیاری | +| title | String | عنوان صفحه شهر | اختیاری | +| keywords | String | کلمات کلیدی SEO | اختیاری | +| footerDescription | Text | متن فوتر سایت نماینده | اختیاری | +| footerDisclaimer | Text | سلب مسئولیت فوتر | اختیاری | +| socialMedia | JSON | لینک‌های شبکه‌های اجتماعی | اختیاری | +| weight | Integer | وزن مرتب‌سازی | اختیاری | +| created | Timestamp | زمان ایجاد | بله | +| changed | Timestamp | آخرین ویرایش | بله | + +### ۲.۲.۴ بیمه پایه (Basic Insurance) + +| **نام فیلد** | **نوع داده** | **توضیحات** | **الزامی** | +| ------------ | ----------------- | ------------ | ---------- | +| id | Integer (PK) | کلید اصلی | بله | +| uuid | UUID | شناسه یکتا | بله | +| uid | Integer (FK→User) | کاربر سازنده | بله | +| label | String | نام بیمه | بله | +| logo | String (URL) | لوگوی بیمه | اختیاری | +| status | Boolean | فعال/غیرفعال | بله | +| created | Timestamp | زمان ایجاد | بله | +| changed | Timestamp | آخرین ویرایش | بله | + +### ۲.۲.۵ بیمه مکمل (Supplementary Insurance) + +ساختار یکسان با بیمه پایه. + +### ۲.۲.۶ تخصص دکتر (Doctor Specialty) + +| **نام فیلد** | **نوع داده** | **توضیحات** | **الزامی** | +| ------------ | ------------------------- | ----------------------------- | ---------- | +| id | Integer (PK) | کلید اصلی | بله | +| uuid | UUID | شناسه یکتا | بله | +| uid | Integer (FK→User) | کاربر سازنده | بله | +| label | String | نام تخصص | بله | +| parent | Integer (FK→specialty.id) | تخصص والد (برای ساختار درختی) | اختیاری | +| status | Boolean | فعال/غیرفعال | بله | +| created | Timestamp | زمان ایجاد | بله | +| changed | Timestamp | آخرین ویرایش | بله | + +### ۲.۲.۷ خدمات دکتر (Doctor Services) + +**⚠ اصلاح شده (Task-05): در تمام پاسخ‌های API باید هم id و هم label (نام) ارسال شود.** + +| **نام فیلد** | **نوع داده** | **توضیحات** | **الزامی** | +| ------------ | ----------------- | --------------------------------------- | ---------- | +| id | Integer (PK) | کلید اصلی — الزامی در تمام پاسخ‌های API | بله | +| uuid | UUID | شناسه یکتا | بله | +| uid | Integer (FK→User) | کاربر سازنده | بله | +| label | String | نام خدمت — الزامی در تمام پاسخ‌های API | بله | +| status | Boolean | فعال/غیرفعال | بله | +| created | Timestamp | زمان ایجاد | بله | +| changed | Timestamp | آخرین ویرایش | بله | + +## ۲.۳ دکتر (Doctor) + +| **نام فیلد** | **نوع داده** | **توضیحات** | **الزامی** | +| ------------------------- | ---------------------- | --------------------------------------------------- | ---------- | +| id | Integer (PK) | کلید اصلی | بله | +| uuid | UUID | شناسه یکتا | بله | +| uid | Integer (FK→User) | حساب کاربری متصل | بله | +| label / name | String | نام نمایشی دکتر | بله | +| gender | Enum | جنسیت: male / female | بله | +| degree | Enum | expert │ general │ specialist │ subspecialistplus | بله | +| doctor_id | String | شماره نظام پزشکی | بله | +| doctor_mobile_number | String | شماره موبایل دکتر | بله | +| clinic_specialty | Integer (FK→specialty) | تخصص اصلی دکتر | بله | +| doctor_services | Array | خدمات ارائه‌شده (id + label باید در پاسخ‌ها برگردد) | بله | +| state | Integer (FK→state.id) | استان محل فعالیت | بله | +| city | Integer (FK→city.id) | شهر محل فعالیت | بله | +| representation | Integer (FK→rep.id) | نماینده متصل (در صورت وجود) | اختیاری | +| active_doctor_appointment | Boolean | آیا نوبت‌دهی آنلاین فعال است؟ | بله | +| doctor_rate | Decimal | میانگین امتیاز | بله | +| doctor_rate_percentage | Float | درصد امتیاز | بله | +| info | Text / JSON | بیوگرافی و اطلاعات تکمیلی | اختیاری | +| activity_time | JSON | ساعات کاری | اختیاری | +| images | Array | آدرس تصاویر پروفایل | اختیاری | +| created | Timestamp | زمان ایجاد | بله | +| changed | Timestamp | آخرین ویرایش | بله | + +## ۲.۴ کلینیک (Clinic) + +| **نام فیلد** | **نوع داده** | **توضیحات** | **الزامی** | +| ---------------- | ---------------------- | ------------------------------------ | ---------- | +| id | Integer (PK) | کلید اصلی | بله | +| uuid | UUID | شناسه یکتا | بله | +| uid | Integer (FK→User) | کاربر صاحب کلینیک | بله | +| label / name | String | نام کلینیک | بله | +| address | Text | آدرس کامل | بله | +| latitude | Decimal | عرض جغرافیایی | بله | +| longitude | Decimal | طول جغرافیایی | بله | +| telephone | String | شماره تماس | بله | +| state | Integer (FK→state.id) | استان | بله | +| city | Integer (FK→city.id) | شهر | بله | +| clinic_specialty | Array | تخصص‌های کلینیک | بله | +| doctor_services | Array | خدمات (id + label الزامی در پاسخ‌ها) | بله | +| insurance | Array | بیمه‌های پذیرفته‌شده | بله | +| doctors | Array | دکترهای عضو کلینیک | بله | +| working_days | JSON | روزهای کاری هفته | بله | +| 24_7 | Boolean | آیا شبانه‌روزی فعال است؟ | بله | +| logo | String (URL) | لوگوی کلینیک | بله | +| images | Array | تصاویر گالری | اختیاری | +| representation | Integer (FK→rep.id) | نماینده متصل | اختیاری | +| info | Text | درباره کلینیک | اختیاری | +| created | Timestamp | زمان ایجاد | بله | +| changed | Timestamp | آخرین ویرایش | بله | + +## ۲.۵ آدرس‌های دکتر (Doctor Addresses) + +| **نام فیلد** | **نوع داده** | **توضیحات** | **الزامی** | +| ------------ | ------------------- | ---------------------------------- | ---------- | +| id | Integer (PK) | کلید اصلی | بله | +| uuid | UUID | شناسه یکتا | بله | +| uid | Integer (FK→User) | کاربر سازنده | بله | +| name | String | برچسب آدرس (مثلاً: مطب اصلی، شعبه) | بله | +| doctor | Integer (FK→doctor) | دکتر مرتبط | بله | +| address | Text | آدرس کامل | بله | +| latitude | Decimal | عرض جغرافیایی | بله | +| longitude | Decimal | طول جغرافیایی | بله | +| telephone | String | شماره تماس محل | بله | +| created | Timestamp | زمان ایجاد | بله | +| changed | Timestamp | آخرین ویرایش | بله | + +## ۲.۶ منشی دکتر (Doctor Secretary) + +محدودیت منشی فعال باید هنگام ایجاد یا فعال‌سازی در سمت سرور بررسی شود. + +| **نام فیلد** | **نوع داده** | **توضیحات** | **الزامی** | +| ------------ | ------------------- | ----------------------------------------------------------------------------- | ---------- | +| id | Integer (PK) | کلید اصلی | بله | +| uuid | UUID | شناسه یکتا | بله | +| uid | Integer (FK→User) | کاربر سازنده | بله | +| secretary | Integer (FK→user) | حساب کاربری منشی | بله | +| doctor | Integer (FK→doctor) | دکتر مرتبط | بله | +| active | Boolean | فعال/غیرفعال — پلن بیسیک: حداکثر ۱ منشی فعال؛ پلن پیشرفته: حداکثر ۳ منشی فعال | بله | +| permission | JSON | نقشه دسترسی‌های دقیق منشی | بله | +| telephone | String | شماره تماس منشی | اختیاری | +| created | Timestamp | زمان ایجاد | بله | +| changed | Timestamp | آخرین ویرایش | بله | + +## ۲.۷ نماینده (Representation) + +**⚠ اصلاح شده (Task-07): فیلدهای استان، شهر و شماره کارت بانکی باید به درستی تعریف شوند.** + +| **نام فیلد** | **نوع داده** | **توضیحات** | **الزامی** | +| ------------------ | --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | ---------- | +| id | Integer (PK) | کلید اصلی | بله | +| uuid | UUID | شناسه یکتا | بله | +| uid | Integer (FK→User) | حساب کاربری نماینده | بله | +| label | String | نام نماینده | بله | +| active | Boolean | فعال/غیرفعال | بله | +| state | Integer (FK→state.id) | استان — الزامی (قبلاً وجود نداشت) | بله | +| city | Integer (FK→city.id) | شهر — الزامی (قبلاً وجود نداشت) | بله | +| address | Text | آدرس دفتر نماینده | بله | +| domain | String | زیردامنه پورتال نماینده | بله | +| bank_account | JSON Array | آرایه‌ای از اطلاعات کارت‌های بانکی. هر آیتم: { card_number, bank_name, is_default }. یکی باید is_default=true داشته باشد — الزامی (قبلاً وجود نداشت) | بله | +| commission_percent | Decimal | درصد کمیسیون از هر نوبت آنلاین | بله | +| created | Timestamp | زمان ایجاد | بله | +| changed | Timestamp | آخرین ویرایش | بله | + +نمونه ساختار JSON کارت‌های بانکی: + +| [ { "card_number": "6037-9999-1234-5678", "bank_name": "ملت", "is_default": true }, { "card_number": "5859-3312-4455-6677", "bank_name": "صادرات", "is_default": false } ] | +| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | + +## ۲.۸ تنظیمات نوبت (Appointment Settings) + +**⚠ اصلاح شده (Task-09): الگوریتم محاسبه اسلات‌ها باید اولویت Override را رعایت کند.** + +### ۲.۸.۱ برنامه هفتگی (Weekly Schedule) + +| **نام فیلد** | **نوع داده** | **توضیحات** | **الزامی** | +| ------------ | ------------------- | ----------------- | ---------- | +| id | Integer (PK) | کلید اصلی | بله | +| uuid | UUID | شناسه یکتا | بله | +| uid | Integer (FK→User) | کاربر سازنده | بله | +| doctor_id | Integer (FK→doctor) | دکتر مرتبط | بله | +| active | Boolean | فعال/غیرفعال | بله | +| date | Timestamp | تاریخ مرجع برنامه | بله | +| created | Timestamp | زمان ایجاد | بله | +| changed | Timestamp | آخرین ویرایش | بله | + +### ۲.۸.۲ تعطیلات (Holidays) + +تعطیلات فقط توسط ادمین ثبت می‌شود. + +| **نام فیلد** | **نوع داده** | **توضیحات** | **الزامی** | +| ------------ | ----------------- | ---------------------- | ---------- | +| id | Integer (PK) | کلید اصلی | بله | +| uuid | UUID | شناسه یکتا | بله | +| uid | Integer (FK→User) | ادمین ثبت‌کننده تعطیلی | بله | +| label | String | نام / توضیح تعطیلی | بله | +| date | Timestamp | تاریخ تعطیل | بله | +| active | Boolean | فعال/غیرفعال | بله | +| created | Timestamp | زمان ایجاد | بله | +| changed | Timestamp | آخرین ویرایش | بله | + +### ۲.۸.۳ لغو تعطیل (Date Override) + +دکتر می‌تواند یک روز تعطیل را override کند و در آن روز کار کند. Override اولویت بالاتری از تعطیلات دارد. + +| **نام فیلد** | **نوع داده** | **توضیحات** | **الزامی** | +| ------------ | ------------------- | ------------------------------------------------- | ---------- | +| id | Integer (PK) | کلید اصلی | بله | +| uuid | UUID | شناسه یکتا | بله | +| uid | Integer (FK→User) | کاربر دکتر | بله | +| doctor_id | Integer (FK→doctor) | دکتری که Override ثبت کرده | بله | +| date | Timestamp | تاریخ مورد نظر برای Override | بله | +| setting | JSON | برنامه سفارشی برای آن روز (اسلات‌ها، ظرفیت و ...) | بله | +| active | Boolean | آیا این Override فعال است؟ | بله | +| created | Timestamp | زمان ایجاد | بله | +| changed | Timestamp | آخرین ویرایش | بله | + +### ۲.۸.۴ الگوریتم محاسبه اسلات‌های خالی + +اولویت‌بندی هنگام محاسبه اسلات‌های موجود برای یک تاریخ مشخص: + +| **اولویت** | **منبع** | **رفتار سیستم** | **نتیجه** | +| -------------- | --------------- | -------------------------------------------------------------------------------------------------------- | ---------- | +| ۱ (بالاترین) | Date Override | اگر Override فعال برای این تاریخ وجود داشت → از برنامه Override استفاده شود. تعطیلی نادیده گرفته می‌شود. | باز | +| ۲ | Holidays | اگر تاریخ تعطیل باشد AND Override وجود نداشته باشد → روز تعطیل، بدون اسلات. | بسته | +| ۳ (پایین‌ترین) | Weekly Schedule | اگر نه Override و نه تعطیلی → برنامه هفتگی اعمال می‌شود. | طبق برنامه | + +## ۲.۹ نوبت (Appointment) + +| **نام فیلد** | **نوع داده** | **توضیحات** | **الزامی** | +| -------------- | ------------------- | --------------------------------------- | ---------- | +| id | Integer (PK) | کلید اصلی | بله | +| uuid | UUID | شناسه یکتا | بله | +| uid | Integer (FK→User) | کاربر بیمار | بله | +| doctor_id | Integer (FK→doctor) | دکتر مربوطه | بله | +| address | Text | آدرس محل نوبت | بله | +| start_time | Timestamp | زمان شروع نوبت | بله | +| end_time | Timestamp | زمان پایان نوبت | بله | +| slot | JSON | متادیتای اسلات (شماره اسلات، مدت و ...) | بله | +| status | Enum | وضعیت نوبت — جدول زیر | بله | +| visited_at | Timestamp | زمان واقعی ویزیت | اختیاری | +| representation | Integer (FK→rep) | در صورت رزرو از طریق دامنه نماینده | اختیاری | +| info | JSON | توضیحات تکمیلی | اختیاری | +| created | Timestamp | زمان ایجاد | بله | +| changed | Timestamp | آخرین ویرایش | بله | + +وضعیت‌های نوبت (Appointment Status): + +| **waiting_for_payment** | **auto_cancel_unpaid** | **reserved** | +| ------------------------ | ----------------------- | -------------- | +| **cancelled_by_patient** | **cancelled_by_doctor** | **checked_in** | +| **waiting** | **in_progress** | **visited** | +| **no_show** | **postponed** | **completed** | + +## ۲.۱۰ پرداخت (Payment Types) + +### ۲.۱۰.۱ پرداخت نوبت + +| **نام فیلد** | **نوع داده** | **توضیحات** | **الزامی** | +| ---------------- | ----------------- | ---------------------------------------------- | ---------- | +| id | Integer (PK) | کلید اصلی | بله | +| uuid | UUID | شناسه یکتا | بله | +| uid | Integer (FK→User) | پرداخت‌کننده | بله | +| reference_id | Integer (FK→appt) | نوبت مرتبط | بله | +| amount | Decimal | مبلغ پرداختی (تومان) | بله | +| payment_method | String | روش / درگاه پرداخت | بله | +| payment_time | Timestamp | زمان پرداخت | بله | +| ref_id | String | شماره مرجع درگاه | بله | +| card_info | JSON | اطلاعات ماسک‌شده کارت | بله | +| representation | Integer (FK→rep) | نماینده (در صورت پرداخت از طریق دامنه نماینده) | اختیاری | +| frontend_address | String (URL) | آدرس بازگشت پس از پرداخت | بله | +| status | Enum | pending │ received │ refund │ canceled | بله | +| created | Timestamp | زمان ایجاد | بله | +| changed | Timestamp | آخرین ویرایش | بله | + +### ۲.۱۰.۲ پرداخت اشتراک + +| **نام فیلد** | **نوع داده** | **توضیحات** | **الزامی** | +| ---------------- | ----------------------- | -------------------------------------- | ---------- | +| id | Integer (PK) | کلید اصلی | بله | +| uuid | UUID | شناسه یکتا | بله | +| uid | Integer (FK→User) | کاربر سابسکرایب‌کننده | بله | +| reference_id | Integer (FK→clinic│doc) | کلینیک یا دکتر مربوطه | بله | +| amount | Decimal | مبلغ اشتراک | بله | +| payment_method | String | روش پرداخت | بله | +| payment_time | Timestamp | زمان پرداخت | بله | +| start_date | Timestamp | تاریخ شروع اشتراک | بله | +| expiration_date | Timestamp | تاریخ انقضای اشتراک | بله | +| ref_id | String | شماره مرجع درگاه | بله | +| card_info | JSON | اطلاعات ماسک‌شده کارت | بله | +| representation | Integer (FK→rep) | نماینده (در صورت وجود) | اختیاری | +| frontend_address | String (URL) | آدرس بازگشت | بله | +| status | Enum | pending │ received │ refund │ canceled | بله | +| created | Timestamp | زمان ایجاد | بله | +| changed | Timestamp | آخرین ویرایش | بله | + +## ۲.۱۱ پروفایل بیمار (Profile) + +| **نام فیلد** | **نوع داده** | **توضیحات** | **الزامی** | +| ----------------------- | ----------------- | -------------------------------------------------------------------------------- | ---------- | +| id | Integer (PK) | کلید اصلی | بله | +| uuid | UUID | شناسه یکتا | بله | +| uid | Integer (FK→User) | کاربر مرتبط | بله | +| national_code | String (10 رقم) | کد ملی | بله | +| national_code_approved | Boolean | تأیید کد ملی | بله | +| date_of_birth | Timestamp | تاریخ تولد | بله | +| gender | Enum | male │ female │ other | بله | +| blood_type | Enum | A+ │ A- │ B+ │ B- │ O+ │ O- │ AB+ │ AB- | بله | +| education | Enum | diploma │ postgraduate_diploma │ bachelor_s_degree │ master_s_degree │ doctorate | بله | +| fathers_name | String | نام پدر | اختیاری | +| family | String | وضعیت تأهل | اختیاری | +| job | String | شغل | اختیاری | +| address | Text | آدرس محل سکونت | اختیاری | +| home_phone | String | تلفن منزل | اختیاری | +| work_phone | String | تلفن محل کار | اختیاری | +| basic_insurance | Integer (FK→ins) | بیمه پایه | اختیاری | +| supplementary_insurance | Integer (FK→ins) | بیمه مکمل | اختیاری | +| insurance_id | String | شماره عضویت بیمه | اختیاری | +| other | JSON | متادیتای قابل توسعه | اختیاری | +| created | Timestamp | زمان ایجاد | بله | +| changed | Timestamp | آخرین ویرایش | بله | + +## ۲.۱۲ وبلاگ (Blog) + +| **نام فیلد** | **نوع داده** | **توضیحات** | **الزامی** | +| ------------ | ----------------- | --------------------- | ---------- | +| id | Integer (PK) | کلید اصلی | بله | +| uuid | UUID | شناسه یکتا | بله | +| uid | Integer (FK→User) | نویسنده | بله | +| label | String | عنوان پست | بله | +| status | Boolean | منتشرشده / پیش‌نویس | بله | +| top | Boolean | پست برجسته / پین‌شده | بله | +| images | Array (حداکثر ۵) | حداکثر ۵ تصویر | بله | +| tag | Array (حداکثر ۵) | حداکثر ۵ تگ دسته‌بندی | بله | +| created | Timestamp | زمان ایجاد | بله | +| changed | Timestamp | آخرین ویرایش | بله | + +## ۲.۱۳ نظرات، لایک و امتیازدهی + +### ۲.۱۳.۱ نظر (Comment) + +| **نام فیلد** | **نوع داده** | **توضیحات** | **الزامی** | +| ------------ | -------------------- | ------------------------------- | ---------- | +| id | Integer (PK) | کلید اصلی | بله | +| uid | Integer (FK→User) | کاربر نظردهنده | بله | +| doctor_id | Integer (FK→doctor) | دکتر مورد نظر | بله | +| comment | Text | متن نظر | بله | +| parent | Integer (FK→comment) | نظر والد (برای پاسخ‌های تودرتو) | اختیاری | +| Approved | Boolean | وضعیت تأیید از سوی مدیر | بله | +| created | Timestamp | زمان ایجاد | بله | +| changed | Timestamp | آخرین ویرایش | بله | + +### ۲.۱۳.۲ لایک (Like) + +| **نام فیلد** | **نوع داده** | **توضیحات** | **الزامی** | +| ------------ | -------------------- | ----------------------------- | ---------- | +| id | Integer (PK) | کلید اصلی | بله | +| uid | Integer (FK→User) | کاربر | بله | +| comment_id | Integer (FK→comment) | نظر هدف | بله | +| like | Boolean | true = لایک، false = دیس‌لایک | بله | +| created | Timestamp | زمان ایجاد | بله | +| changed | Timestamp | آخرین ویرایش | بله | + +### ۲.۱۳.۳ امتیاز (Rate) + +| **نام فیلد** | **نوع داده** | **توضیحات** | **الزامی** | +| ---------------------- | ------------------- | ------------------------------- | ---------- | +| id | Integer (PK) | کلید اصلی | بله | +| uid | Integer (FK→User) | کاربر امتیازدهنده | بله | +| doctor_id | Integer (FK→doctor) | دکتر امتیازگیرنده | بله | +| starts | Decimal | امتیاز ستاره‌ای کلی (مثلاً ۴.۵) | بله | +| percent | Float | درصد امتیاز کلی | بله | +| doctor_behavior | Integer (۱-۵) | امتیاز برخورد دکتر | بله | +| accuracy_of_diagnosis | Integer (۱-۵) | امتیاز دقت تشخیص | بله | +| waiting_time_at_clinic | Integer (۱-۵) | امتیاز زمان انتظار | بله | +| doctor_expertise | Integer (۱-۵) | امتیاز تخصص دکتر | بله | +| clinic_cleanliness | Integer (۱-۵) | امتیاز نظافت کلینیک | بله | +| created | Timestamp | زمان ایجاد | بله | +| changed | Timestamp | آخرین ویرایش | بله | + +# ۳. مشخصات API + +تمام APIها باید بر اساس clini_pro.json مستند شوند و ورودی، خروجی و کدهای خطا به صورت کامل مشخص باشند. + +## Task-02: احراز هویت (Authentication) + +**⚠ اصلاح شده: فقط کاربران با نقش doctor، clinic یا doctor_s_secretary می‌توانند با نام کاربری/رمز عبور لاگین کنند. مسیر /oauth/userinfo باید دقیقاً شبیه به بک‌اند دروپال طراحی شود.** + +### POST /oauth/token — ورود به سیستم + +| **Endpoint** | POST /oauth/token | +| ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Method | POST | +| **ورودی** | Content-Type: application/x-www-form-urlencoded grant_type: password username: password: client_id: client_secret: | +| خروجی | HTTP 200 { access_token: string, token_type: "Bearer", expires_in: number, refresh_token: string, scope: string } | +| **خطاها** | 400 invalid_request — پارامتر اجباری ارسال نشده 401 invalid_client — اعتبارنامه نادرست 401 invalid_grant — نام کاربری یا رمز اشتباه 403 unauthorized_client — نقش کاربر مجاز نیست (فقط: doctor, clinic, doctor_s_secretary) 500 server_error | + +### GET /oauth/userinfo — اطلاعات کاربر (سازگار با دروپال) + +| **Endpoint** | GET /oauth/userinfo | +| ------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Method | GET | +| **ورودی** | Header: Authorization: Bearer | +| خروجی | HTTP 200 { sub: string (uid), name: string (realname), preferred_username: string (mobile_number), email: string │ null, picture: string │ null, roles: string[], status: boolean, uid: number, uuid: string } نکته: ساختار پاسخ باید دقیقاً با /oauth/userinfo دروپال یکسان باشد. | +| **خطاها** | 401 Unauthorized — توکن موجود نیست یا نامعتبر است 403 Forbidden — توکن معتبر اما حساب غیرفعال است 500 server_error | + +### POST /oauth/token — تجدید توکن (Refresh) + +| **Endpoint** | POST /oauth/token (refresh) | +| ------------ | -------------------------------------------------------------------------------------------------------------- | +| Method | POST | +| **ورودی** | grant_type: refresh_token refresh_token: client_id: client_secret: | +| خروجی | HTTP 200 { access_token: string, token_type: "Bearer", expires_in: number, refresh_token: string } | +| **خطاها** | 400 invalid_request 401 invalid_grant — refresh token منقضی یا نامعتبر 500 server_error | + +## Task-05: API دکتر + +**⚠ اصلاح شده: آرایه‌های services و insurances باید در تمام پاسخ‌ها شامل id و label (نام) باشند.** + +### GET /api/v1/doctors/{id} + +| **Endpoint** | GET /api/v1/doctors/{id} | +| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Method | GET | +| **ورودی** | Path: id (integer) — شناسه دکتر Header: Authorization: Bearer (اختیاری برای داده عمومی) | +| خروجی | HTTP 200 { id, uuid, name, gender, degree, clinic_specialty: { id, label }, doctor_services: [{ id, label }, ...], ← الزامی state: { id, label }, city: { id, label }, representation: { id, label } │ null, active_doctor_appointment: boolean, doctor_rate, doctor_rate_percentage, images: [], info, activity_time } | +| **خطاها** | 404 Not Found — دکتر یافت نشد 403 Forbidden — دسترسی کافی نیست 500 server_error | + +### GET /api/v1/doctors/{id}/insurances + +| **Endpoint** | GET /api/v1/doctors/{id}/insurances | +| ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Method | GET | +| **ورودی** | Path: id (integer) — شناسه دکتر | +| خروجی | HTTP 200 { basic_insurance: [{ id, label, logo }, ...], supplementary_insurance: [{ id, label, logo }, ...] } نکته: هر آیتم باید id و label داشته باشد. | +| **خطاها** | 404 Not Found 500 server_error | + +## Task-07: API نماینده (Agent) + +**⚠ اصلاح شده: استان، شهر و شماره کارت بانکی باید در پاسخ‌ها وجود داشته باشند.** + +### GET /api/v1/representations/{id} + +| **Endpoint** | GET /api/v1/representations/{id} | +| ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Method | GET | +| **ورودی** | Path: id (integer) Header: Authorization: Bearer | +| خروجی | HTTP 200 { id, uuid, label, active, state: { id, label }, ← الزامی city: { id, label }, ← الزامی domain, address, bank_account: [ { card_number, bank_name, is_default: true }, { card_number, bank_name, is_default: false } ], ← الزامی (آرایه با یک دیفالت) commission_percent } | +| **خطاها** | 404 Not Found 403 Forbidden 500 server_error | + +### POST /api/v1/representations/{id}/bank-accounts + +| **Endpoint** | POST /api/v1/representations/{id}/bank-accounts | +| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Method | POST | +| **ورودی** | Header: Authorization: Bearer Body (JSON): { card_number: string (16 رقم), bank_name: string, is_default: boolean } نکته: اگر is_default=true باشد، دیفالت سایر کارت‌ها لغو می‌شود. | +| خروجی | HTTP 201 Created { success: true, bank_account: { card_number, bank_name, is_default } } | +| **خطاها** | 400 Bad Request — فرمت شماره کارت نامعتبر 409 Conflict — کارت قبلاً ثبت شده 403 Forbidden 500 server_error | + +## Task-08: API دسته‌بندی‌ها + +**⚠ اصلاح شده: استان‌ها و شهرها درست تنظیم نشده‌اند. تمام endpoint های دسته‌بندی باید داده کامل برگردانند.** + +### GET /api/v1/categories/states — استان‌ها + +| **Endpoint** | GET /api/v1/categories/states | +| ------------ | -------------------------------------------------------------------------- | +| Method | GET | +| **ورودی** | Query: ?status=1 (اختیاری) Header: Authorization: Bearer | +| خروجی | HTTP 200 { data: [ { id, uuid, label, status }, ... ], total: number } | +| **خطاها** | 401 Unauthorized 500 server_error | + +### GET /api/v1/categories/cities — شهرها + +| **Endpoint** | GET /api/v1/categories/cities | +| ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Method | GET | +| **ورودی** | Query: ?state_id=&status=1 (اختیاری) Header: Authorization: Bearer | +| خروجی | HTTP 200 { data: [ { id, uuid, label, status, parent: { id, label }, ← ارجاع به استان domain, representation }, ... ], total: number } | +| **خطاها** | 400 Bad Request — state_id نامعتبر 401 Unauthorized 500 server_error | + +### سایر Endpoint های دسته‌بندی — الزامی + +| **Endpoint** | **فیلدهای بازگشتی** | +| ----------------------------------------------- | ------------------------------- | +| GET /api/v1/categories/tags | id, uuid, label, status | +| GET /api/v1/categories/basic-insurances | id, uuid, label, logo, status | +| GET /api/v1/categories/supplementary-insurances | id, uuid, label, logo, status | +| GET /api/v1/categories/specialties | id, uuid, label, parent, status | +| GET /api/v1/categories/doctor-services | id, uuid, label, status | + +## Task-09: API تنظیمات نوبت + +### GET /api/v1/appointment-settings/slots — دریافت اسلات‌های خالی + +| **Endpoint** | GET /api/v1/appointment-settings/slots | +| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Method | GET | +| **ورودی** | Query: doctor_id: integer (الزامی) date: string YYYY-MM-DD (الزامی) Header: Authorization: Bearer | +| خروجی | HTTP 200 { date: string, doctor_id: integer, is_holiday: boolean, is_overridden: boolean, schedule_source: 'override' │ 'holiday_closed' │ 'weekly_schedule', available_slots: [ { slot_index, start_time, end_time, is_available }, ... ] } الگوریتم: Override > تعطیلی > برنامه هفتگی | +| **خطاها** | 400 Bad Request — پارامتر اجباری ارسال نشده 404 Not Found — دکتر یافت نشد 500 server_error | + +### POST /api/v1/appointment-settings/overrides — ثبت Override توسط دکتر + +| **Endpoint** | POST /api/v1/appointment-settings/overrides | +| ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Method | POST | +| **ورودی** | Header: Authorization: Bearer Body (JSON): { doctor_id: integer, date: string YYYY-MM-DD, setting: { slots: [{ start_time, end_time, capacity }] }, active: boolean } | +| خروجی | HTTP 201 Created { id, uuid, doctor_id, date, setting, active, created } | +| **خطاها** | 400 Bad Request — تاریخ یا تنظیم نامعتبر 403 Forbidden — دکتر مجاز به تغییر Override دیگری نیست 409 Conflict — Override برای این تاریخ قبلاً وجود دارد 500 server_error | + +### POST /api/v1/appointment-settings/holidays — ثبت تعطیلی (فقط ادمین) + +| **Endpoint** | POST /api/v1/appointment-settings/holidays | +| ------------ | -------------------------------------------------------------------------------------------------------------------------------------------- | +| Method | POST | +| **ورودی** | Header: Authorization: Bearer Body (JSON): { label: string, date: string YYYY-MM-DD, active: boolean } | +| خروجی | HTTP 201 Created { id, uuid, label, date, active, created } | +| **خطاها** | 400 Bad Request — تاریخ نامعتبر 403 Forbidden — درخواست‌دهنده ادمین نیست 409 Conflict — تعطیلی برای این تاریخ قبلاً ثبت شده 500 server_error | + +# ۴. سیستم حساب و صف پیامک + +## ۴.۱ حساب پیامک (SMS Account) + +| **نام فیلد** | **نوع داده** | **توضیحات** | **الزامی** | +| ------------ | ----------------- | ---------------------------- | ---------- | +| id | Integer (PK) | کلید اصلی | بله | +| uid | Integer (FK→User) | کاربر مالک (دکتر یا کلینیک) | بله | +| owner_type | Enum | doctor │ clinic | بله | +| owner_id | Integer | FK به doctor.id یا clinic.id | بله | +| balance | Integer | تعداد پیامک باقی‌مانده | بله | +| created | Timestamp | زمان ایجاد | بله | +| changed | Timestamp | آخرین ویرایش | بله | + +## ۴.۲ صف پیامک (SMS Queue) + +نرم‌افزار لوکال (Tauri) جاب‌های ارسال پیامک ایجاد می‌کند. بک‌اند آن‌ها را در صف قرار می‌دهد و با هر ارسال، از موجودی کسر می‌کند. + +| **نام فیلد** | **نوع داده** | **توضیحات** | **الزامی** | +| ---------------- | --------------------- | ----------------------------------------------------- | ---------- | +| id | Integer (PK) | کلید اصلی | بله | +| owner_id | Integer (FK→sms_acct) | حساب پیامک مالک | بله | +| recipient_mobile | String | شماره موبایل گیرنده | بله | +| message | Text | متن پیامک | بله | +| scheduled_at | Timestamp | زمان برنامه‌ریزی‌شده ارسال (مثلاً یک روز قبل از نوبت) | بله | +| sent_at | Timestamp | زمان واقعی ارسال | اختیاری | +| status | Enum | queued │ sent │ failed │ cancelled | بله | +| created | Timestamp | زمان ایجاد | بله | +| changed | Timestamp | آخرین ویرایش | بله | + +## ۴.۳ API پیامک + +### POST /api/v1/sms/queue — افزودن به صف + +| **Endpoint** | POST /api/v1/sms/queue | +| ------------ | --------------------------------------------------------------------------------------------------------------------------------------- | +| Method | POST | +| **ورودی** | Header: Authorization: Bearer Body (JSON): { recipient_mobile: string, message: string, scheduled_at: ISO8601 timestamp } | +| خروجی | HTTP 201 Created { id, status: 'queued', scheduled_at, remaining_balance: integer } | +| **خطاها** | 400 Bad Request — شماره موبایل یا پیام نامعتبر 402 Payment Required — موجودی پیامک کافی نیست 403 Forbidden 500 server_error | + +### GET /api/v1/sms/balance — موجودی حساب پیامک + +| **Endpoint** | GET /api/v1/sms/balance | +| ------------ | --------------------------------------------------------------------------------------- | +| Method | GET | +| **ورودی** | Header: Authorization: Bearer | +| خروجی | HTTP 200 { owner_id: integer, owner_type: 'doctor' │ 'clinic', balance: integer } | +| **خطاها** | 401 Unauthorized 404 Not Found — حساب پیامک وجود ندارد 500 server_error | + +# ۵. فهرست مشکلات شناسایی‌شده و اصلاحات لازم + +| **تسک** | **مشکل** | **اصلاح مورد نیاز** | **وضعیت** | +| ------- | -------------------------------------- | --------------------------------------------------------------- | --------- | +| عمومی | جدول کاربران درست ایجاد نشده | بازسازی با تمام فیلدهای بخش ۲.۱ | 🔴 باز | +| عمومی | جدول نقش‌ها درست ایجاد نشده | تعریف Enum نقش‌ها و اتصال به کاربر | 🔴 باز | +| عمومی | ورودی/خروجی/خطاهای API تعریف نشده | تمام APIها باید I/O و کد خطا بر اساس clini_pro.json داشته باشند | 🔴 باز | +| Task-02 | لاگین فقط برای نقش‌های مجاز | فقط doctor, clinic, doctor_s_secretary مجاز به لاگین هستند | 🔴 باز | +| Task-02 | oauth/userinfo با دروپال یکسان نیست | ساختار پاسخ باید دقیقاً با بک‌اند دروپال یکسان باشد | 🔴 باز | +| Task-05 | services/insurances فاقد id+name هستند | هر آیتم باید { id, label } داشته باشد | 🔴 باز | +| Task-07 | استان و شهر نماینده ندارد | فیلد state و city به موجودیت representation اضافه شود | 🔴 باز | +| Task-07 | اطلاعات کارت بانکی وجود ندارد | bank_account به صورت آرایه JSON با is_default اضافه شود | 🔴 باز | +| Task-08 | استان/شهر درست ست نشده | FK والد در city و endpoint استان باید داده کامل برگرداند | 🔴 باز | +| Task-08 | سایر دسته‌بندی‌ها ناقص هستند | همه دسته‌بندی‌ها باید فیلد کامل برگردانند | 🔴 باز | +| Task-09 | ثبت تعطیلی فقط توسط ادمین نیست | endpoint تعطیلات باید نقش admin بررسی کند | 🔴 باز | +| Task-09 | Override دکتر پیاده‌سازی نشده | موجودیت Date Override با دسترسی دکتر اضافه شود | 🔴 باز | +| Task-09 | اولویت الگوریتم اسلات اشتباه است | اولویت: Override > تعطیلی > برنامه هفتگی | 🔴 باز | + +# ۶. پیوست + +## ۶.۱ دیاگرام وضعیت نوبت + +- ایجاد با پرداخت آنلاین ← waiting_for_payment ← reserved (پس از پرداخت) + +- عدم پرداخت ← auto_cancel_unpaid + +- لغو توسط بیمار ← cancelled_by_patient + +- لغو توسط دکتر ← cancelled_by_doctor + +- ورود بیمار ← checked_in ← waiting ← in_progress ← visited یا no_show + +- تکمیل ← completed + +- تعویق ← postponed + +## ۶.۲ جریان کمیسیون + +- بیمار از طریق دامنه نماینده نوبت رزرو می‌کند + +- مبلغ کمیسیون (قابل تنظیم در ادمین) به کیف پول نماینده واریز می‌شود + +- نکته مهم: کمیسیون از حق نوبت‌گیری است، نه از مبلغ کل نوبت + +- در صورت سابسکرایب پلن توسط دکتر: کمیسیون به کیف پول نماینده مربوطه واریز می‌شود + +## ۶.۳ بررسی محدودیت منشی + +- هنگام فعال‌سازی منشی: تعداد منشی‌های فعال آن دکتر بررسی شود + +- پلن بیسیک: اگر ۱ منشی فعال وجود دارد، ثبت جدید رد شود + +- پلن پیشرفته: اگر ۳ منشی فعال وجود دارد، ثبت جدید رد شود + +- این بررسی باید کاملاً در سمت سرور انجام شود + +## ۶.۴ محدوده سیستم (Scope) + +کلینیک پرو صرفاً موارد زیر را هندل می‌کند: + +- ثبت‌نام و احراز هویت کاربران + +- نوبت‌دهی آنلاین + +- مدیریت نماینده و کمیسیون + +- مدیریت اشتراک + +مدیریت جزئیات کلینیک، پرونده بیماران و سایر عملکردهای پیشرفته توسط نرم‌افزار لوکال Tauri هندل می‌شود و در اسکوپ این PRD نیست. + +--- + +# بخش دوم — مستند کامل API + +--- + +## فهرست مطالب + +1. [احراز هویت (Authentication)](#1) +2. [کاربر (User)](#2) +3. [پروفایل بیمار (User Profile)](#3) +4. [دکتر (Doctor)](#4) +5. [آدرس دکتر (Doctor Address)](#5) +6. [کلینیک (Clinic)](#6) +7. [نماینده (Agent / Representation)](#7) +8. [دسته‌بندی‌ها (Categories)](#8) +9. [بیمه دکتر (Doctor Insurance)](#9) +10. [تنظیمات نوبت — برنامه هفتگی](#10) +11. [تنظیمات نوبت — Date Override](#11) +12. [تنظیمات نوبت — تعطیلات](#12) +13. [نوبت‌دهی (Appointment)](#13) +14. [منشی (Secretary)](#14) +15. [پرداخت (Payment)](#15) +16. [وبلاگ (Blog)](#16) + +--- + +## خطاهای عمومی + +| کد HTTP | عنوان | توضیح | +| ------- | --------------------- | ---------------------------------- | +| `400` | Bad Request | پارامتر اجباری نیست یا فرمت نادرست | +| `401` | Unauthorized | توکن Bearer نامعتبر یا منقضی شده | +| `403` | Forbidden | دسترسی مجاز نیست | +| `404` | Not Found | منبع یافت نشد | +| `422` | Unprocessable Entity | اعتبارسنجی داده شکست خورد | +| `500` | Internal Server Error | خطای داخلی سرور | + +--- + + + +## 1. احراز هویت (Authentication) + +### 1. 🔵 `POST` refresh token + +``` +POST /oauth/token +``` + +**🔓 احراز هویت:** الزامی نیست + +#### Request Body + +| فیلد | نوع | الزامی | توضیح / مثال | +| --------------- | ------ | ------ | --------------- | +| `grant_type` | string | ✅ | `refresh_token` | +| `client_id` | string | ✅ | | +| `client_secret` | string | ✅ | | +| `refresh_token` | string | ✅ | | + +#### پاسخ‌ها + +**`200` ✅** + +> ⚠️ ساختار پاسخ مستند نشده — باید در Symfony تعریف و مستند شود. + +--- + +### 2. 🟢 `GET` X-CSRF-Token + +``` +GET /session/token +``` + +**🔓 احراز هویت:** الزامی نیست + +> این API برای دریافت توکن CSRF از سرور استفاده می‌شود. این توکن برای ارسال درخواست‌های امن POST، PUT، DELETE و PATCH در سیستم نیاز است. + +#### پاسخ‌ها + +**`200` ✅** + +> ⚠️ ساختار پاسخ مستند نشده — باید در Symfony تعریف و مستند شود. + +--- + +### 3. 🟢 `GET` user info 🆕 + +``` +GET /oauth/userinfo +``` + +**🔐 احراز هویت:** `Authorization: Bearer {access_token}` — الزامی + +> این API اطلاعات کاربر احراز هویت‌شده را با استفاده از توکن JWT برمی‌گرداند. برای استفاده از این endpoint باید کاربر از قبل احراز هویت شده و یک access token معتبر در اختیار داشته باشد. + +#### پاسخ‌ها + +**`200` ✅** + +| فیلد | نوع | مثال / توضیح | +| ---------------- | --------- | ------------------------------------ | +| `email` | null | null | +| `email_verified` | boolean | true | +| `username` | string | 09120671710 | +| `id` | string | 22 | +| `uuid` | string | d200f5c5-d717-4526-b263-d3bb7d0228d6 | +| `created` | string | 1762262151 | +| `changed` | string | 1762262267 | +| `status` | string | 1 | +| `roles` | object | {0, 2} | +| `realName` | string | single doctor | +| `picture` | array (0) | [] | +| `clinic_pro` | object | {base_role, db_uuid, db_key...} | + +
+مثال کامل Response (کلیک کنید) + +```json +{ + "email": null, + "email_verified": true, + "username": "09120671710", + "id": "22", + "uuid": "d200f5c5-d717-4526-b263-d3bb7d0228d6", + "created": "1762262151", + "changed": "1762262267", + "status": "1", + "roles": { + "0": "authenticated", + "2": "doctor" + }, + "realName": "single doctor", + "picture": [], + "clinic_pro": { + "base_role": "doctor", + "db_uuid": "61be915b-595a-42e5-bca5-f80d22f4f14a", + "db_key": "22bea8c1dc64d9b0c744810722519efe7290276ecd82d9bc650482aa4539bf0d", + "my_doctors_uuid": { + "uuid": "61be915b-595a-42e5-bca5-f80d22f4f14a", + "id": "29", + "name": "single doctor" + } + } +} +``` + +
+ +--- + + + +## 2. کاربر (User) + +### 4. 🔵 `POST` verify code + +``` +POST /api/v1/user/verify-code +``` + +**🔓 احراز هویت:** الزامی نیست + +#### Request Body + +| فیلد | نوع | الزامی | توضیح / مثال | +| --------------- | ------ | ------ | ------------ | +| `mobile` | string | ✅ | | +| `captcha_token` | string | ✅ | | + +#### مثال Request + +```json +{ + "mobile": "09120671713", + "captcha_token": "" +} +``` + +#### پاسخ‌ها + +**`200` ✅** + +> ⚠️ ساختار پاسخ مستند نشده — باید در Symfony تعریف و مستند شود. + +--- + +### 5. 🔵 `POST` send code + +``` +POST /api/v1/user/send-code +``` + +**🔓 احراز هویت:** الزامی نیست + +> ارسال کد تأیید موبایل Endpoint POST https://back-dev. توضیح این درخواست برای ارسال کد تأیید (OTP) به شماره موبایل کاربر استفاده می‌شود. + +#### Request Body + +| فیلد | نوع | الزامی | توضیح / مثال | +| --------------- | ------ | ------ | ------------ | +| `mobile` | string | ✅ | | +| `captcha_token` | string | ✅ | | + +#### مثال Request + +```json +{ + "mobile": "09120671713", + "captcha_token": "" +} +``` + +#### پاسخ‌ها + +**`200` ✅** + +> ⚠️ ساختار پاسخ مستند نشده — باید در Symfony تعریف و مستند شود. + +--- + +### 6. 🔵 `POST` register + +``` +POST /api/v1/user/register +``` + +**🔓 احراز هویت:** الزامی نیست + +#### هدرهای اضافی + +| هدر | نوع | الزامی | توضیح | +| -------------- | ------ | ------ | ----- | +| `X-CSRF-Token` | string | ✅ | | + +#### پاسخ‌ها + +**`200` ✅** + +> ⚠️ ساختار پاسخ مستند نشده — باید در Symfony تعریف و مستند شود. + +--- + +### 7. 🔴 `DELETE` delete user + +``` +DELETE /api/v1/user/5 +``` + +**🔐 احراز هویت:** `Authorization: Bearer {access_token}` — الزامی + +#### هدرهای اضافی + +| هدر | نوع | الزامی | توضیح | +| -------------- | ------ | ------ | ----- | +| `X-CSRF-Token` | string | ✅ | | + +#### پاسخ‌ها + +**`200` ✅** + +> ⚠️ ساختار پاسخ مستند نشده — باید در Symfony تعریف و مستند شود. + +--- + +### 8. 🟡 `PATCH` patch + +``` +PATCH /api/v1/user/2 +``` + +**🔐 احراز هویت:** `Authorization: Bearer {access_token}` — الزامی + +#### هدرهای اضافی + +| هدر | نوع | الزامی | توضیح | +| -------------- | ------ | ------ | ----- | +| `X-CSRF-Token` | string | ✅ | | + +#### Request Body + +| فیلد | نوع | الزامی | توضیح / مثال | +| -------------- | ------ | ------ | ------------ | +| `field_name` | string | ✅ | | +| `field_family` | string | ✅ | | +| `mail` | string | ✅ | | + +#### مثال Request + +```json +{ + "field_name": "سحر ", + "field_family": "صادقی", + "mail": "hamedhosseini0143@gmail.com" +} +``` + +#### پاسخ‌ها + +**`200` ✅** + +> ⚠️ ساختار پاسخ مستند نشده — باید در Symfony تعریف و مستند شود. + +--- + +### 9. 🟢 `GET` list secretary + +``` +GET /api/v1/secretaries/be1fc63e-0207-4fe4-a58b-5ac30953b742 +``` + +**🔐 احراز هویت:** `Authorization: Bearer {access_token}` — الزامی + +#### پارامترهای Query + +| پارامتر | نوع | الزامی | توضیح | +| -------- | ------ | ------ | ---------------- | +| `active` | string | ✅ | — مثال: `active` | + +#### هدرهای اضافی + +| هدر | نوع | الزامی | توضیح | +| -------------- | ------ | ------ | ----- | +| `X-CSRF-Token` | string | ✅ | | +| `Content-Type` | string | ✅ | | + +#### پاسخ‌ها + +**`200` ✅** + +> ⚠️ ساختار پاسخ مستند نشده — باید در Symfony تعریف و مستند شود. + +--- + + + +## 3. پروفایل بیمار (User Profile) + +### 10. 🔵 `POST` post + +``` +POST /api/v1/user-profile +``` + +**🔐 احراز هویت:** `Authorization: Bearer {access_token}` — الزامی + +#### هدرهای اضافی + +| هدر | نوع | الزامی | توضیح | +| -------------- | ------ | ------ | ----- | +| `X-CSRF-Token` | string | ✅ | | + +#### Request Body + +| فیلد | نوع | الزامی | توضیح / مثال | +| ------------------------- | ---------------- | ------ | ------------ | +| `name` | string | ✅ | | +| `description` | array\ | ✅ | | +| `birthday` | string | ✅ | | +| `basic_insurance` | array\ | ✅ | | +| `blood_type` | string | ✅ | | +| `education` | string | ✅ | | +| `fathers_name` | string | ✅ | | +| `gender` | string | ✅ | | +| `home_phone` | string | ✅ | | +| `job` | string | ✅ | | +| `marital_status` | string | ✅ | | +| `supplementary_insurance` | string | ✅ | | +| `work_phone` | string | ✅ | | +| `address` | string | ✅ | | +| `other` | array\ | ✅ | | + +#### مثال Request + +```json +{ + "name": "test", + "description": [ + { + "value": "this is text", + "format": "basic_html" + } + ], + "birthday": "1234566", + "basic_insurance": [ + 251 + ], + "blood_type": "ab_negative", + "education": "postgraduate_diploma", + "fathers_name": "gholam", + "gender": "male", + "home_phone": "07433332178", + "job": "azad", + "marital_status": "married", + "supplementary_insurance": "308", + "work_phone": "07433332178", + "address": "askajhsaklsj", + "other": [ + { + "disease": [ + { + "id": 1, + "name": "فشار خون", + "status": "false" + }, + { + "id": 2, + "name": "دیابت", + "status": "false" + }, + { + "id": 3, + "name": "آسم", + "status": "false" + }, + { + "id": 4, + "name": "بیماری قلبی عروقی", + "status": "false" + }, + { + "id": 5, + "name": "سرطان", + "status": "false" + }, + { + "id": 6, + "name": "آلزایمر", + "status": "false" + }, + { + "id": 7, + "name": "پارکینسون", + "status": "false" + }, + { + "id": 8, + "name": "بیماری کلیوی", + "status": "false" + }, + { + "id": 9, + "name": "بیماری کبدی", + "status": "false" + }, + { + "id": 10, + "name": "سکته مغزی", + "status": "false" + }, + { + "id": 11, + "name": "افسردگی", + "status": "false" + }, + { + "id": 12, + "name": "اضطراب مزمن", + "status": "false" + }, + { + "id": 13, + "name": "میگرن", + "status": "false" + }, + { + "id": 14, + "name": "کم‌کاری تیروئید", + "status": "false" + }, + { + "id": 15, + "name": "پرکاری تیروئید", + "status": "false" + }, + { + "id": 16, + "name": "سل", + "status": "false" + }, + { + "id": 17, + "name": "هپاتیت B", + "status": "false" + }, + { + "id": 18, + "name": "هپاتیت C", + "status": "false" + }, + { + "id": 19, + "name": "HIV / ایدز", + "status": "false" + }, + { + "id": 20, + "name": "COVID-19", + "status": "false" + }, + { + "id": 21, + "name": "لوپوس", + "status": "false" + }, + { + "id": 22, + "name": "ام‌اس (MS)", + "status": "false" + }, + { + "id": 23, + "name": "آرتریت روماتوئید", + "status": "false" + }, + { + "id": 24, + "name": "پسوریازیس", + "status": "false" + }, + { + "id": 25, + "name": "صرع", + "status": "false" + }, + { + "id": 26, + "name": "بیماری سلیاک", + "status": "false" + }, + { + "id": 27, + "name": "عدم تحمل لاکتوز", + "status": "false" + }, + { + "id": 28, + "name": "چربی خون بالا", + "status": "false" + }, + { + "id": 29, + "name": "کم‌خونی", + "status": "false" + }, + { + "id": 30, + "name": "هموفیلی", + "status": "false" + } + ], + "allergies": [ + { + "substance": "پنی‌سیلین", + "reaction": "کهیر", + "severity": "شدید" + }, + { + "substance": "گرده گیاهان", + "reaction": "عطسه و آبریزش بینی", + "severity": "خفیف" + } + ], + "medications": [ + { + "name": "لورازپام", + "dose": "1mg", + "frequency": "شب‌ها قبل خواب" + }, + { + "name": "آتنولول", + "dose": "50mg", + "frequency": "صبح‌ها" + } + ], + "surgeries": [ + { + "type": "آپاندکتومی", + "year": 2015, + "hospital": "بیمارستان امام خمینی" + }, + { + "type": "عمل قلب باز", + "year": 2022, + "hospital": "بیمارستان شهید رجایی" + } + ], + "family_history": [ + { + "relation": "پدر", + "disease": "فشار خون" + }, + { + "relation": "مادر", + "disease": "دیابت نوع 2" + }, + { + "relation": "خواهر", + "disease": "آسم" + } + ], + "relatives": [ + { + "name": "علی", + "relation": "پسر عمو", + "contact": { + "phone": "+989121234567", + "email": "father@example.com", + "address": "تهران، خیابان انقلاب، پلاک 12" + } + }, + { + "name": "سارا", + "relation": "خواهر", + "contact": { + "phone": "+989121234567", + "email": "father@example.com", + "address": "تهران، خیابان انقلاب، پلاک 12" + } + } + ] + } + ] +} +``` + +#### پاسخ‌ها + +**`200` ✅** + +> ⚠️ ساختار پاسخ مستند نشده — باید در Symfony تعریف و مستند شود. + +--- + +### 11. 🟢 `GET` get + +``` +GET /api/v1/user-profile/4cb9d9d5-c5ec-4aae-a464-af56e4502aa8 +``` + +**🔐 احراز هویت:** `Authorization: Bearer {access_token}` — الزامی + +> این API برای دریافت اطلاعات پروفایل کاربر با استفاده از UUID کاربر استفاده می‌شود. دسترسی به این endpoint نیاز به احراز هویت با توکن JWT و CSRF Token دارد. + +#### هدرهای اضافی + +| هدر | نوع | الزامی | توضیح | +| -------------- | ------ | ------ | ----- | +| `X-CSRF-Token` | string | ✅ | | + +#### پاسخ‌ها + +**`200` ✅** + +> ⚠️ ساختار پاسخ مستند نشده — باید در Symfony تعریف و مستند شود. + +--- + +### 12. 🟡 `PATCH` path only disease + +``` +PATCH /api/v1/user-profile/fa845b62-194d-4f82-9007-958645026ce5 +``` + +**🔐 احراز هویت:** `Authorization: Bearer {access_token}` — الزامی + +#### هدرهای اضافی + +| هدر | نوع | الزامی | توضیح | +| -------------- | ------ | ------ | ----- | +| `X-CSRF-Token` | string | ✅ | | +| `Content-Type` | string | ✅ | | + +#### Request Body + +| فیلد | نوع | الزامی | توضیح / مثال | +| ------------------------- | ---------------- | ------ | ------------ | +| `name` | string | ✅ | | +| `description` | array\ | ✅ | | +| `birthday` | string | ✅ | | +| `basic_insurance` | array\ | ✅ | | +| `blood_type` | string | ✅ | | +| `education` | string | ✅ | | +| `fathers_name` | string | ✅ | | +| `gender` | string | ✅ | | +| `home_phone` | string | ✅ | | +| `job` | string | ✅ | | +| `marital_status` | string | ✅ | | +| `supplementary_insurance` | string | ✅ | | +| `work_phone` | string | ✅ | | +| `address` | string | ✅ | | +| `other` | array\ | ✅ | | + +#### مثال Request + +```json +{ + "name": "test", + "description": [ + { + "value": "this is text", + "format": "basic_html" + } + ], + "birthday": "1234566", + "basic_insurance": [ + 251 + ], + "blood_type": "ab_negative", + "education": "postgraduate_diploma", + "fathers_name": "gholam", + "gender": "male", + "home_phone": "07433332178", + "job": "azad", + "marital_status": "married", + "supplementary_insurance": "308", + "work_phone": "07433332178", + "address": "askajhsaklsj", + "other": [ + { + "disease": [ + { + "id": 1, + "name": "فشار خون", + "status": "false" + }, + { + "id": 2, + "name": "دیابت", + "status": "false" + }, + { + "id": 3, + "name": "آسم", + "status": "false" + }, + { + "id": 4, + "name": "بیماری قلبی عروقی", + "status": "false" + }, + { + "id": 5, + "name": "سرطان", + "status": "false" + }, + { + "id": 6, + "name": "آلزایمر", + "status": "false" + }, + { + "id": 7, + "name": "پارکینسون", + "status": "false" + }, + { + "id": 8, + "name": "بیماری کلیوی", + "status": "false" + }, + { + "id": 9, + "name": "بیماری کبدی", + "status": "false" + }, + { + "id": 10, + "name": "سکته مغزی", + "status": "false" + }, + { + "id": 11, + "name": "افسردگی", + "status": "false" + }, + { + "id": 12, + "name": "اضطراب مزمن", + "status": "false" + }, + { + "id": 13, + "name": "میگرن", + "status": "false" + }, + { + "id": 14, + "name": "کم‌کاری تیروئید", + "status": "false" + }, + { + "id": 15, + "name": "پرکاری تیروئید", + "status": "false" + }, + { + "id": 16, + "name": "سل", + "status": "false" + }, + { + "id": 17, + "name": "هپاتیت B", + "status": "false" + }, + { + "id": 18, + "name": "هپاتیت C", + "status": "false" + }, + { + "id": 19, + "name": "HIV / ایدز", + "status": "false" + }, + { + "id": 20, + "name": "COVID-19", + "status": "false" + }, + { + "id": 21, + "name": "لوپوس", + "status": "false" + }, + { + "id": 22, + "name": "ام‌اس (MS)", + "status": "false" + }, + { + "id": 23, + "name": "آرتریت روماتوئید", + "status": "false" + }, + { + "id": 24, + "name": "پسوریازیس", + "status": "false" + }, + { + "id": 25, + "name": "صرع", + "status": "false" + }, + { + "id": 26, + "name": "بیماری سلیاک", + "status": "false" + }, + { + "id": 27, + "name": "عدم تحمل لاکتوز", + "status": "false" + }, + { + "id": 28, + "name": "چربی خون بالا", + "status": "false" + }, + { + "id": 29, + "name": "کم‌خونی", + "status": "false" + }, + { + "id": 30, + "name": "هموفیلی", + "status": "false" + } + ], + "allergies": [ + { + "substance": "پنی‌سیلین", + "reaction": "کهیر", + "severity": "شدید" + }, + { + "substance": "گرده گیاهان", + "reaction": "عطسه و آبریزش بینی", + "severity": "خفیف" + } + ], + "medications": [ + { + "name": "لورازپام", + "dose": "1mg", + "frequency": "شب‌ها قبل خواب" + }, + { + "name": "آتنولول", + "dose": "50mg", + "frequency": "صبح‌ها" + } + ], + "surgeries": [ + { + "type": "آپاندکتومی", + "year": 2015, + "hospital": "بیمارستان امام خمینی" + }, + { + "type": "عمل قلب باز", + "year": 2022, + "hospital": "بیمارستان شهید رجایی" + } + ], + "family_history": [ + { + "relation": "پدر", + "disease": "فشار خون" + }, + { + "relation": "مادر", + "disease": "دیابت نوع 2" + }, + { + "relation": "خواهر", + "disease": "آسم" + } + ], + "relatives": [ + { + "name": "علی", + "relation": "پسر عمو", + "contact": { + "phone": "+989121234567", + "email": "father@example.com", + "address": "تهران، خیابان انقلاب، پلاک 12" + } + }, + { + "name": "سارا", + "relation": "خواهر", + "contact": { + "phone": "+989121234567", + "email": "father@example.com", + "address": "تهران، خیابان انقلاب، پلاک 12" + } + } + ] + } + ] +} +``` + +#### پاسخ‌ها + +**`200` ✅** + +> ⚠️ ساختار پاسخ مستند نشده — باید در Symfony تعریف و مستند شود. + +--- + +### 13. 🔴 `DELETE` delete + +``` +DELETE /api/v1/user-profile/e4fd81b4-31bc-4f9a-b300-da23026f9d79 +``` + +**🔐 احراز هویت:** `Authorization: Bearer {access_token}` — الزامی + +#### هدرهای اضافی + +| هدر | نوع | الزامی | توضیح | +| -------------- | ------ | ------ | ----- | +| `X-CSRF-Token` | string | ✅ | | + +#### پاسخ‌ها + +**`200` ✅** + +> ⚠️ ساختار پاسخ مستند نشده — باید در Symfony تعریف و مستند شود. + +--- + + + +## 4. دکتر (Doctor) + +### 14. 🔵 `POST` post + +``` +POST /api/v1/doctor +``` + +**🔐 احراز هویت:** `Authorization: Bearer {access_token}` — الزامی + +> این API برای مدیریت کامل اطلاعات پزشکان در سیستم کلینیک پرو طراحی شده است. شما می‌توانید از طریق این سرویس پزشکان جدید ثبت کنید، اطلاعات آن‌ها را مشاهده، ویرایش یا حذف کنید. + +#### هدرهای اضافی + +| هدر | نوع | الزامی | توضیح | +| -------------- | ------ | ------ | ----- | +| `X-CSRF-Token` | string | ✅ | | + +#### پاسخ‌ها + +**`200` ✅** + +> ⚠️ ساختار پاسخ مستند نشده — باید در Symfony تعریف و مستند شود. + +--- + +### 15. 🟡 `PATCH` patch + +``` +PATCH /api/v1/doctor/9eb88108-1411-4e78-93ee-9b2149233f4e +``` + +**🔐 احراز هویت:** `Authorization: Bearer {access_token}` — الزامی + +> این API برای مدیریت کامل اطلاعات پزشکان در سیستم کلینیک پرو طراحی شده است. شما می‌توانید از طریق این سرویس پزشکان جدید ثبت کنید، اطلاعات آن‌ها را مشاهده، ویرایش یا حذف کنید. + +#### هدرهای اضافی + +| هدر | نوع | الزامی | توضیح | +| -------------- | ------ | ------ | ----- | +| `X-CSRF-Token` | string | ✅ | | +| `Content-Type` | string | ✅ | | + +#### Request Body + +| فیلد | نوع | الزامی | توضیح / مثال | +| ----------------- | --------------- | ------ | ------------ | +| `title` | string | ✅ | | +| `doctor_services` | array\ | ✅ | | + +#### مثال Request + +```json +{ + "title": "hamed", + "doctor_services": [ + "اکوکاردیوگرافی" + ] +} +``` + +#### پاسخ‌ها + +**`200` ✅** + +> ⚠️ ساختار پاسخ مستند نشده — باید در Symfony تعریف و مستند شود. + +--- + +### 16. 🔴 `DELETE` delete + +``` +DELETE /api/v1/doctor/9eb88108-1411-4e78-93ee-9b2149233f4e +``` + +**🔐 احراز هویت:** `Authorization: Bearer {access_token}` — الزامی + +> این API برای مدیریت کامل اطلاعات پزشکان در سیستم کلینیک پرو طراحی شده است. شما می‌توانید از طریق این سرویس پزشکان جدید ثبت کنید، اطلاعات آن‌ها را مشاهده، ویرایش یا حذف کنید. + +#### هدرهای اضافی + +| هدر | نوع | الزامی | توضیح | +| -------------- | ------ | ------ | ----- | +| `X-CSRF-Token` | string | ✅ | | +| `Content-Type` | string | ✅ | | + +#### پاسخ‌ها + +**`200` ✅** + +> ⚠️ ساختار پاسخ مستند نشده — باید در Symfony تعریف و مستند شود. + +--- + +### 17. 🟢 `GET` get 🆕 + +``` +GET /api/v1/doctor/61be915b-595a-42e5-bca5-f80d22f4f14a +``` + +**🔓 احراز هویت:** الزامی نیست + +> این API برای مدیریت کامل اطلاعات پزشکان در سیستم کلینیک پرو طراحی شده است. شما می‌توانید از طریق این سرویس پزشکان جدید ثبت کنید، اطلاعات آن‌ها را مشاهده، ویرایش یا حذف کنید. + +#### پاسخ‌ها + +**`200` ✅** + +| فیلد | نوع | مثال / توضیح | +| --------------------- | --------- | -------------------------------------------- | +| `id` | string | 29 | +| `uuid` | string | 61be915b-595a-42e5-bca5-f80d22f4f14a | +| `name` | string | single doctor | +| `gender` | string | woman | +| `experience` | integer | 21 | +| `activity_time` | string | 1107808200 | +| `medical_system_code` | string | 121212121212 | +| `detail` | string | test | +| `degree` | string | specialist | +| `specialties` | array (1) | [{uuid, id, name}] | +| `img` | array (1) | [{url, fid, filename}] | +| `expertise` | array (3) | [{uuid, id, name}] | +| `satisfaction` | string | 60 | +| `point` | string | 3.5 | +| `free_turn` | string | اولین نوبت آزاد: سه‌شنبه 19 خرداد ساعت 15:00 | +| `hours_of_work` | string | از شنبه تا چهارشنبه از ساعت 08:00 تا 18:00 | +| `address` | array (2) | [{id, uuid, name}] | +| `average_rate` | object | {total_rates} | +| `state` | array (1) | [{uuid, id, name}] | +| `city` | array (1) | [{uuid, id, name}] | + +
+مثال کامل Response (کلیک کنید) + +```json +{ + "id": "29", + "uuid": "61be915b-595a-42e5-bca5-f80d22f4f14a", + "name": "single doctor", + "gender": "woman", + "experience": 21, + "activity_time": "1107808200", + "medical_system_code": "121212121212", + "detail": "test", + "degree": "specialist", + "specialties": [ + { + "uuid": "d60a269d-f7d7-4589-8eab-451e6f740d40", + "id": "603", + "name": "داخلی عمومی", + "parent": "602" + } + ], + "img": [ + { + "url": "https://clinic-pro-back.ddev.site/sites/default/files/doctors/2025-11/2025-11-19-090938-screenclip.png", + "fid": "98", + "filename": "2025-11-19-090938-screenclip.png", + "filemime": "image/png", + "filesize": 173665 + } + ], + "expertise": [ + { + "uuid": "6faff90a-92f6-4fe2-9f67-b11e2b82a0c6", + "id": "1601", + "name": "معاینه و تشخیص پزشک متخصص" + }, + { + "uuid": "2c4d4446-4e4a-42de-829b-c51d3103ad04", + "id": "1602", + "name": "ویزیت تخصصی" + }, + { + "uuid": "e5ac35df-af8f-4cef-8119-e92645512313", + "id": "1603", + "name": "ویزیت فوق تخصص" + } + ], + "satisfaction": "60", + "point": "3.5", + "free_turn": "اولین نوبت آزاد: سه‌شنبه 19 خرداد ساعت 15:00", + "hours_of_work": "از شنبه تا چهارشنبه از ساعت 08:00 تا 18:00", + "address": [ + { + "id": "39", + "uuid": "7b759d2a-af8a-4730-8eb0-e77dcd3a724e", + "name": "2آدرس مطب", + "map": { + "latitude": "53.121212", + "longitude": "57.121212" + }, + "address": "آذربايجان غربي، مياندوآب، خيابان ۱۵ خرداد (پشت پارک معلم)، برج ماندگار، طبقه هشتم", + "telephone": "09120671756" + }, + { + "id": "40", + "uuid": "458fcbc7-32be-4d30-80dd-f7c4c6f1dace", + "name": "2آدرس مطب", + "map": { + "latitude": "53.121212", + "longitude": "57.121212" + }, + "address": "آذربايجان غربي، مياندوآب، خيابان ۱۵ خرداد (پشت پارک معلم)، برج ماندگار، طبقه هشتم", + "telephone": "09120671756" + } + ], + "average_rate": { + "total_rates": null + }, + "state": [ + { + "uuid": "fffae390-b2e4-403f-8b1d-f2cb294c8059", + "id": "23", + "name": "کهگیلویه و بویراحمد" + } + ], + "city": [ + { + "uuid": "17993c45-8b46-41b8-8e1d-f1d7243a7b75", + "id": "123", + "name": "یاسوج", + "parent": "23" + } + ] +} +``` + +
+ +--- + +### 18. 🟢 `GET` doctor list 🆕 + +``` +GET /api/v1/doctors +``` + +**🔓 احراز هویت:** الزامی نیست + +> این API برای دریافت لیست پزشکان با قابلیت‌های پیشرفته جستجو، فیلتر و صفحه‌بندی طراحی شده است. شما می‌توانید از طریق این سرویس لیست کاملی از پزشکان را با امکان فیلتر کردن بر اساس نام، استان، شهر، تخصص، جنسیت، مدرک تحصیلی و وضعیت فعالیت دریافت کنید. + +#### پارامترهای Query + +| پارامتر | نوع | الزامی | توضیح | +| ----------- | ------ | ------ | ------------------------------ | +| `name` | string | — | — مثال: `علی` | +| `state` | string | ✅ | — مثال: `31` | +| `city` | string | ✅ | — مثال: `62` | +| `specialty` | string | — | — مثال: `503` | +| `gender` | string | — | — مثال: `man` | +| `degree` | string | — | — مثال: `general` | +| `active` | array | — | — مثال: `0` | +| `page` | string | — | — مثال: `1` | +| `limit` | string | — | — مثال: `10` | +| `sort` | string | — | sort= ASC, DESC — مثال: `DESC` | + +#### پاسخ‌ها + +**`200` ✅** + +| فیلد | نوع | مثال / توضیح | +| ------ | --------- | --------------------------------------- | +| `data` | array (7) | [{id, uuid, name}] | +| `page` | object | {totalRecords, totalPages, currentPage} | + +
+مثال کامل Response (کلیک کنید) + +```json +{ + "data": [ + { + "id": "18", + "uuid": "801094ca-b20d-4bfe-828c-37ff62c8a45c", + "name": "doctor clinic", + "gender": "woman", + "degree": "specialist", + "img": [ + { + "url": "https://clinic-pro-back.ddev.site/sites/default/files/insurance/images/tamin_ejtemaei.jpg", + "fid": "23", + "filename": "tamin_ejtemaei.jpg", + "filemime": "image/jpeg", + "filesize": 418044 + } + ], + "specialties": [ + { + "uuid": "cd22005c-759a-4a4b-b54a-5471948d7ca4", + "id": "602", + "name": "داخلی", + "parent": null + }, + { + "uuid": "d60a269d-f7d7-4589-8eab-451e6f740d40", + "id": "603", + "name": "داخلی عمومی", + "parent": "602" + } + ], + "satisfaction": "60", + "point": "3.5", + "free_turn": "نوبت آزادی موجود نیست", + "hours_of_work": "اطلاعات برنامه هفتگی موجود نیست", + "active": true + }, + { + "id": "20", + "uuid": "9109ad21-62fa-42ec-aeee-1e45a88e54b9", + "name": "doctor 3", + "gender": "woman", + "degree": "specialist", + "img": [ + { + "url": "https://clinic-pro-back.ddev.site/sites/default/files/insurance/images/tamin_ejtemaei.jpg", + "fid": "23", + "filename": "tamin_ejtemaei.jpg", + "filemime": "image/jpeg", + "filesize": 418044 + } + ], + "specialties": [ + { + "uuid": "cd22005c-759a-4a4b-b54a-5471948d7ca4", + "id": "602", + "name": "داخلی", + "parent": null + }, + { + "uuid": "d60a269d-f7d7-4589-8eab-451e6f740d40", + "id": "603", + "name": "داخلی عمومی", + "parent": "602" + } + ], + "satisfaction": "60", + "point": "3.5", + "free_turn": "نوبت آزادی موجود نیست", + "hours_of_work": "اطلاعات برنامه هفتگی موجود نیست", + "active": true + }, + { + "id": "21", + "uuid": "2123fc79-5ef2-4d8d-96b9-1aee353e58e4", + "name": "doctor 2", + "gender": "woman", + "degree": "specialist", + "img": [ + { + "url": "https://clinic-pro-back.ddev.site/sites/default/files/insurance/images/tamin_ejtemaei.jpg", + "fid": "23", + "filename": "tamin_ejtemaei.jpg", + "filemime": "image/jpeg", + "filesize": 418044 + } + ], + "specialties": [ + { + "uuid": "cd22005c-759a-4a4b-b54a-5471948d7ca4", + "id": "602", + "name": "داخلی", + "parent": null + }, + { + "uuid": "d60a269d-f7d7-4589-8eab-451e6f740d40", + "id": "603", + "name": "داخلی عمومی", + "parent": "602" + } + ], + "satisfaction": "60", + "point": "3.5", + "free_turn": "نوبت آزادی موجود نیست", + "hours_of_work": "اطلاعات برنامه هفتگی موجود نیست", + "active": true + }, + { + "id": "22", + "uuid": "c1eaea3b-6b36-4547-9fa3-bea050117fd2", + "name": "doctor 1", + "gender": "woman", + "degree": "specialist", + "img": [ + { + "url": "https://clinic-pro-back.ddev.site/sites/default/files/insurance/images/tamin_ejtemaei.jpg", + "fid": "23", + "filename": "tamin_ejtemaei.jpg", + "filemime": "image/jpeg", + "filesize": 418044 + } + ], + "specialties": [ + { + "uuid": "cd22005c-759a-4a4b-b54a-5471948d7ca4", + "id": "602", + "name": "داخلی", + "parent": null + }, + { + "uuid": "d60a269d-f7d7-4589-8eab-451e6f740d40", + "id": "603", + "name": "داخلی عمومی", + "parent": "602" + } + ], + "satisfaction": "60", + "point": "3.5", + "free_turn": "نوبت آزادی موجود نیست", + "hours_of_work": "اطلاعات برنامه هفتگی موجود نیست", + "active": true + }, + { + "id": "29", + "uuid": "61be915b-595a-42e5-bca5-f80d22f4f14a", + "name": "single doctor", + "gender": "woman", + "degree": "specialist", + "img": [ + { + "url": "https://clinic-pro-back.ddev.site/sites/default/files/doctors/2025-11/2025-11-19-090938-screenclip.png", + "fid": "98", + "filename": "2025-11-19-090938-screenclip.png", + "filemime": "image/png", + "filesize": 173665 + } + ], + "specialties": [ + { + "uuid": "d60a269d-f7d7-4589-8eab-451e6f740d40", + "id": "603", + "name": "داخلی عمومی", + "parent": "602" + } + ], + "satisfaction": "60", + "point": "3.5", + "free_turn": "اولین نوبت آزاد: سه‌شنبه 19 خرداد ساعت 15:00", + "hours_of_work": "از شنبه تا چهارشنبه از ساعت 08:00 تا 18:00", + "active": true + }, + { + "id": "35", + "uuid": "ce7afecc-57fd-4496-a3fd-e451b9e2d271", + "name": "سحر", + "gender": "woman", + "degree": "general", + "img": [ + { + "url": "https://clinic-pro-back.ddev.site/sites/default/files/doctors/2025-11/003.png", + "fid": "24", + "filename": "003.png", + "filemime": "image/png", + "filesize": 34314 + } + ], + "specialties": [ + { + "uuid": "d60a269d-f7d7-4589-8eab-451e6f740d40", + "id": "603", + "name": "داخلی عمومی", + "parent": "602" + } + ], + "satisfaction": "60", + "point": "3.5", + "free_turn": "نوبت آزادی موجود نیست", + "hours_of_work": "اطلاعات برنامه هفتگی موجود نیست", + "active": true + }, + { + "id": "34", + "uuid": "1f8b1601-b8f5-4538-ad4a-cfc25843f533", + "name": null, + "gender": null, + "degree": null, + "img": [], + "specialties": [], + "satisfaction": null, + "point": null, + "free_turn": "نوبت آزادی موجود نیست", + "hours_of_work": "اطلاعات برنامه هفتگی موجود نیست", + "active": true + } + ], + "page": { + "totalRecords": 7, + "totalPages": 1, + "currentPage": 1 + } +} +``` + +
+ +--- + +### 19. 🔵 `POST` image upload + +``` +POST /file/upload/clinic_pro/doctor/field_image +``` + +**🔐 احراز هویت:** `Authorization: Bearer {access_token}` — الزامی + +#### هدرهای اضافی + +| هدر | نوع | الزامی | توضیح | +| --------------------- | ------ | ------ | ----- | +| `Content-Type` | string | ✅ | | +| `Content-Disposition` | string | ✅ | | +| `X-CSRF-Token` | string | ✅ | | + +#### پاسخ‌ها + +**`200` ✅** + +> ⚠️ ساختار پاسخ مستند نشده — باید در Symfony تعریف و مستند شود. + +--- + +### 20. 🟢 `GET` Clinic Doctor List + +``` +GET /api/v1/clinic/doctor-list/e4550163-5a88-4f67-b07e-cd6063738598 +``` + +**🔓 احراز هویت:** الزامی نیست + +> این API برای دریافت لیست پزشکان یک کلینیک خاص طراحی شده است. با استفاده از این سرویس می‌توانید لیست پزشکان یک کلینیک را با امکانات فیلتر، جستجو، مرتب‌سازی و صفحه‌بندی دریافت کنید. + +#### پارامترهای Query + +| پارامتر | نوع | الزامی | توضیح | +| ----------- | ------ | ------ | ------------------------------ | +| `name` | string | — | — مثال: `علی` | +| `state` | string | ✅ | — مثال: `31` | +| `city` | string | ✅ | — مثال: `62` | +| `specialty` | string | — | — مثال: `503` | +| `gender` | string | — | — مثال: `man` | +| `degree` | string | — | — مثال: `general` | +| `active` | array | — | — مثال: `0` | +| `page` | string | — | — مثال: `1` | +| `limit` | string | — | — مثال: `10` | +| `sort` | string | — | sort= ASC, DESC — مثال: `DESC` | + +#### پاسخ‌ها + +**`200` ✅** + +> ⚠️ ساختار پاسخ مستند نشده — باید در Symfony تعریف و مستند شود. + +--- + +### 21. 🟢 `GET` all doctor services + +``` +GET /api/v1/categorys/doctor_services +``` + +**🔓 احراز هویت:** الزامی نیست + +> این API برای دریافت و مدیریت دسته‌بندی‌های مختلف سیستم طراحی شده است. شما می‌توانید از طریق این API لیست دسته‌بندی‌های مختلفی مانند شهرها، استان‌ها، تخصص‌های پزشکی، انواع بیمه و سایر دسته‌بندی‌ها را با قابلیت صفحه‌بندی و فیلتر دریافت کنید. + +#### پارامترهای Query + +| پارامتر | نوع | الزامی | توضیح | +| ------- | ------ | ------ | ------------ | +| `page` | string | ✅ | — مثال: `1` | +| `limit` | string | ✅ | — مثال: `30` | + +#### پاسخ‌ها + +**`200` ✅** + +> ⚠️ ساختار پاسخ مستند نشده — باید در Symfony تعریف و مستند شود. + +--- + +### 22. 🟢 `GET` get doctor rate + +``` +GET /api/v1/clinicpro-comment/doctor-rate/86483134-d5b9-4110-a49c-f0c640ac0e00 +``` + +**🔓 احراز هویت:** الزامی نیست + +> ارسالی در url مقدار uuid دکتر می باشد + +#### هدرهای اضافی + +| هدر | نوع | الزامی | توضیح | +| -------------- | ------ | ------ | ----- | +| `Content-Type` | string | ✅ | | + +#### پاسخ‌ها + +**`200` ✅** + +> ⚠️ ساختار پاسخ مستند نشده — باید در Symfony تعریف و مستند شود. + +--- + +### 23. 🔴 `DELETE` delete + +``` +DELETE /api/v1/rate/doctor/2824a1d0-edff-4b1f-84aa-ac617dcfadb2 +``` + +**🔐 احراز هویت:** `Authorization: Bearer {access_token}` — الزامی + +#### هدرهای اضافی + +| هدر | نوع | الزامی | توضیح | +| -------------- | ------ | ------ | ----- | +| `X-CSRF-Token` | string | ✅ | | +| `Content-Type` | string | ✅ | | + +#### پاسخ‌ها + +**`200` ✅** + +> ⚠️ ساختار پاسخ مستند نشده — باید در Symfony تعریف و مستند شود. + +--- + + + +## 5. آدرس دکتر (Doctor Address) + +### 24. 🔵 `POST` post + +``` +POST /api/v1/clinic-pro/doctor-address +``` + +**🔐 احراز هویت:** `Authorization: Bearer {access_token}` — الزامی + +> این API برای مدیریت کامل آدرس‌های مطب‌های پزشکان طراحی شده است. شما می‌توانید از طریق این سرویس آدرس‌های جدید برای پزشکان ایجاد کنید، آدرس‌های موجود را مشاهده، ویرایش یا حذف کنید. + +#### هدرهای اضافی + +| هدر | نوع | الزامی | توضیح | +| -------------- | ------ | ------ | ----- | +| `X-CSRF-Token` | string | ✅ | | + +#### پاسخ‌ها + +**`200` ✅** + +> ⚠️ ساختار پاسخ مستند نشده — باید در Symfony تعریف و مستند شود. + +--- + +### 25. 🟡 `PATCH` patch + +``` +PATCH /api/v1/clinic-pro/doctor-address/48 +``` + +**🔐 احراز هویت:** `Authorization: Bearer {access_token}` — الزامی + +> این API برای مدیریت کامل آدرس‌های مطب‌های پزشکان طراحی شده است. شما می‌توانید از طریق این سرویس آدرس‌های جدید برای پزشکان ایجاد کنید، آدرس‌های موجود را مشاهده، ویرایش یا حذف کنید. + +#### هدرهای اضافی + +| هدر | نوع | الزامی | توضیح | +| -------------- | ------ | ------ | ----- | +| `X-CSRF-Token` | string | ✅ | | +| `Content-Type` | string | ✅ | | + +#### Request Body + +| فیلد | نوع | الزامی | توضیح / مثال | +| ----------------- | --------------- | ------ | ------------ | +| `title` | string | ✅ | | +| `doctor_services` | array\ | ✅ | | + +#### مثال Request + +```json +{ + "title": "hamed", + "doctor_services": [ + "اکوکاردیوگرافی" + ] +} +``` + +#### پاسخ‌ها + +**`200` ✅** + +> ⚠️ ساختار پاسخ مستند نشده — باید در Symfony تعریف و مستند شود. + +--- + +### 26. 🔴 `DELETE` delete + +``` +DELETE /api/v1/clinic-pro/doctor-address/37 +``` + +**🔐 احراز هویت:** `Authorization: Bearer {access_token}` — الزامی + +> این API برای مدیریت کامل آدرس‌های مطب‌های پزشکان طراحی شده است. شما می‌توانید از طریق این سرویس آدرس‌های جدید برای پزشکان ایجاد کنید، آدرس‌های موجود را مشاهده، ویرایش یا حذف کنید. + +#### هدرهای اضافی + +| هدر | نوع | الزامی | توضیح | +| -------------- | ------ | ------ | ----- | +| `X-CSRF-Token` | string | ✅ | | +| `Content-Type` | string | ✅ | | + +#### پاسخ‌ها + +**`200` ✅** + +> ⚠️ ساختار پاسخ مستند نشده — باید در Symfony تعریف و مستند شود. + +--- + +### 27. 🟢 `GET` get 🆕 + +``` +GET /api/v1/clinic-pro/doctor-address/39 +``` + +**🔐 احراز هویت:** `Authorization: Bearer {access_token}` — الزامی + +> این API برای مدیریت کامل آدرس‌های مطب‌های پزشکان طراحی شده است. شما می‌توانید از طریق این سرویس آدرس‌های جدید برای پزشکان ایجاد کنید، آدرس‌های موجود را مشاهده، ویرایش یا حذف کنید. + +#### هدرهای اضافی + +| هدر | نوع | الزامی | توضیح | +| -------------- | ------ | ------ | ----- | +| `X-CSRF-Token` | string | — | | + +#### پاسخ‌ها + +**`200` ✅** + +| فیلد | نوع | مثال / توضیح | +| ----------- | ------ | ------------------------------------------------------------ | +| `id` | string | 39 | +| `uuid` | string | 7b759d2a-af8a-4730-8eb0-e77dcd3a724e | +| `name` | string | 2آدرس مطب | +| `map` | object | {latitude, longitude} | +| `address` | string | آذربايجان غربي، مياندوآب، خيابان ۱۵ خرداد (پشت پارک معلم)، ب | +| `telephone` | string | 09120671756 | + +
+مثال کامل Response (کلیک کنید) + +```json +{ + "id": "39", + "uuid": "7b759d2a-af8a-4730-8eb0-e77dcd3a724e", + "name": "2آدرس مطب", + "map": { + "latitude": "53.121212", + "longitude": "57.121212" + }, + "address": "آذربايجان غربي، مياندوآب، خيابان ۱۵ خرداد (پشت پارک معلم)، برج ماندگار، طبقه هشتم", + "telephone": "09120671756" +} +``` + +
+ +--- + +### 28. 🟢 `GET` list 🆕 + +``` +GET /api/v1/clinic-pro/doctor-addresses/29 +``` + +**🔓 احراز هویت:** الزامی نیست + +> این API برای دریافت لیست آدرس‌های مطب‌های یک پزشک خاص طراحی شده است. با استفاده از این سرویس می‌توانید تمام مکان‌هایی که پزشک مورد نظر در آنها فعالیت می‌کند را همراه با جزئیات کامل آدرس، ساعات کاری، اطلاعات تماس و موقعیت جغرافیایی دریافت کنید. + +#### پاسخ‌ها + +**`200` ✅** + +| فیلد | نوع | مثال / توضیح | +| ------ | --------- | --------------------------------------- | +| `data` | array (2) | [{id, uuid, name}] | +| `page` | object | {totalRecords, totalPages, currentPage} | + +
+مثال کامل Response (کلیک کنید) + +```json +{ + "data": [ + { + "id": "39", + "uuid": "7b759d2a-af8a-4730-8eb0-e77dcd3a724e", + "name": "2آدرس مطب", + "map": { + "latitude": "53.121212", + "longitude": "57.121212" + }, + "address": "آذربايجان غربي، مياندوآب، خيابان ۱۵ خرداد (پشت پارک معلم)، برج ماندگار، طبقه هشتم", + "telephone": "09120671756" + }, + { + "id": "40", + "uuid": "458fcbc7-32be-4d30-80dd-f7c4c6f1dace", + "name": "2آدرس مطب", + "map": { + "latitude": "53.121212", + "longitude": "57.121212" + }, + "address": "آذربايجان غربي، مياندوآب، خيابان ۱۵ خرداد (پشت پارک معلم)، برج ماندگار، طبقه هشتم", + "telephone": "09120671756" + } + ], + "page": { + "totalRecords": 2, + "totalPages": 1, + "currentPage": 1 + } +} +``` + +
+ +--- + + + +## 6. کلینیک (Clinic) + +### 29. 🟢 `GET` clinic list 🆕 + +``` +GET /api/v1/clinics +``` + +**🔓 احراز هویت:** الزامی نیست + +> این API برای دریافت لیست کامل کلینیک‌های موجود در سیستم طراحی شده است. شما می‌توانید از طریق این سرویس لیست تمام کلینیک‌ها را با امکانات پیشرفته فیلترینگ، جستجو و صفحه‌بندی دریافت کنید. + +#### پارامترهای Query + +| پارامتر | نوع | الزامی | توضیح | +| ----------- | ------ | ------ | -------------- | +| `state` | string | — | — مثال: `13` | +| `city` | string | — | — مثال: `32` | +| `specialty` | string | — | — مثال: `511` | +| `page` | string | ✅ | — مثال: `1` | +| `limit` | string | ✅ | — مثال: `10` | +| `sort` | string | — | — مثال: `DESC` | + +#### پاسخ‌ها + +**`200` ✅** + +| فیلد | نوع | مثال / توضیح | +| ------ | --------- | --------------------------------------- | +| `data` | array (4) | [{id, uuid, title}] | +| `page` | object | {totalRecords, totalPages, currentPage} | + +
+مثال کامل Response (کلیک کنید) + +```json +{ + "data": [ + { + "id": "23", + "uuid": "e4550163-5a88-4f67-b07e-cd6063738598", + "title": "clinic 1", + "state": [ + { + "uuid": "c0fe6c46-6120-4c53-8d6a-def5b6de3173", + "id": "29", + "name": "هرمزگان" + } + ], + "city": [ + { + "uuid": "81b18aae-7701-47d7-a07b-b3316c2d5081", + "id": "130", + "name": "بندرعباس", + "parent": "29" + } + ], + "clinic_logo": [ + { + "url": "https://clinic-pro-back.ddev.site/sites/default/files/clinics/logo/2025-11/003_7.png", + "fid": "96", + "filename": "003_7.png", + "filemime": "image/png", + "filesize": 39281 + } + ], + "doctors": 3, + "address": " بندرعباس: رسالت شمالی- میدان صادقیه- ساختمان پاسارگاد- طبقه 4 و 5", + "field_working_days": "شنبه تا سه شنبه ساعد ۱۲:۲۰" + }, + { + "id": "24", + "uuid": "2a58c3c3-9755-4231-8ab8-27030405e55d", + "title": "clinic 2", + "state": [ + { + "uuid": "4f95230b-8cd0-4934-89cc-bd5719412c61", + "id": "8", + "name": "تهران" + } + ], + "city": [], + "clinic_logo": [], + "doctors": 1, + "address": " بندرعباس: رسالت شمالی- میدان صادقیه- ساختمان پاسارگاد- طبقه 4 و 5", + "field_working_days": "شنبه تا سه شنبه ساعد ۱۲:۲۰" + }, + { + "id": "32", + "uuid": "4919e517-2719-40bf-b35a-e36a5d8a93f1", + "title": null, + "state": [], + "city": [], + "clinic_logo": [], + "doctors": 0, + "address": null, + "field_working_days": null + }, + { + "id": "33", + "uuid": "6f6b2e6c-09db-4577-8fed-9ba92e82786c", + "title": null, + "state": [], + "city": [], + "clinic_logo": [], + "doctors": 0, + "address": null, + "field_working_days": null + } + ], + "page": { + "totalRecords": 4, + "totalPages": 1, + "currentPage": 1 + } +} +``` + +
+ +--- + +### 30. 🟢 `GET` get 🆕 + +``` +GET /api/v1/clinic/e4550163-5a88-4f67-b07e-cd6063738598 +``` + +**🔓 احراز هویت:** الزامی نیست + +> این API برای مدیریت کامل کلینیک‌ها در سیستم طراحی شده است. شما می‌توانید از طریق این API کلینیک‌های جدید ایجاد کنید، اطلاعات کلینیک‌های موجود را مشاهده، ویرایش یا حذف کنید. + +#### پاسخ‌ها + +**`200` ✅** + +| فیلد | نوع | مثال / توضیح | +| -------------------- | --------- | ------------------------------------------------------------ | +| `id` | string | 23 | +| `uuid` | string | e4550163-5a88-4f67-b07e-cd6063738598 | +| `title` | string | clinic 1 | +| `images_clinic` | array (5) | [{url, fid, filename}] | +| `clinic_logo` | array (1) | [{url, fid, filename}] | +| `phone_number` | string | ۰۶۱-۳۳۹۱۶۵۸۹ | +| `caption` | string | مجموعه کلینیک‌های فخرائی دارای 20 سال سابقه‌‌ی فعالیت در زمی | +| `list_bime` | array (4) | [{uuid, id, name}] | +| `specialties` | array (3) | [{uuid, id, name}] | +| `services` | array (5) | [{uuid, id, name}] | +| `clinic_specialty` | array (3) | [{uuid, id, name}] | +| `doctors` | integer | 3 | +| `doctor_list` | null | null | +| `city` | array (1) | [{uuid, id, name}] | +| `state` | array (1) | [{uuid, id, name}] | +| `location` | string | بندرعباس: رسالت شمالی- میدان صادقیه- ساختمان پاسارگاد- طبقه | +| `map` | object | {latitude, longitude} | +| `24_7` | boolean | true | +| `field_working_days` | string | شنبه تا سه شنبه ساعد ۱۲:۲۰ | + +
+مثال کامل Response (کلیک کنید) + +```json +{ + "id": "23", + "uuid": "e4550163-5a88-4f67-b07e-cd6063738598", + "title": "clinic 1", + "images_clinic": [ + { + "url": "https://clinic-pro-back.ddev.site/sites/default/files/2025-11/003_52.png", + "fid": "91", + "filename": "003_52.png", + "filemime": "image/png", + "filesize": 34314 + }, + { + "url": "https://clinic-pro-back.ddev.site/sites/default/files/2025-11/003_53.png", + "fid": "92", + "filename": "003_53.png", + "filemime": "image/png", + "filesize": 34314 + }, + { + "url": "https://clinic-pro-back.ddev.site/sites/default/files/2025-11/003_54.png", + "fid": "93", + "filename": "003_54.png", + "filemime": "image/png", + "filesize": 34314 + }, + { + "url": "https://clinic-pro-back.ddev.site/sites/default/files/2025-11/003_55.png", + "fid": "94", + "filename": "003_55.png", + "filemime": "image/png", + "filesize": 34314 + }, + { + "url": "https://clinic-pro-back.ddev.site/sites/default/files/2025-11/003_56.png", + "fid": "95", + "filename": "003_56.png", + "filemime": "image/png", + "filesize": 34314 + } + ], + "clinic_logo": [ + { + "url": "https://clinic-pro-back.ddev.site/sites/default/files/clinics/logo/2025-11/003_7.png", + "fid": "96", + "filename": "003_7.png", + "filemime": "image/png", + "filesize": 39281 + } + ], + "phone_number": "۰۶۱-۳۳۹۱۶۵۸۹", + "caption": "مجموعه کلینیک‌های فخرائی دارای 20 سال سابقه‌‌ی فعالیت در زمینه کاشت مو و زیبایی در داخل ایران و دیگر کشور‌های جهان است. مجموعه ما در طی این مدت با کسب بیش از صدهزار تجربه موفق در این زمینه توانسته است...", + "list_bime": [ + { + "uuid": "aee3930e-9532-47a0-829c-a18c813ce566", + "id": "1559", + "name": "بیمه آتیه سازان حافظ", + "logo": [ + { + "url": "https://clinic-pro-back.ddev.site/sites/default/files/insurance/images/hafez-insurance.png", + "fid": "5", + "filename": "hafez-insurance.png", + "filemime": "image/png", + "filesize": 16100 + } + ] + }, + { + "uuid": "5cc67174-b6ab-4dfd-8d10-c9899bfb2dc3", + "id": "1555", + "name": "بیمه SOS", + "logo": [ + { + "url": "https://clinic-pro-back.ddev.site/sites/default/files/insurance/images/sos.jpeg", + "fid": "21", + "filename": "sos.jpeg", + "filemime": "image/jpeg", + "filesize": 14933 + } + ] + }, + { + "uuid": "f0bfa574-129f-4152-9634-f4f4f9ad098a", + "id": "1558", + "name": "بیمه حکمت", + "logo": [ + { + "url": "https://clinic-pro-back.ddev.site/sites/default/files/insurance/images/hekmat.png", + "fid": "6", + "filename": "hekmat.png", + "filemime": "image/png", + "filesize": 3999 + } + ] + }, + { + "uuid": "47aeb87b-317e-4a3a-a0be-bf22d355c376", + "id": "1552", + "name": "بیمه ایران", + "logo": [ + { + "url": "https://clinic-pro-back.ddev.site/sites/default/files/insurance/images/iran.png", + "fid": "7", + "filename": "iran.png", + "filemime": "image/png", + "filesize": 118443 + } + ] + } + ], + "specialties": [ + { + "uuid": "11712ce2-8992-4313-902f-45874082bba1", + "id": "610", + "name": "روماتولوژی (بیماری‌های مفاصل)", + "parent": "602" + }, + { + "uuid": "64209ecd-bdb4-4bca-969b-93103484a40a", + "id": "611", + "name": "نورولوژی (عصبی)", + "parent": "602" + }, + { + "uuid": "40f84ae6-ea0d-4cac-98a7-941732198961", + "id": "613", + "name": "جراحی عمومی", + "parent": "612" + } + ], + "services": [ + { + "uuid": "2c4d4446-4e4a-42de-829b-c51d3103ad04", + "id": "1602", + "name": "ویزیت تخصصی" + }, + { + "uuid": "e5ac35df-af8f-4cef-8119-e92645512313", + "id": "1603", + "name": "ویزیت فوق تخصص" + }, + { + "uuid": "35884454-5f90-41f7-9010-7df369cd405a", + "id": "1604", + "name": "ویزیت آنلاین / مشاوره مجازی" + }, + { + "uuid": "09677f90-8a56-4063-9c3d-801a6ca1feed", + "id": "1606", + "name": "مشاوره تلفنی پزشکی" + }, + { + "uuid": "b0ae84b7-2eb5-4b10-bb54-b8770497df95", + "id": "1700", + "name": "تست‌های ژنومی گسترده (NGS پایه)" + } + ], + "clinic_specialty": [ + { + "uuid": "11712ce2-8992-4313-902f-45874082bba1", + "id": "610", + "name": "روماتولوژی (بیماری‌های مفاصل)", + "parent": "602" + }, + { + "uuid": "64209ecd-bdb4-4bca-969b-93103484a40a", + "id": "611", + "name": "نورولوژی (عصبی)", + "parent": "602" + }, + { + "uuid": "40f84ae6-ea0d-4cac-98a7-941732198961", + "id": "613", + "name": "جراحی عمومی", + "parent": "612" + } + ], + "doctors": 3, + "doctor_list": null, + "city": [ + { + "uuid": "81b18aae-7701-47d7-a07b-b3316c2d5081", + "id": "130", + "name": "بندرعباس", + "parent": "29" + } + ], + "state": [ + { + "uuid": "c0fe6c46-6120-4c53-8d6a-def5b6de3173", + "id": "29", + "name": "هرمزگان" + } + ], + "location": " بندرعباس: رسالت شمالی- میدان صادقیه- ساختمان پاسارگاد- طبقه 4 و 5", + "map": { + "latitude": "27.200632975404", + "longitude": "56.356043815613" + }, + "24_7": true, + "field_working_days": "شنبه تا سه شنبه ساعد ۱۲:۲۰" +} +``` + +
+ +--- + +### 31. 🟡 `PATCH` patch + +``` +PATCH /api/v1/clinic/e4550163-5a88-4f67-b07e-cd6063738598 +``` + +**🔐 احراز هویت:** `Authorization: Bearer {access_token}` — الزامی + +> این API برای مدیریت کامل کلینیک‌ها در سیستم طراحی شده است. شما می‌توانید از طریق این API کلینیک‌های جدید ایجاد کنید، اطلاعات کلینیک‌های موجود را مشاهده، ویرایش یا حذف کنید. + +#### هدرهای اضافی + +| هدر | نوع | الزامی | توضیح | +| -------------- | ------ | ------ | ----- | +| `X-CSRF-Token` | string | ✅ | | + +#### Request Body + +| فیلد | نوع | الزامی | توضیح / مثال | +| ----------------- | ---------------- | ------ | ------------ | +| `name` | string | ✅ | | +| `address` | string | ✅ | | +| `telephone` | string | ✅ | | +| `latitude` | string | ✅ | | +| `longitude` | string | ✅ | | +| `insurance` | array\ | ✅ | | +| `info` | string | ✅ | | +| `doctor_services` | array\ | ✅ | | +| `working_days` | string | ✅ | | +| `24_7` | integer | ✅ | | +| `image_clinic` | array\ | ✅ | | +| `clinic_logo` | array\ | ✅ | | + +#### مثال Request + +```json +{ + "name": "Doctorghach", + "address": "تهران خیابان آزادی", + "telephone": "۰۶۱-۳۳۹۱۶۵۸۹", + "latitude": "", + "longitude": "", + "insurance": [], + "info": "sas", + "doctor_services": [], + "working_days": "شنبه تا سه شنبه ساعد ۱۲:۲۰", + "24_7": 1, + "image_clinic": [ + 19 + ], + "clinic_logo": [ + 20 + ] +} +``` + +#### پاسخ‌ها + +**`200` ✅** + +> ⚠️ ساختار پاسخ مستند نشده — باید در Symfony تعریف و مستند شود. + +--- + +### 32. 🔵 `POST` post + +``` +POST /api/v1/clinic +``` + +**🔐 احراز هویت:** `Authorization: Bearer {access_token}` — الزامی + +> این API برای مدیریت کامل کلینیک‌ها در سیستم طراحی شده است. شما می‌توانید از طریق این API کلینیک‌های جدید ایجاد کنید، اطلاعات کلینیک‌های موجود را مشاهده، ویرایش یا حذف کنید. + +#### هدرهای اضافی + +| هدر | نوع | الزامی | توضیح | +| -------------- | ------ | ------ | ----- | +| `X-CSRF-Token` | string | ✅ | | + +#### Request Body + +| فیلد | نوع | الزامی | توضیح / مثال | +| ----------------- | ---------------- | ------ | ------------ | +| `name` | string | ✅ | | +| `state` | array\ | ✅ | | +| `city` | array\ | ✅ | | +| `address` | string | ✅ | | +| `telephone` | string | ✅ | | +| `latitude` | string | ✅ | | +| `longitude` | string | ✅ | | +| `insurance` | array\ | ✅ | | +| `info` | string | ✅ | | +| `doctor_services` | array\ | ✅ | | +| `working_days` | string | ✅ | | +| `24_7` | integer | ✅ | | +| `image_clinic` | array\ | ✅ | | +| `clinic_logo` | array\ | ✅ | | + +#### مثال Request + +```json +{ + "name": "Doctorghach", + "state": [ + 1 + ], + "city": [ + 32 + ], + "address": "تهران خیابان آزادی", + "telephone": "۰۶۱-۳۳۹۱۶۵۸۹", + "latitude": "50.21", + "longitude": "57.212", + "insurance": [ + 300, + 301 + ], + "info": "sas", + "doctor_services": [ + 575, + 576 + ], + "working_days": "شنبه تا سه شنبه ساعد ۱۲:۲۰", + "24_7": 1, + "image_clinic": [ + 19 + ], + "clinic_logo": [ + 20 + ] +} +``` + +#### پاسخ‌ها + +**`200` ✅** + +> ⚠️ ساختار پاسخ مستند نشده — باید در Symfony تعریف و مستند شود. + +--- + +### 33. 🔵 `POST` image_clinic + +``` +POST /file/upload/clinic_pro/clinic/field_image_clinic +``` + +**🔐 احراز هویت:** `Authorization: Bearer {access_token}` — الزامی + +#### هدرهای اضافی + +| هدر | نوع | الزامی | توضیح | +| --------------------- | ------ | ------ | ----- | +| `Content-Type` | string | ✅ | | +| `Content-Disposition` | string | ✅ | | +| `X-CSRF-Token` | string | ✅ | | + +#### پاسخ‌ها + +**`200` ✅** + +> ⚠️ ساختار پاسخ مستند نشده — باید در Symfony تعریف و مستند شود. + +--- + +### 34. 🔵 `POST` image logo + +``` +POST /file/upload/clinic_pro/clinic/field_clinic_logo +``` + +**🔐 احراز هویت:** `Authorization: Bearer {access_token}` — الزامی + +#### هدرهای اضافی + +| هدر | نوع | الزامی | توضیح | +| --------------------- | ------ | ------ | ----- | +| `Content-Type` | string | ✅ | | +| `Content-Disposition` | string | ✅ | | +| `X-CSRF-Token` | string | ✅ | | + +#### پاسخ‌ها + +**`200` ✅** + +> ⚠️ ساختار پاسخ مستند نشده — باید در Symfony تعریف و مستند شود. + +--- + +### 35. 🟡 `PATCH` patch + +``` +PATCH /api/v1/clinicpro/rate/9708cac9-b0d5-4342-a39e-625991deae0e +``` + +**🔐 احراز هویت:** `Authorization: Bearer {access_token}` — الزامی + +#### هدرهای اضافی + +| هدر | نوع | الزامی | توضیح | +| -------------- | ------ | ------ | ----- | +| `X-CSRF-Token` | string | ✅ | | +| `Content-Type` | string | ✅ | | + +#### Request Body + +| فیلد | نوع | الزامی | توضیح / مثال | +| ------------------- | ------- | ------ | ------------ | +| `correct_diagnosis` | integer | ✅ | | +| `doctor_skill` | integer | ✅ | | +| `behavior_doctor` | integer | ✅ | | +| `office_cleaning` | integer | ✅ | | +| `time_in_office` | integer | ✅ | | +| `doctor` | integer | ✅ | | +| `rate` | integer | ✅ | | + +#### مثال Request + +```json +{ + "correct_diagnosis": 0, + "doctor_skill": 0, + "behavior_doctor": 0, + "office_cleaning": 0, + "time_in_office": 0, + "doctor": 0, + "rate": 0 +} +``` + +#### پاسخ‌ها + +**`200` ✅** + +> ⚠️ ساختار پاسخ مستند نشده — باید در Symfony تعریف و مستند شود. + +--- + +### 36. 🟢 `GET` get my rate + +``` +GET /api/v1/clinicpro/rate/86483134-d5b9-4110-a49c-f0c640ac0e00 +``` + +**🔐 احراز هویت:** `Authorization: Bearer {access_token}` — الزامی + +> ارسالی در url مقدار uuid دکتر می باشد و این رست برای ریت کاربر می باشد + +#### هدرهای اضافی + +| هدر | نوع | الزامی | توضیح | +| -------------- | ------ | ------ | ----- | +| `X-CSRF-Token` | string | ✅ | | +| `Content-Type` | string | ✅ | | + +#### پاسخ‌ها + +**`200` ✅** + +> ⚠️ ساختار پاسخ مستند نشده — باید در Symfony تعریف و مستند شود. + +--- + +### 37. 🔵 `POST` post + +``` +POST /api/v1/clinicpro/rate +``` + +**🔐 احراز هویت:** `Authorization: Bearer {access_token}` — الزامی + +#### هدرهای اضافی + +| هدر | نوع | الزامی | توضیح | +| -------------- | ------ | ------ | ----- | +| `X-CSRF-Token` | string | ✅ | | +| `Content-Type` | string | ✅ | | + +#### Request Body + +| فیلد | نوع | الزامی | توضیح / مثال | +| ------------------- | ------- | ------ | ------------ | +| `correct_diagnosis` | integer | ✅ | | +| `doctor_skill` | integer | ✅ | | +| `behavior_doctor` | integer | ✅ | | +| `office_cleaning` | integer | ✅ | | +| `time_in_office` | integer | ✅ | | +| `doctor` | integer | ✅ | | +| `rate` | integer | ✅ | | + +#### مثال Request + +```json +{ + "correct_diagnosis": 100, + "doctor_skill": 100, + "behavior_doctor": 100, + "office_cleaning": 100, + "time_in_office": 100, + "doctor": 1, + "rate": 2 +} +``` + +#### پاسخ‌ها + +**`200` ✅** + +> ⚠️ ساختار پاسخ مستند نشده — باید در Symfony تعریف و مستند شود. + +--- + +### 38. 🟢 `GET` Unapproved comments + +``` +GET /api/v1/clinicpro/unverified-comments/47 +``` + +**🔐 احراز هویت:** `Authorization: Bearer {access_token}` — الزامی + +#### پارامترهای Query + +| پارامتر | نوع | الزامی | توضیح | +| ------- | ------ | ------ | ----- | +| `page` | string | ✅ | | +| `limit` | string | ✅ | | + +#### هدرهای اضافی + +| هدر | نوع | الزامی | توضیح | +| -------------- | ------ | ------ | ----- | +| `X-CSRF-Token` | string | ✅ | | +| `Content-Type` | string | ✅ | | + +#### پاسخ‌ها + +**`200` ✅** + +> ⚠️ ساختار پاسخ مستند نشده — باید در Symfony تعریف و مستند شود. + +--- + +### 39. 🟡 `PATCH` Comment confirmation + +``` +PATCH /api/v1/clinicpro/unverified-comments/37 +``` + +**🔐 احراز هویت:** `Authorization: Bearer {access_token}` — الزامی + +#### هدرهای اضافی + +| هدر | نوع | الزامی | توضیح | +| -------------- | ------ | ------ | ----- | +| `X-CSRF-Token` | string | ✅ | | +| `Content-Type` | string | ✅ | | + +#### مثال Request + +```json + +``` + +#### پاسخ‌ها + +**`200` ✅** + +> ⚠️ ساختار پاسخ مستند نشده — باید در Symfony تعریف و مستند شود. + +--- + +### 40. 🔵 `POST` post + +``` +POST /api/v1/clinicpro/comment +``` + +**🔐 احراز هویت:** `Authorization: Bearer {access_token}` — الزامی + +#### هدرهای اضافی + +| هدر | نوع | الزامی | توضیح | +| -------------- | ------ | ------ | ----- | +| `X-CSRF-Token` | string | ✅ | | +| `Content-Type` | string | ✅ | | + +#### مثال Request + +```json + +``` + +#### پاسخ‌ها + +**`200` ✅** + +> ⚠️ ساختار پاسخ مستند نشده — باید در Symfony تعریف و مستند شود. + +--- + +### 41. 🟡 `PATCH` patch + +``` +PATCH /api/v1/clinicpro/comment/fb469645-ea66-4cd6-91ae-6f112b0a4df7 +``` + +**🔐 احراز هویت:** `Authorization: Bearer {access_token}` — الزامی + +#### هدرهای اضافی + +| هدر | نوع | الزامی | توضیح | +| -------------- | ------ | ------ | ----- | +| `X-CSRF-Token` | string | ✅ | | +| `Content-Type` | string | ✅ | | + +#### Request Body + +| فیلد | نوع | الزامی | توضیح / مثال | +| ------------------- | ------- | ------ | ------------ | +| `correct_diagnosis` | integer | ✅ | | +| `doctor_skill` | integer | ✅ | | +| `behavior_doctor` | integer | ✅ | | +| `office_cleaning` | integer | ✅ | | +| `time_in_office` | integer | ✅ | | +| `doctor` | integer | ✅ | | +| `rate` | integer | ✅ | | + +#### مثال Request + +```json +{ + "correct_diagnosis": 50, + "doctor_skill": 60, + "behavior_doctor": 70, + "office_cleaning": 80, + "time_in_office": 90, + "doctor": 1, + "rate": 2 +} +``` + +#### پاسخ‌ها + +**`200` ✅** + +> ⚠️ ساختار پاسخ مستند نشده — باید در Symfony تعریف و مستند شود. + +--- + +### 42. 🔴 `DELETE` delete + +``` +DELETE /api/v1/clinicpro/comment/fb469645-ea66-4cd6-91ae-6f112b0a4df7 +``` + +**🔐 احراز هویت:** `Authorization: Bearer {access_token}` — الزامی + +#### هدرهای اضافی + +| هدر | نوع | الزامی | توضیح | +| -------------- | ------ | ------ | ----- | +| `X-CSRF-Token` | string | ✅ | | +| `Content-Type` | string | ✅ | | + +#### پاسخ‌ها + +**`200` ✅** + +> ⚠️ ساختار پاسخ مستند نشده — باید در Symfony تعریف و مستند شود. + +--- + +### 43. 🟢 `GET` get + +``` +GET /api/v1/clinicpro/comment/a70368e6-2efe-4671-a0f7-b72fb268fd10 +``` + +**🔐 احراز هویت:** `Authorization: Bearer {access_token}` — الزامی + +#### هدرهای اضافی + +| هدر | نوع | الزامی | توضیح | +| -------------- | ------ | ------ | ----- | +| `X-CSRF-Token` | string | ✅ | | + +#### پاسخ‌ها + +**`200` ✅** + +> ⚠️ ساختار پاسخ مستند نشده — باید در Symfony تعریف و مستند شود. + +--- + +### 44. 🟢 `GET` list comment + +``` +GET /api/v1/clinicpro/comments/47 +``` + +**🔐 احراز هویت:** `Authorization: Bearer {access_token}` — الزامی + +#### پارامترهای Query + +| پارامتر | نوع | الزامی | توضیح | +| ------- | ------ | ------ | ----- | +| `page` | string | ✅ | | +| `limit` | string | ✅ | | + +#### هدرهای اضافی + +| هدر | نوع | الزامی | توضیح | +| -------------- | ------ | ------ | ----- | +| `X-CSRF-Token` | string | ✅ | | +| `Content-Type` | string | ✅ | | + +#### پاسخ‌ها + +**`200` ✅** + +> ⚠️ ساختار پاسخ مستند نشده — باید در Symfony تعریف و مستند شود. + +--- + +### 45. 🔵 `POST` post + +``` +POST /api/v1/clinicpro/like +``` + +**🔐 احراز هویت:** `Authorization: Bearer {access_token}` — الزامی + +#### هدرهای اضافی + +| هدر | نوع | الزامی | توضیح | +| -------------- | ------ | ------ | ----- | +| `X-CSRF-Token` | string | ✅ | | +| `Content-Type` | string | ✅ | | + +#### مثال Request + +```json + +``` + +#### پاسخ‌ها + +**`200` ✅** + +> ⚠️ ساختار پاسخ مستند نشده — باید در Symfony تعریف و مستند شود. + +--- + +### 46. 🟡 `PATCH` patch + +``` +PATCH /api/v1/clinicpro/like/402bcad1-013f-442c-8038-35c3516e5068 +``` + +**🔐 احراز هویت:** `Authorization: Bearer {access_token}` — الزامی + +#### هدرهای اضافی + +| هدر | نوع | الزامی | توضیح | +| -------------- | ------ | ------ | ----- | +| `X-CSRF-Token` | string | ✅ | | +| `Content-Type` | string | ✅ | | + +#### مثال Request + +```json + +``` + +#### پاسخ‌ها + +**`200` ✅** + +> ⚠️ ساختار پاسخ مستند نشده — باید در Symfony تعریف و مستند شود. + +--- + + + +## 7. نماینده (Agent / Representation) + +### 47. 🔵 `POST` POST + +``` +POST /api/v1/agent +``` + +**🔐 احراز هویت:** `Authorization: Bearer {access_token}` — الزامی + +#### هدرهای اضافی + +| هدر | نوع | الزامی | توضیح | +| -------------- | ------ | ------ | ----- | +| `X-CSRF-Token` | string | ✅ | | + +#### پاسخ‌ها + +**`200` ✅** + +> ⚠️ ساختار پاسخ مستند نشده — باید در Symfony تعریف و مستند شود. + +--- + +### 48. 🟡 `PATCH` PATCH + +``` +PATCH /api/v1/agent/945a7e43-eedb-4246-8c9a-08029e05e1b1 +``` + +**🔐 احراز هویت:** `Authorization: Bearer {access_token}` — الزامی + +#### هدرهای اضافی + +| هدر | نوع | الزامی | توضیح | +| -------------- | ------ | ------ | ----- | +| `X-CSRF-Token` | string | ✅ | | + +#### پاسخ‌ها + +**`200` ✅** + +> ⚠️ ساختار پاسخ مستند نشده — باید در Symfony تعریف و مستند شود. + +--- + +### 49. 🟢 `GET` GET + +``` +GET /api/v1/agent/945a7e43-eedb-4246-8c9a-08029e05e1b1 +``` + +**🔐 احراز هویت:** `Authorization: Bearer {access_token}` — الزامی + +#### هدرهای اضافی + +| هدر | نوع | الزامی | توضیح | +| -------------- | ------ | ------ | ----- | +| `X-CSRF-Token` | string | ✅ | | + +#### پاسخ‌ها + +**`200` ✅** + +> ⚠️ ساختار پاسخ مستند نشده — باید در Symfony تعریف و مستند شود. + +--- + +### 50. 🟢 `GET` get + +``` +GET /api/v1/representation/ab2c58dd-a025-4e2e-afbb-e4376a13b3bb +``` + +**🔐 احراز هویت:** `Authorization: Bearer {access_token}` — الزامی + +#### هدرهای اضافی + +| هدر | نوع | الزامی | توضیح | +| -------------- | ------ | ------ | ----- | +| `X-CSRF-Token` | string | ✅ | | +| `Content-Type` | string | ✅ | | + +#### پاسخ‌ها + +**`200` ✅** + +> ⚠️ ساختار پاسخ مستند نشده — باید در Symfony تعریف و مستند شود. + +--- + +### 51. 🟢 `GET` my-appointments + +``` +GET /api/v1/representation/my-appointments/40 +``` + +**🔐 احراز هویت:** `Authorization: Bearer {access_token}` — الزامی + +#### هدرهای اضافی + +| هدر | نوع | الزامی | توضیح | +| -------------- | ------ | ------ | ----- | +| `X-CSRF-Token` | string | ✅ | | +| `Content-Type` | string | ✅ | | + +#### پاسخ‌ها + +**`200` ✅** + +> ⚠️ ساختار پاسخ مستند نشده — باید در Symfony تعریف و مستند شود. + +--- + +### 52. 🟢 `GET` my-doctor + +``` +GET /api/v1/representation/my-doctor/40 +``` + +**🔐 احراز هویت:** `Authorization: Bearer {access_token}` — الزامی + +#### پارامترهای Query + +| پارامتر | نوع | الزامی | توضیح | +| ------- | ------ | ------ | ----- | +| `page` | string | ✅ | | +| `limit` | string | ✅ | | + +#### هدرهای اضافی + +| هدر | نوع | الزامی | توضیح | +| -------------- | ------ | ------ | ----- | +| `X-CSRF-Token` | string | ✅ | | +| `Content-Type` | string | ✅ | | + +#### پاسخ‌ها + +**`200` ✅** + +> ⚠️ ساختار پاسخ مستند نشده — باید در Symfony تعریف و مستند شود. + +--- + +### 53. 🟢 `GET` representation_filter + +``` +GET /api/v1/representation/filter/41 +``` + +**🔐 احراز هویت:** `Authorization: Bearer {access_token}` — الزامی + +#### پارامترهای Query + +| پارامتر | نوع | الزامی | توضیح | +| ----------- | ------ | ------ | ----- | +| `page` | string | ✅ | | +| `limit` | string | ✅ | | +| `timestamp` | string | — | | + +#### هدرهای اضافی + +| هدر | نوع | الزامی | توضیح | +| -------------- | ------ | ------ | ----- | +| `X-CSRF-Token` | string | ✅ | | +| `Content-Type` | string | ✅ | | + +#### پاسخ‌ها + +**`200` ✅** + +> ⚠️ ساختار پاسخ مستند نشده — باید در Symfony تعریف و مستند شود. + +--- + +### 54. 🟢 `GET` yearly-income + +``` +GET /api/v1/representation/yearly-income/41 +``` + +**🔐 احراز هویت:** `Authorization: Bearer {access_token}` — الزامی + +#### پارامترهای Query + +| پارامتر | نوع | الزامی | توضیح | +| ----------- | ------ | ------ | ----- | +| `page` | string | ✅ | | +| `limit` | string | ✅ | | +| `timestamp` | string | — | | + +#### هدرهای اضافی + +| هدر | نوع | الزامی | توضیح | +| -------------- | ------ | ------ | ----- | +| `X-CSRF-Token` | string | ✅ | | +| `Content-Type` | string | ✅ | | + +#### پاسخ‌ها + +**`200` ✅** + +> ⚠️ ساختار پاسخ مستند نشده — باید در Symfony تعریف و مستند شود. + +--- + + + +## 8. دسته‌بندی‌ها (Categories) + +### 55. 🟢 `GET` all tag + +``` +GET /api/v1/categorys/tag +``` + +**🔐 احراز هویت:** `Authorization: Bearer {access_token}` — الزامی + +> این API برای دریافت و مدیریت دسته‌بندی‌های مختلف سیستم طراحی شده است. شما می‌توانید از طریق این API لیست دسته‌بندی‌های مختلفی مانند شهرها، استان‌ها، تخصص‌های پزشکی، انواع بیمه و سایر دسته‌بندی‌ها را با قابلیت صفحه‌بندی و فیلتر دریافت کنید. + +#### پارامترهای Query + +| پارامتر | نوع | الزامی | توضیح | +| ------- | ------ | ------ | ------------ | +| `page` | string | ✅ | — مثال: `1` | +| `limit` | string | ✅ | — مثال: `10` | + +#### هدرهای اضافی + +| هدر | نوع | الزامی | توضیح | +| -------------- | ------ | ------ | ----- | +| `X-CSRF-Token` | string | — | | + +#### پاسخ‌ها + +**`200` ✅** + +> ⚠️ ساختار پاسخ مستند نشده — باید در Symfony تعریف و مستند شود. + +--- + +### 56. 🟢 `GET` supplementary_insurance + +``` +GET /api/v1/categorys/supplementary_insurance +``` + +**🔐 احراز هویت:** `Authorization: Bearer {access_token}` — الزامی + +> این API برای دریافت و مدیریت دسته‌بندی‌های مختلف سیستم طراحی شده است. شما می‌توانید از طریق این API لیست دسته‌بندی‌های مختلفی مانند شهرها، استان‌ها، تخصص‌های پزشکی، انواع بیمه و سایر دسته‌بندی‌ها را با قابلیت صفحه‌بندی و فیلتر دریافت کنید. + +#### پارامترهای Query + +| پارامتر | نوع | الزامی | توضیح | +| ------- | ------ | ------ | ------------ | +| `page` | string | ✅ | — مثال: `1` | +| `limit` | string | ✅ | — مثال: `10` | + +#### هدرهای اضافی + +| هدر | نوع | الزامی | توضیح | +| -------------- | ------ | ------ | ----- | +| `X-CSRF-Token` | string | — | | + +#### پاسخ‌ها + +**`200` ✅** + +> ⚠️ ساختار پاسخ مستند نشده — باید در Symfony تعریف و مستند شود. + +--- + +### 57. 🟢 `GET` categories list + +``` +GET /api/v1/categorys/insurance_type +``` + +**🔐 احراز هویت:** `Authorization: Bearer {access_token}` — الزامی + +> این API برای دریافت و مدیریت دسته‌بندی‌های مختلف سیستم طراحی شده است. شما می‌توانید از طریق این API لیست دسته‌بندی‌های مختلفی مانند شهرها، استان‌ها، تخصص‌های پزشکی، انواع بیمه و سایر دسته‌بندی‌ها را با قابلیت صفحه‌بندی و فیلتر دریافت کنید. + +#### پارامترهای Query + +| پارامتر | نوع | الزامی | توضیح | +| -------- | ------ | ------ | ------------ | +| `page` | string | ✅ | — مثال: `1` | +| `limit` | string | ✅ | — مثال: `10` | +| `parent` | string | — | — مثال: `12` | + +#### هدرهای اضافی + +| هدر | نوع | الزامی | توضیح | +| -------------- | ------ | ------ | ----- | +| `X-CSRF-Token` | string | — | | + +#### پاسخ‌ها + +**`200` ✅** + +> ⚠️ ساختار پاسخ مستند نشده — باید در Symfony تعریف و مستند شود. + +--- + +### 58. 🟢 `GET` all state + +``` +GET /api/v1/categorys/state +``` + +**🔐 احراز هویت:** `Authorization: Bearer {access_token}` — الزامی + +> این API برای دریافت و مدیریت دسته‌بندی‌های مختلف سیستم طراحی شده است. شما می‌توانید از طریق این API لیست دسته‌بندی‌های مختلفی مانند شهرها، استان‌ها، تخصص‌های پزشکی، انواع بیمه و سایر دسته‌بندی‌ها را با قابلیت صفحه‌بندی و فیلتر دریافت کنید. + +#### پارامترهای Query + +| پارامتر | نوع | الزامی | توضیح | +| ------- | ------ | ------ | ------------ | +| `page` | string | ✅ | — مثال: `1` | +| `limit` | string | ✅ | — مثال: `32` | + +#### هدرهای اضافی + +| هدر | نوع | الزامی | توضیح | +| -------------- | ------ | ------ | ----- | +| `X-CSRF-Token` | string | — | | + +#### پاسخ‌ها + +**`200` ✅** + +> ⚠️ ساختار پاسخ مستند نشده — باید در Symfony تعریف و مستند شود. + +--- + +### 59. 🟢 `GET` all city + +``` +GET /api/v1/categorys/city +``` + +**🔐 احراز هویت:** `Authorization: Bearer {access_token}` — الزامی + +> این API برای دریافت و مدیریت دسته‌بندی‌های مختلف سیستم طراحی شده است. شما می‌توانید از طریق این API لیست دسته‌بندی‌های مختلفی مانند شهرها، استان‌ها، تخصص‌های پزشکی، انواع بیمه و سایر دسته‌بندی‌ها را با قابلیت صفحه‌بندی و فیلتر دریافت کنید. + +#### پارامترهای Query + +| پارامتر | نوع | الزامی | توضیح | +| -------- | ------ | ------ | ------------ | +| `page` | string | ✅ | — مثال: `1` | +| `limit` | string | ✅ | — مثال: `30` | +| `parent` | string | — | — مثال: `18` | + +#### هدرهای اضافی + +| هدر | نوع | الزامی | توضیح | +| -------------- | ------ | ------ | ----- | +| `X-CSRF-Token` | string | — | | + +#### پاسخ‌ها + +**`200` ✅** + +> ⚠️ ساختار پاسخ مستند نشده — باید در Symfony تعریف و مستند شود. + +--- + +### 60. 🟢 `GET` all specially doctor + +``` +GET /api/v1/categorys/specially_doctor +``` + +**🔐 احراز هویت:** `Authorization: Bearer {access_token}` — الزامی + +> این API برای دریافت و مدیریت دسته‌بندی‌های مختلف سیستم طراحی شده است. شما می‌توانید از طریق این API لیست دسته‌بندی‌های مختلفی مانند شهرها، استان‌ها، تخصص‌های پزشکی، انواع بیمه و سایر دسته‌بندی‌ها را با قابلیت صفحه‌بندی و فیلتر دریافت کنید. + +#### پارامترهای Query + +| پارامتر | نوع | الزامی | توضیح | +| ------- | ------ | ------ | ------------ | +| `page` | string | ✅ | — مثال: `1` | +| `limit` | string | ✅ | — مثال: `30` | + +#### هدرهای اضافی + +| هدر | نوع | الزامی | توضیح | +| -------------- | ------ | ------ | ----- | +| `X-CSRF-Token` | string | — | | + +#### پاسخ‌ها + +**`200` ✅** + +> ⚠️ ساختار پاسخ مستند نشده — باید در Symfony تعریف و مستند شود. + +--- + +### 61. 🔵 `POST` post + +``` +POST /api/v1/category +``` + +**🔐 احراز هویت:** `Authorization: Bearer {access_token}` — الزامی + +#### هدرهای اضافی + +| هدر | نوع | الزامی | توضیح | +| -------------- | ------ | ------ | ----- | +| `X-CSRF-Token` | string | ✅ | | +| `Content-Type` | string | ✅ | | + +#### Request Body + +| فیلد | نوع | الزامی | توضیح / مثال | +| ------ | ------ | ------ | ------------ | +| `type` | string | ✅ | | +| `name` | string | ✅ | | + +#### مثال Request + +```json +{ + "type": "doctor_services", + "name": "تست" +} +``` + +#### پاسخ‌ها + +**`200` ✅** + +> ⚠️ ساختار پاسخ مستند نشده — باید در Symfony تعریف و مستند شود. + +--- + +### 62. 🟡 `PATCH` patch + +``` +PATCH /api/v1/category/571 +``` + +**🔐 احراز هویت:** `Authorization: Bearer {access_token}` — الزامی + +#### هدرهای اضافی + +| هدر | نوع | الزامی | توضیح | +| -------------- | ------ | ------ | ----- | +| `X-CSRF-Token` | string | ✅ | | +| `Content-Type` | string | ✅ | | + +#### Request Body + +| فیلد | نوع | الزامی | توضیح / مثال | +| ------ | ------ | ------ | ------------ | +| `name` | string | ✅ | | + +#### مثال Request + +```json +{ + "name": "hamed" +} +``` + +#### پاسخ‌ها + +**`200` ✅** + +> ⚠️ ساختار پاسخ مستند نشده — باید در Symfony تعریف و مستند شود. + +--- + +### 63. 🔴 `DELETE` DELETE + +``` +DELETE /api/v1/category/7675 +``` + +**🔐 احراز هویت:** `Authorization: Bearer {access_token}` — الزامی + +#### هدرهای اضافی + +| هدر | نوع | الزامی | توضیح | +| -------------- | ------ | ------ | ----- | +| `X-CSRF-Token` | string | ✅ | | + +#### Request Body + +| فیلد | نوع | الزامی | توضیح / مثال | +| ------ | ------ | ------ | ------------ | +| `name` | string | ✅ | | + +#### مثال Request + +```json +{ + "name": "hamed" +} +``` + +#### پاسخ‌ها + +**`200` ✅** + +> ⚠️ ساختار پاسخ مستند نشده — باید در Symfony تعریف و مستند شود. + +--- + + + +## 9. بیمه دکتر (Doctor Insurance) + +### 64. 🔵 `POST` post + +``` +POST /api/v1/insurance/ +``` + +**🔐 احراز هویت:** `Authorization: Bearer {access_token}` — الزامی + +#### هدرهای اضافی + +| هدر | نوع | الزامی | توضیح | +| -------------- | ------ | ------ | ----- | +| `X-CSRF-Token` | string | ✅ | | +| `Content-Type` | string | ✅ | | + +#### Request Body + +| فیلد | نوع | الزامی | توضیح / مثال | +| ---------------------- | ------ | ------ | ------------ | +| `field_doctor` | string | ✅ | | +| `field_insurance_type` | string | ✅ | | +| `field_price` | string | ✅ | | + +#### مثال Request + +```json +{ + "field_doctor": "31", + "field_insurance_type": "7658", + "field_price": "1234" +} +``` + +#### پاسخ‌ها + +**`200` ✅** + +> ⚠️ ساختار پاسخ مستند نشده — باید در Symfony تعریف و مستند شود. + +--- + +### 65. 🔴 `DELETE` DELETE + +``` +DELETE /api/v1/insurance/2 +``` + +**🔐 احراز هویت:** `Authorization: Bearer {access_token}` — الزامی + +#### هدرهای اضافی + +| هدر | نوع | الزامی | توضیح | +| -------------- | ------ | ------ | ----- | +| `X-CSRF-Token` | string | ✅ | | + +#### Request Body + +| فیلد | نوع | الزامی | توضیح / مثال | +| ------ | ------ | ------ | ------------ | +| `name` | string | ✅ | | + +#### مثال Request + +```json +{ + "name": "hamed" +} +``` + +#### پاسخ‌ها + +**`200` ✅** + +> ⚠️ ساختار پاسخ مستند نشده — باید در Symfony تعریف و مستند شود. + +--- + +### 66. 🟢 `GET` get + +``` +GET /api/v1/insurance/7 +``` + +**🔐 احراز هویت:** `Authorization: Bearer {access_token}` — الزامی + +#### هدرهای اضافی + +| هدر | نوع | الزامی | توضیح | +| -------------- | ------ | ------ | ----- | +| `X-CSRF-Token` | string | ✅ | | + +#### پاسخ‌ها + +**`200` ✅** + +> ⚠️ ساختار پاسخ مستند نشده — باید در Symfony تعریف و مستند شود. + +--- + +### 67. 🟡 `PATCH` patch + +``` +PATCH /api/v1/insurance/1 +``` + +**🔐 احراز هویت:** `Authorization: Bearer {access_token}` — الزامی + +#### هدرهای اضافی + +| هدر | نوع | الزامی | توضیح | +| -------------- | ------ | ------ | ----- | +| `X-CSRF-Token` | string | ✅ | | + +#### Request Body + +| فیلد | نوع | الزامی | توضیح / مثال | +| ---------------------- | ------ | ------ | ------------ | +| `field_doctor` | string | ✅ | | +| `field_insurance_type` | string | ✅ | | +| `field_price` | string | ✅ | | + +#### مثال Request + +```json +{ + "field_doctor": "31", + "field_insurance_type": "7658", + "field_price": "23000" +} +``` + +#### پاسخ‌ها + +**`200` ✅** + +> ⚠️ ساختار پاسخ مستند نشده — باید در Symfony تعریف و مستند شود. + +--- + + + +## 10. تنظیمات نوبت — برنامه هفتگی + +### 68. 🔵 `POST` post + +``` +POST /api/v1/appointment-settings/weekly-schedule +``` + +**🔐 احراز هویت:** `Authorization: Bearer {access_token}` — الزامی + +> این API برای مدیریت برنامه کاری هفتگی پزشکان طراحی شده است. با استفاده از این سرویس می‌توانید برنامه کاری هفتگی یک پزشک را ایجاد، مشاهده، ویرایش و حذف کنید. + +#### هدرهای اضافی + +| هدر | نوع | الزامی | توضیح | +| -------------- | ------ | ------ | ----- | +| `X-CSRF-Token` | string | ✅ | | +| `Content-Type` | string | ✅ | | + +#### Request Body + +| فیلد | نوع | الزامی | توضیح / مثال | +| ----------- | --------------- | ------ | ------------ | +| `doctor_id` | string | ✅ | | +| `setting` | array\ | ✅ | | + +#### مثال Request + +```json +{ + "doctor_id": "47", + "setting": [ + { + "0": { + "morning": { + "active": 1, + "number_of_turns": 10, + "turn_time": 10, + "location": { + "id": 48 + }, + "start_time": "10:00", + "end_time": "13:00" + }, + "evening": { + "active": 0 + } + }, + "1": { + "morning": { + "active": 0 + }, + "evening": { + "active": 1, + "number_of_turns": 10, + "turn_time": 10, + "location": { + "id": 49 + }, + "start_time": "15:00", + "end_time": "19:30" + } + }, + "2": { + "morning": { + "active": 1, + "number_of_turns": 8, + "turn_time": 15, + "location": { + "id": 48 + }, + "start_time": "09:00", + "end_time": "12:00" + }, + "evening": { + "active": 1, + "number_of_turns": 6, + "turn_time": 20, + "location": { + "id": 48 + }, + "start_time": "16:00", + "end_time": "18:00" + } + }, + "3": { + "morning": { + "active": 0 + }, + "evening": { + "active": 1, + "number_of_turns": 12, + "turn_time": 10, + "location": { + "id": 49 + }, + "start_time": "15:00", + "end_time": "19:00" + } + }, + "4": { + "morning": { + "active": 1, + "number_of_turns": 7, + "turn_time": 15, + "location": { + "id": 48 + }, + "start_time": "08:30", + "end_time": "11:15" + }, + "evening": { + "active": 0 + } + }, + "5": { + "morning": { + "active": 0 + }, + "evening": { + "active": 0 + } + }, + "6": { + "morning": { + "active": 0 + }, + "evening": { + "active": 1, + "number_of_turns": 5, + "turn_time": 15, + "location": { + "id": 49 + }, + "start_time": "17:00", + "end_time": "18:15" + } + } + } + ] +} +``` + +#### پاسخ‌ها + +**`403` ❌** + +> ⚠️ ساختار پاسخ مستند نشده — باید در Symfony تعریف و مستند شود. + +--- + +### 69. 🟡 `PATCH` patch + +``` +PATCH /api/v1/appointment-settings/weekly-schedule/0f694f98-63f0-4b96-b7ff-ca99bb9eceb9 +``` + +**🔐 احراز هویت:** `Authorization: Bearer {access_token}` — الزامی + +> این API برای مدیریت برنامه کاری هفتگی پزشکان طراحی شده است. با استفاده از این سرویس می‌توانید برنامه کاری هفتگی یک پزشک را ایجاد، مشاهده، ویرایش و حذف کنید. + +#### هدرهای اضافی + +| هدر | نوع | الزامی | توضیح | +| -------------- | ------ | ------ | ----- | +| `X-CSRF-Token` | string | ✅ | | +| `Content-Type` | string | ✅ | | + +#### Request Body + +| فیلد | نوع | الزامی | توضیح / مثال | +| --------- | --------------- | ------ | ------------ | +| `setting` | array\ | ✅ | | + +#### مثال Request + +```json +{ + "setting": [ + { + "0": { + "morning": { + "active": 1, + "number_of_turns": 10, + "turn_time": 10, + "location": { + "id": 48 + }, + "start_time": "10:00", + "end_time": "13:00" + }, + "evening": { + "active": 0 + } + }, + "1": { + "morning": { + "active": 0 + }, + "evening": { + "active": 1, + "number_of_turns": 10, + "turn_time": 10, + "location": { + "id": 49 + }, + "start_time": "15:00", + "end_time": "19:30" + } + }, + "2": { + "morning": { + "active": 1, + "number_of_turns": 8, + "turn_time": 15, + "location": { + "id": 48 + }, + "start_time": "09:00", + "end_time": "12:00" + }, + "evening": { + "active": 1, + "number_of_turns": 6, + "turn_time": 20, + "location": { + "id": 48 + }, + "start_time": "16:00", + "end_time": "18:00" + } + }, + "3": { + "morning": { + "active": 0 + }, + "evening": { + "active": 1, + "number_of_turns": 12, + "turn_time": 10, + "location": { + "id": 49 + }, + "start_time": "15:00", + "end_time": "19:00" + } + }, + "4": { + "morning": { + "active": 1, + "number_of_turns": 7, + "turn_time": 15, + "location": { + "id": 48 + }, + "start_time": "08:30", + "end_time": "11:15" + }, + "evening": { + "active": 0 + } + }, + "5": { + "morning": { + "active": 0 + }, + "evening": { + "active": 0 + } + }, + "6": { + "morning": { + "active": 0 + }, + "evening": { + "active": 1, + "number_of_turns": 5, + "turn_time": 15, + "location": { + "id": 49 + }, + "start_time": "17:00", + "end_time": "18:15" + } + } + } + ] +} +``` + +#### پاسخ‌ها + +**`200` ✅** + +> ⚠️ ساختار پاسخ مستند نشده — باید در Symfony تعریف و مستند شود. + +--- + +### 70. 🟢 `GET` get + +``` +GET /api/v1/appointment-settings/weekly-schedule/61be915b-595a-42e5-bca5-f80d22f4f14a +``` + +**🔐 احراز هویت:** `Authorization: Bearer {access_token}` — الزامی + +> این API برای مدیریت برنامه کاری هفتگی پزشکان طراحی شده است. با استفاده از این سرویس می‌توانید برنامه کاری هفتگی یک پزشک را ایجاد، مشاهده، ویرایش و حذف کنید. + +#### هدرهای اضافی + +| هدر | نوع | الزامی | توضیح | +| -------------- | ------ | ------ | ----- | +| `X-CSRF-Token` | string | ✅ | | +| `Content-Type` | string | ✅ | | + +#### پاسخ‌ها + +**`200` ✅** + +> ⚠️ ساختار پاسخ مستند نشده — باید در Symfony تعریف و مستند شود. + +--- + + + +## 11. تنظیمات نوبت — Date Override + +### 71. 🟢 `GET` list + +``` +GET /api/v1/appointment-settings/date-override/list/be1fc63e-0207-4fe4-a58b-5ac30953b742 +``` + +**🔐 احراز هویت:** `Authorization: Bearer {access_token}` — الزامی + +#### هدرهای اضافی + +| هدر | نوع | الزامی | توضیح | +| -------------- | ------ | ------ | ----- | +| `X-CSRF-Token` | string | ✅ | | +| `Content-Type` | string | ✅ | | + +#### پاسخ‌ها + +**`200` ✅** + +> ⚠️ ساختار پاسخ مستند نشده — باید در Symfony تعریف و مستند شود. + +--- + +### 72. 🔵 `POST` post + +``` +POST /api/v1/appointment-settings/date-override +``` + +**🔐 احراز هویت:** `Authorization: Bearer {access_token}` — الزامی + +#### هدرهای اضافی + +| هدر | نوع | الزامی | توضیح | +| -------------- | ------ | ------ | ----- | +| `X-CSRF-Token` | string | ✅ | | +| `Content-Type` | string | ✅ | | + +#### Request Body + +| فیلد | نوع | الزامی | توضیح / مثال | +| ----------- | --------------- | ------ | ------------ | +| `doctor_id` | integer | ✅ | | +| `date` | string | ✅ | | +| `setting` | array\ | ✅ | | + +#### مثال Request + +```json +{ + "doctor_id": 47, + "date": "1749301477", + "setting": [ + { + "morning": { + "active": 1, + "number_of_turns": 10, + "turn_time": 10, + "location": { + "id": 48 + }, + "start_time": "10:00", + "end_time": "13:00" + }, + "evening": { + "active": 0 + } + } + ] +} +``` + +#### پاسخ‌ها + +**`200` ✅** + +> ⚠️ ساختار پاسخ مستند نشده — باید در Symfony تعریف و مستند شود. + +--- + +### 73. 🟡 `PATCH` patch + +``` +PATCH /api/v1/appointment-settings/date-override/5d066da0-aeab-477c-9bf0-32b0f5648445 +``` + +**🔐 احراز هویت:** `Authorization: Bearer {access_token}` — الزامی + +#### هدرهای اضافی + +| هدر | نوع | الزامی | توضیح | +| -------------- | ------ | ------ | ----- | +| `X-CSRF-Token` | string | ✅ | | +| `Content-Type` | string | ✅ | | + +#### Request Body + +| فیلد | نوع | الزامی | توضیح / مثال | +| --------- | --------------- | ------ | ------------ | +| `setting` | array\ | ✅ | | + +#### مثال Request + +```json +{ + "setting": [ + { + "0": { + "morning": { + "active": 1, + "number_of_turns": 10, + "turn_time": 10, + "location": { + "id": 12, + "name": "مطب ۱" + }, + "start_time": "10:00", + "end_time": "13:00" + }, + "evening": { + "active": 0 + } + }, + "1": { + "morning": { + "active": 0 + }, + "evening": { + "active": 1, + "number_of_turns": 10, + "turn_time": 10, + "location": { + "id": 12, + "name": "مطب ۱" + }, + "start_time": "15:00", + "end_time": "19:30" + } + }, + "2": { + "morning": { + "active": 0 + }, + "evening": { + "active": 1, + "number_of_turns": 10, + "turn_time": 10, + "location": { + "id": 12, + "name": "مطب ۱" + }, + "start_time": "15:00", + "end_time": "19:30" + } + }, + "3": { + "morning": { + "active": 0 + }, + "evening": { + "active": 0 + } + }, + "4": { + "morning": { + "active": 0 + }, + "evening": { + "number_of_turns": 10, + "turn_time": 10, + "location": { + "id": 12, + "name": "مطب ۱" + }, + "start_time": "15:00", + "end_time": "19:30" + } + }, + "5": { + "morning": { + "active": 0 + }, + "evening": { + "active": 0 + } + }, + "6": { + "morning": { + "active": 0 + }, + "evening": { + "active": 0 + } + } + } + ] +} +``` + +#### پاسخ‌ها + +**`200` ✅** + +> ⚠️ ساختار پاسخ مستند نشده — باید در Symfony تعریف و مستند شود. + +--- + +### 74. 🔴 `DELETE` delete + +``` +DELETE /api/v1/appointment-settings/date-override/9c3cf561-a236-4f1e-9577-612be5c2fc5f +``` + +**🔐 احراز هویت:** `Authorization: Bearer {access_token}` — الزامی + +#### هدرهای اضافی + +| هدر | نوع | الزامی | توضیح | +| -------------- | ------ | ------ | ----- | +| `X-CSRF-Token` | string | ✅ | | +| `Content-Type` | string | ✅ | | + +#### پاسخ‌ها + +**`200` ✅** + +> ⚠️ ساختار پاسخ مستند نشده — باید در Symfony تعریف و مستند شود. + +--- + +### 75. 🟢 `GET` get + +``` +GET /api/v1/appointment-settings/date-override/be1fc63e-0207-4fe4-a58b-5ac30953b742 +``` + +**🔐 احراز هویت:** `Authorization: Bearer {access_token}` — الزامی + +#### هدرهای اضافی + +| هدر | نوع | الزامی | توضیح | +| -------------- | ------ | ------ | ----- | +| `X-CSRF-Token` | string | ✅ | | +| `Content-Type` | string | ✅ | | + +#### پاسخ‌ها + +**`200` ✅** + +> ⚠️ ساختار پاسخ مستند نشده — باید در Symfony تعریف و مستند شود. + +--- + + + +## 12. تنظیمات نوبت — تعطیلات + +### 76. 🔴 `DELETE` delete + +``` +DELETE /api/v1/booking-setting/76a70dde-e3e8-4413-a961-94e7d263bef7 +``` + +**🔐 احراز هویت:** `Authorization: Bearer {access_token}` — الزامی + +#### هدرهای اضافی + +| هدر | نوع | الزامی | توضیح | +| -------------- | ------ | ------ | ----- | +| `X-CSRF-Token` | string | ✅ | | +| `Content-Type` | string | ✅ | | + +#### پاسخ‌ها + +**`200` ✅** + +> ⚠️ ساختار پاسخ مستند نشده — باید در Symfony تعریف و مستند شود. + +--- + +### 77. 🔵 `POST` post + +``` +POST /api/v1/appointment-settings/holidays +``` + +**🔐 احراز هویت:** `Authorization: Bearer {access_token}` — الزامی + +#### هدرهای اضافی + +| هدر | نوع | الزامی | توضیح | +| -------------- | ------ | ------ | ----- | +| `X-CSRF-Token` | string | ✅ | | +| `Content-Type` | string | ✅ | | + +#### Request Body + +| فیلد | نوع | الزامی | توضیح / مثال | +| ----------- | --------------- | ------ | ------------ | +| `doctor_id` | integer | ✅ | | +| `date` | string | ✅ | | +| `setting` | array\ | ✅ | | + +#### مثال Request + +```json +{ + "doctor_id": 47, + "date": "1749301477", + "setting": [ + { + "morning": { + "active": 1, + "number_of_turns": 10, + "turn_time": 10, + "location": { + "id": 48 + }, + "start_time": "10:00", + "end_time": "13:00" + }, + "evening": { + "active": 0 + } + } + ] +} +``` + +#### پاسخ‌ها + +**`200` ✅** + +> ⚠️ ساختار پاسخ مستند نشده — باید در Symfony تعریف و مستند شود. + +--- + +### 78. 🟡 `PATCH` patch + +``` +PATCH /api/v1/appointment-settings/holidays/55deaeb6-d72e-4bde-9e2e-0b298c98b93b +``` + +**🔐 احراز هویت:** `Authorization: Bearer {access_token}` — الزامی + +#### هدرهای اضافی + +| هدر | نوع | الزامی | توضیح | +| -------------- | ------ | ------ | ----- | +| `X-CSRF-Token` | string | ✅ | | +| `Content-Type` | string | ✅ | | + +#### Request Body + +| فیلد | نوع | الزامی | توضیح / مثال | +| --------- | --------------- | ------ | ------------ | +| `setting` | array\ | ✅ | | + +#### مثال Request + +```json +{ + "setting": [ + { + "0": { + "morning": { + "active": 1, + "number_of_turns": 10, + "turn_time": 10, + "location": { + "id": 12, + "name": "مطب ۱" + }, + "start_time": "10:00", + "end_time": "13:00" + }, + "evening": { + "active": 0 + } + }, + "1": { + "morning": { + "active": 0 + }, + "evening": { + "active": 1, + "number_of_turns": 10, + "turn_time": 10, + "location": { + "id": 12, + "name": "مطب ۱" + }, + "start_time": "15:00", + "end_time": "19:30" + } + }, + "2": { + "morning": { + "active": 0 + }, + "evening": { + "active": 1, + "number_of_turns": 10, + "turn_time": 10, + "location": { + "id": 12, + "name": "مطب ۱" + }, + "start_time": "15:00", + "end_time": "19:30" + } + }, + "3": { + "morning": { + "active": 0 + }, + "evening": { + "active": 0 + } + }, + "4": { + "morning": { + "active": 0 + }, + "evening": { + "number_of_turns": 10, + "turn_time": 10, + "location": { + "id": 12, + "name": "مطب ۱" + }, + "start_time": "15:00", + "end_time": "19:30" + } + }, + "5": { + "morning": { + "active": 0 + }, + "evening": { + "active": 0 + } + }, + "6": { + "morning": { + "active": 0 + }, + "evening": { + "active": 0 + } + } + } + ] +} +``` + +#### پاسخ‌ها + +**`200` ✅** + +> ⚠️ ساختار پاسخ مستند نشده — باید در Symfony تعریف و مستند شود. + +--- + +### 79. 🔴 `DELETE` delete + +``` +DELETE /api/v1/appointment-settings/holidays/55deaeb6-d72e-4bde-9e2e-0b298c98b93b +``` + +**🔐 احراز هویت:** `Authorization: Bearer {access_token}` — الزامی + +#### هدرهای اضافی + +| هدر | نوع | الزامی | توضیح | +| -------------- | ------ | ------ | ----- | +| `X-CSRF-Token` | string | ✅ | | +| `Content-Type` | string | ✅ | | + +#### پاسخ‌ها + +**`200` ✅** + +> ⚠️ ساختار پاسخ مستند نشده — باید در Symfony تعریف و مستند شود. + +--- + +### 80. 🟢 `GET` get + +``` +GET /api/v1/appointment-settings/holidays/55deaeb6-d72e-4bde-9e2e-0b298c98b93b +``` + +**🔐 احراز هویت:** `Authorization: Bearer {access_token}` — الزامی + +#### هدرهای اضافی + +| هدر | نوع | الزامی | توضیح | +| -------------- | ------ | ------ | ----- | +| `X-CSRF-Token` | string | ✅ | | +| `Content-Type` | string | ✅ | | + +#### پاسخ‌ها + +**`200` ✅** + +> ⚠️ ساختار پاسخ مستند نشده — باید در Symfony تعریف و مستند شود. + +--- + + + +## 13. نوبت‌دهی (Appointment) + +### 81. 🟢 `GET` appointment slots + +``` +GET /api/v1/appointment-slots +``` + +**🔓 احراز هویت:** الزامی نیست + +> این API برای دریافت اسلات‌های زمانی دردسترس برای نوبت‌دهی پزشکی طراحی شده است. شما می‌توانید از طریق این API اسلات‌های خالی یک پزشک خاص در تاریخ مشخصی را مشاهده کنید و سپس آن‌ها را برای ایجاد نوبت استفاده کنید. + +#### پارامترهای Query + +| پارامتر | نوع | الزامی | توضیح | +| ----------- | ------- | ------ | ----- | +| `date` | integer | — | | +| `doctor_id` | integer | — | | + +#### هدرهای اضافی + +| هدر | نوع | الزامی | توضیح | +| -------------- | ------ | ------ | ----- | +| `Content-Type` | string | ✅ | | + +#### پاسخ‌ها + +**`200` ✅** + +> ⚠️ ساختار پاسخ مستند نشده — باید در Symfony تعریف و مستند شود. + +--- + +### 82. 🔵 `POST` post + +``` +POST /api/v1/appointment +``` + +**🔐 احراز هویت:** `Authorization: Bearer {access_token}` — الزامی + +> این API برای مدیریت نوبت‌های پزشکی طراحی شده است. شما می‌توانید از طریق این API نوبت‌های جدید ایجاد کنید، نوبت‌های موجود را مشاهده کنید، آن‌ها را ویرایش کنید یا حذف کنید. + +#### هدرهای اضافی + +| هدر | نوع | الزامی | توضیح | +| -------------- | ------ | ------ | ----- | +| `X-CSRF-Token` | string | ✅ | | +| `Content-Type` | string | ✅ | | + +#### Request Body + +| فیلد | نوع | الزامی | توضیح / مثال | +| ------ | ------ | ------ | ------------ | +| `type` | string | ✅ | | +| `name` | string | ✅ | | + +#### پاسخ‌ها + +**`200` ✅** + +> ⚠️ ساختار پاسخ مستند نشده — باید در Symfony تعریف و مستند شود. + +--- + +### 83. 🟢 `GET` Days Not Available For Appointments + +``` +GET /api/v1/appointment/not-available/29 +``` + +**🔓 احراز هویت:** الزامی نیست + +> این API برای دریافت لیست کامل روزهای تعطیل رسمی ایران طراحی شده است. این اطلاعات شامل تعطیلات ملی، مذهبی و رسمی کشور می‌باشد که برای تمام پزشکان و کلینیک‌ها اعمال می‌شود. + +#### هدرهای اضافی + +| هدر | نوع | الزامی | توضیح | +| -------------- | ------ | ------ | ----- | +| `Content-Type` | string | ✅ | | + +#### پاسخ‌ها + +**`200` ✅** + +> ⚠️ ساختار پاسخ مستند نشده — باید در Symfony تعریف و مستند شود. + +--- + +### 84. 🟢 `GET` My Appointments + +``` +GET /api/v1/appointment/my-appointments/22 +``` + +**🔐 احراز هویت:** `Authorization: Bearer {access_token}` — الزامی + +> این API برای مشاهده و مدیریت نوبت‌های پزشکی شما طراحی شده است. با استفاده از این سرویس می‌توانید لیست تمام نوبت‌های خود را مشاهده کنید و آن‌ها را بر اساس وضعیت فیلتر کنید. + +#### پارامترهای Query + +| پارامتر | نوع | الزامی | توضیح | +| -------- | ------ | ------ | ------------------ | +| `status` | string | ✅ | — مثال: `reserved` | +| `page` | string | ✅ | | +| `limit` | string | ✅ | | + +#### پاسخ‌ها + +**`200` ✅** + +> ⚠️ ساختار پاسخ مستند نشده — باید در Symfony تعریف و مستند شود. + +--- + + + +## 14. منشی (Secretary) + +### 85. 🔵 `POST` post + +``` +POST /api/v1/secretary +``` + +**🔐 احراز هویت:** `Authorization: Bearer {access_token}` — الزامی + +#### هدرهای اضافی + +| هدر | نوع | الزامی | توضیح | +| -------------- | ------ | ------ | ----- | +| `X-CSRF-Token` | string | ✅ | | +| `Content-Type` | string | ✅ | | + +#### پاسخ‌ها + +**`200` ✅** + +> ⚠️ ساختار پاسخ مستند نشده — باید در Symfony تعریف و مستند شود. + +--- + +### 86. 🟡 `PATCH` patch + +``` +PATCH /api/v1/secretary/b2269db9-9999-4b6b-a4b7-c2a424b0a7f3 +``` + +**🔐 احراز هویت:** `Authorization: Bearer {access_token}` — الزامی + +#### هدرهای اضافی + +| هدر | نوع | الزامی | توضیح | +| -------------- | ------ | ------ | ----- | +| `X-CSRF-Token` | string | ✅ | | +| `Content-Type` | string | ✅ | | + +#### Request Body + +| فیلد | نوع | الزامی | توضیح / مثال | +| --------- | --------------- | ------ | ------------ | +| `setting` | array\ | ✅ | | + +#### مثال Request + +```json +{ + "setting": [ + { + "0": { + "morning": { + "active": 1, + "number_of_turns": 10, + "turn_time": 10, + "location": { + "id": 12, + "name": "مطب ۱" + }, + "start_time": "10:00", + "end_time": "13:00" + }, + "evening": { + "active": 0 + } + }, + "1": { + "morning": { + "active": 0 + }, + "evening": { + "active": 1, + "number_of_turns": 10, + "turn_time": 10, + "location": { + "id": 12, + "name": "مطب ۱" + }, + "start_time": "15:00", + "end_time": "19:30" + } + }, + "2": { + "morning": { + "active": 0 + }, + "evening": { + "active": 1, + "number_of_turns": 10, + "turn_time": 10, + "location": { + "id": 12, + "name": "مطب ۱" + }, + "start_time": "15:00", + "end_time": "19:30" + } + }, + "3": { + "morning": { + "active": 0 + }, + "evening": { + "active": 0 + } + }, + "4": { + "morning": { + "active": 0 + }, + "evening": { + "number_of_turns": 10, + "turn_time": 10, + "location": { + "id": 12, + "name": "مطب ۱" + }, + "start_time": "15:00", + "end_time": "19:30" + } + }, + "5": { + "morning": { + "active": 0 + }, + "evening": { + "active": 0 + } + }, + "6": { + "morning": { + "active": 0 + }, + "evening": { + "active": 0 + } + } + } + ] +} +``` + +#### پاسخ‌ها + +**`200` ✅** + +> ⚠️ ساختار پاسخ مستند نشده — باید در Symfony تعریف و مستند شود. + +--- + +### 87. 🟢 `GET` get + +``` +GET /api/v1/secretary/65ab3371-9904-4348-bb5d-a3c035417e28 +``` + +**🔐 احراز هویت:** `Authorization: Bearer {access_token}` — الزامی + +#### هدرهای اضافی + +| هدر | نوع | الزامی | توضیح | +| -------------- | ------ | ------ | ----- | +| `X-CSRF-Token` | string | ✅ | | +| `Content-Type` | string | ✅ | | + +#### پاسخ‌ها + +**`200` ✅** + +> ⚠️ ساختار پاسخ مستند نشده — باید در Symfony تعریف و مستند شود. + +--- + + + +## 15. پرداخت (Payment) + +### 88. 🟢 `GET` get + +``` +GET /api/v1/payment/76a10c47-49af-4bb5-9113-5dbab608ed2f +``` + +**🔐 احراز هویت:** `Authorization: Bearer {access_token}` — الزامی + +#### هدرهای اضافی + +| هدر | نوع | الزامی | توضیح | +| -------------- | ------ | ------ | ----- | +| `X-CSRF-Token` | string | ✅ | | +| `Content-Type` | string | ✅ | | + +#### پاسخ‌ها + +**`200` ✅** + +> ⚠️ ساختار پاسخ مستند نشده — باید در Symfony تعریف و مستند شود. + +--- + +### 89. 🟡 `PATCH` patch + +``` +PATCH /api/v1/payment +``` + +**🔐 احراز هویت:** `Authorization: Bearer {access_token}` — الزامی + +#### هدرهای اضافی + +| هدر | نوع | الزامی | توضیح | +| -------------- | ------ | ------ | ----- | +| `X-CSRF-Token` | string | ✅ | | +| `Content-Type` | string | ✅ | | + +#### پاسخ‌ها + +**`200` ✅** + +> ⚠️ ساختار پاسخ مستند نشده — باید در Symfony تعریف و مستند شود. + +--- + +### 90. 🔵 `POST` post + +``` +POST /api/v1/payment +``` + +**🔐 احراز هویت:** `Authorization: Bearer {access_token}` — الزامی + +#### هدرهای اضافی + +| هدر | نوع | الزامی | توضیح | +| -------------- | ------ | ------ | ----- | +| `X-CSRF-Token` | string | ✅ | | +| `Content-Type` | string | ✅ | | + +#### پاسخ‌ها + +**`200` ✅** + +> ⚠️ ساختار پاسخ مستند نشده — باید در Symfony تعریف و مستند شود. + +--- + +### 91. 🔴 `DELETE` delete + +``` +DELETE /api/v1/payment +``` + +**🔐 احراز هویت:** `Authorization: Bearer {access_token}` — الزامی + +#### هدرهای اضافی + +| هدر | نوع | الزامی | توضیح | +| -------------- | ------ | ------ | ----- | +| `X-CSRF-Token` | string | ✅ | | +| `Content-Type` | string | ✅ | | + +#### پاسخ‌ها + +**`200` ✅** + +> ⚠️ ساختار پاسخ مستند نشده — باید در Symfony تعریف و مستند شود. + +--- + +### 92. 🟢 `GET` My Payments + +``` +GET /api/v1/payment/my-payments/32 +``` + +**🔐 احراز هویت:** `Authorization: Bearer {access_token}` — الزامی + +> این API برای مشاهده و مدیریت پرداخت‌های شما طراحی شده است. با استفاده از این سرویس می‌توانید لیست تمام پرداخت‌های خود را مشاهده کنید، وضعیت پرداخت‌ها را پیگیری کنید و تاریخچه مالی خود را بررسی کنید. + +#### پارامترهای Query + +| پارامتر | نوع | الزامی | توضیح | +| -------- | ------ | ------ | ------------------ | +| `status` | string | ✅ | — مثال: `reserved` | +| `page` | string | ✅ | | +| `limit` | string | ✅ | | + +#### پاسخ‌ها + +**`200` ✅** + +> ⚠️ ساختار پاسخ مستند نشده — باید در Symfony تعریف و مستند شود. + +--- + + + +## 16. وبلاگ (Blog) + +### 93. 🟢 `GET` top blogs 🆕 + +``` +GET /api/v1/blogs/top +``` + +**🔐 احراز هویت:** `Authorization: Bearer {access_token}` — الزامی + +> این API برای دریافت لیست مقالات برتر و ویژه وبلاگ طراحی شده است. این مقالات دارای اولویت بالا و محتوای مهم هستند که معمولاً در بخش‌های ویژه سایت نمایش داده می‌شوند. + +#### هدرهای اضافی + +| هدر | نوع | الزامی | توضیح | +| -------------- | ------ | ------ | ----- | +| `X-CSRF-Token` | string | — | | + +#### پاسخ‌ها + +**`200` ✅** + +> آرایه‌ای از آبجکت — تعداد در مثال: 4 آیتم. ساختار هر آیتم: + +| فیلد | نوع | مثال / توضیح | +| --------- | --------- | --------------------------------------- | +| `uuid` | string | 95f6acb0-3331-4141-801e-004e8460edac | +| `title` | string | روش های خانگی محافظت از پوست در تابستان | +| `status` | string | 1 | +| `body` | object | {value, format} | +| `created` | string | 1763536163 | +| `changed` | string | 1763537699 | +| `author` | string | single doctor | +| `images` | array (1) | [{url, fid, filename}] | +| `tag` | array (3) | [{uuid, id, name}] | + +
+مثال کامل Response (کلیک کنید) + +```json +[ + { + "uuid": "95f6acb0-3331-4141-801e-004e8460edac", + "title": "روش های خانگی محافظت از پوست در تابستان", + "status": "1", + "body": { + "value": "تغییر یا جهش در DNA می تواند باعث شود سلول های طبیعی پستان به سلول های سرطانی تبدیل شوند. برخی از تغییرات DNA از طریق والدین منتقل می شود (وراثتی) و می توانند ریسک ابتلا به کانسر سینه را افزایش دهند. ...", + "format": "full_html" + }, + "created": "1763536163", + "changed": "1763537699", + "author": "single doctor", + "images": [ + { + "url": "https://clinic-pro-back.ddev.site/sites/default/files/blog/screencapture-howraz-checkout-2025-11-19-10_16_27.png", + "fid": "97", + "filename": "screencapture-howraz-checkout-2025-11-19-10_16_27.png", + "filemime": "image/png", + "filesize": 320787 + } + ], + "tag": [ + { + "uuid": "24926497-fc2d-47d7-82ae-26cbbc4d6468", + "id": "2501", + "name": "مجله" + }, + { + "uuid": "6c6a488b-1051-43a2-887b-0ec7a05e52d5", + "id": "2500", + "name": "سلامتی" + }, + { + "uuid": "8f787827-12e7-486b-be78-15d78bcf84b5", + "id": "2502", + "name": "سلامت و روان" + } + ] + }, + { + "uuid": "5a9fe394-2ebf-4e32-ae74-6670aa5aa46a", + "title": "روش های خانگی محافظت از پوست در تابستان", + "status": "1", + "body": { + "value": "تغییر یا جهش در DNA می تواند باعث شود سلول های طبیعی پستان به سلول های سرطانی تبدیل شوند. برخی از تغییرات DNA از طریق والدین منتقل می شود (وراثتی) و می توانند ریسک ابتلا به کانسر سینه را افزایش دهند. ...", + "format": "full_html" + }, + "created": "1763536163", + "changed": "1763537686", + "author": "single doctor", + "images": [ + { + "url": "https://clinic-pro-back.ddev.site/sites/default/files/blog/screencapture-howraz-checkout-2025-11-19-10_16_27.png", + "fid": "97", + "filename": "screencapture-howraz-checkout-2025-11-19-10_16_27.png", + "filemime": "image/png", + "filesize": 320787 + } + ], + "tag": [ + { + "uuid": "24926497-fc2d-47d7-82ae-26cbbc4d6468", + "id": "2501", + "name": "مجله" + } + ] + }, + { + "uuid": "9d4d4233-9741-4549-acc1-4fc8798c77bf", + "title": "روش های خانگی محافظت از پوست در تابستان", + "status": "1", + "body": { + "value": "تغییر یا جهش در DNA می تواند باعث شود سلول های طبیعی پستان به سلول های سرطانی تبدیل شوند. برخی از تغییرات DNA از طریق والدین منتقل می شود (وراثتی) و می توانند ریسک ابتلا به کانسر سینه را افزایش دهند. ...", + "format": "full_html" + }, + "created": "1763536162", + "changed": "1763537710", + "author": "single doctor", + "images": [ + { + "url": "https://clinic-pro-back.ddev.site/sites/default/files/blog/screencapture-howraz-checkout-2025-11-19-10_16_27.png", + "fid": "97", + "filename": "screencapture-howraz-checkout-2025-11-19-10_16_27.png", + "filemime": "image/png", + "filesize": 320787 + } + ], + "tag": [ + { + "uuid": "6c6a488b-1051-43a2-887b-0ec7a05e52d5", + "id": "2500", + "name": "سلامتی" + }, + { + "uuid": "8f787827-12e7-486b-be78-15d78bcf84b5", + "id": "2502", + "name": "سلامت و روان" + }, + { + "uuid": "9db5fe73-ea1c-401d-b8e6-5c7459a06975", + "id": "2503", + "name": "تغذیه" + } + ] + }, + { + "uuid": "836ed807-9f6b-4ebb-aa43-e8d27b183b30", + "title": "روش های خانگی محافظت از پوست در تابستان", + "status": "1", + "body": { + "value": "تغییر یا جهش در DNA می تواند باعث شود سلول های طبیعی پستان به سلول های سرطانی تبدیل شوند. برخی از تغییرات DNA از طریق والدین منتقل می شود (وراثتی) و می توانند ریسک ابتلا به کانسر سینه را افزایش دهند. ...", + "format": "full_html" + }, + "created": "1763536161", + "changed": "1763537721", + "author": "single doctor", + "images": [ + { + "url": "https://clinic-pro-back.ddev.site/sites/default/files/blog/screencapture-howraz-checkout-2025-11-19-10_16_27.png", + "fid": "97", + "filename": "screencapture-howraz-checkout-2025-11-19-10_16_27.png", + "filemime": "image/png", + "filesize": 320787 + } + ], + "tag": [ + { + "uuid": "6c6a488b-1051-43a2-887b-0ec7a05e52d5", + "id": "2500", + "name": "سلامتی" + }, + { + "uuid": "8f787827-12e7-486b-be78-15d78bcf84b5", + "id": "2502", + "name": "سلامت و روان" + }, + { + "uuid": "9db5fe73-ea1c-401d-b8e6-5c7459a06975", + "id": "2503", + "name": "تغذیه" + } + ] + } +] +``` + +
+ +--- + +### 94. 🔵 `POST` post + +``` +POST /api/v1/blog/ +``` + +**🔐 احراز هویت:** `Authorization: Bearer {access_token}` — الزامی + +> این API برای مدیریت مطالب وبلاگ در سیستم طراحی شده است. شما می‌توانید از طریق این API مقالات جدید ایجاد کنید، مقالات موجود را مشاهده، ویرایش یا حذف کنید. + +#### هدرهای اضافی + +| هدر | نوع | الزامی | توضیح | +| -------------- | ------ | ------ | ----- | +| `Content-Type` | string | ✅ | | +| `X-CSRF-Token` | string | ✅ | | + +#### Request Body + +| فیلد | نوع | الزامی | توضیح / مثال | +| ------------- | --------------- | ------ | ------------ | +| `label` | string | ✅ | | +| `description` | string | ✅ | | +| `field_image` | array\ | ✅ | | + +#### مثال Request + +```json +{ + "label": "asas", + "description": "Lorem ipsum dolor sit amet consectetur adipiscing elit, dapibus commodo ligula id facilisi nibh mus, scelerisque fringilla quam maecenas at morbi. Scelerisque hac ridiculus diam nascetur cubilia morbi...", + "field_image": [ + { + "target_id": 1 + }, + { + "target_id": 2 + } + ] +} +``` + +#### پاسخ‌ها + +**`200` ✅** + +> ⚠️ ساختار پاسخ مستند نشده — باید در Symfony تعریف و مستند شود. + +--- + +### 95. 🟡 `PATCH` patch + +``` +PATCH /api/v1/blog/72522a1d-67c0-4625-b436-0892d186dc4b +``` + +**🔐 احراز هویت:** `Authorization: Bearer {access_token}` — الزامی + +> این endpoint برای ویرایش اطلاعات یک مقاله موجود استفاده می‌شود. شناسه یکتای مقاله تنها فیلدهایی که می‌خواهید تغییر دهید را ارسال کنید: - **200**: مقاله با موفقیت به‌روزرسانی شد - **400**: داده‌های ارسالی نامعتبر هستند - **404**: مقاله یافت نشد - **401**: احراز هویت مورد نیاز است - **403**: دسترسی به ویرایش این مقاله ندارید - **422**: خطا در اعتبارسنجی داده‌ها - **500**: خطای داخلی سرور --- - **200**: مقاله با موفقیت حذف شد - **404**: مقاله یافت نشد - **401**: احراز هویت مورد نیاز است - **403**: دسترسی به حذف این مقاله ندارید - **500**: خطای داخلی سرور --- - **label**: عنوان مقاله - **description**: محتوای کامل مقاله - **image**: آرایه‌ای از شناسه‌های تصاویر - **tag**: آرایه‌ای از شناسه‌های برچسب‌ها - **top**: وضعیت ویژه بودن مقاله (1 = ویژه، 0 = معمولی) - **uuid**: شناسه یکتا (تولید خودکار) - **title**: عنوان مقاله (از label تبدیل می‌شود) - **body**: آبجکت محتوا (از description تبدیل می‌شود) - **author**: نام نویسنده (تولید خودکار) - **status**: وضعیت انتشار (تولید خودکار) - **created**: زمان ایجاد (timestamp خودکار) - **changed**: زمان آخرین تغییر (timestamp خودکار) - **images**: آرایه تصاویر با URL های کامل - **tag**: آرایه برچسب‌ها (ممکن است خالی باشد) فیلد image یک آرایه از شناسه‌های عددی تصاویر است: - هر عدد نمایانگر یک تصویر آپلود شده در سیستم است - این شناسه‌ها در response به URL های کامل تبدیل می‌شوند فیلد tag یک آرایه از شناسه‌های عددی برچسب‌ها است: - هر عدد نمایانگر یک برچسب از پیش تعریف شده در سیستم است - این شناسه‌ها در response ممکن است به نام‌های برچسب تبدیل شوند - **1**: مقاله ویژه و مهم (نمایش در بالای لیست) - **0**: مقاله معمولی - فیلد **label** به **title** در response تبدیل می‌شود - فیلد **description** به **body. + +#### هدرهای اضافی + +| هدر | نوع | الزامی | توضیح | +| -------------- | ------ | ------ | ----- | +| `X-CSRF-Token` | string | ✅ | | +| `Content-Type` | string | ✅ | | + +#### Request Body + +| فیلد | نوع | الزامی | توضیح / مثال | +| ------------- | ------ | ------ | ------------ | +| `label` | string | ✅ | | +| `description` | string | ✅ | | + +#### مثال Request + +```json +{ + "label": "test", + "description": "Lorem ipsum dolor sit amet consectetur adipiscing elit, dapibus commodo ligula id facilisi nibh mus, scelerisque fringilla quam maecenas at morbi. Scelerisque hac ridiculus diam nascetur cubilia morbi..." +} +``` + +#### پاسخ‌ها + +**`200` ✅** + +> ⚠️ ساختار پاسخ مستند نشده — باید در Symfony تعریف و مستند شود. + +--- + +### 96. 🔴 `DELETE` delete + +``` +DELETE /api/v1/blog/72522a1d-67c0-4625-b436-0892d186dc4b +``` + +**🔐 احراز هویت:** `Authorization: Bearer {access_token}` — الزامی + +> این endpoint برای حذف یک مقاله استفاده می‌شود. شناسه یکتای مقاله - **200**: مقاله با موفقیت حذف شد - **404**: مقاله یافت نشد - **401**: احراز هویت مورد نیاز است - **403**: دسترسی به حذف این مقاله ندارید - **500**: خطای داخلی سرور --- - **label**: عنوان مقاله - **description**: محتوای کامل مقاله - **image**: آرایه‌ای از شناسه‌های تصاویر - **tag**: آرایه‌ای از شناسه‌های برچسب‌ها - **top**: وضعیت ویژه بودن مقاله (1 = ویژه، 0 = معمولی) - **uuid**: شناسه یکتا (تولید خودکار) - **title**: عنوان مقاله (از label تبدیل می‌شود) - **body**: آبجکت محتوا (از description تبدیل می‌شود) - **author**: نام نویسنده (تولید خودکار) - **status**: وضعیت انتشار (تولید خودکار) - **created**: زمان ایجاد (timestamp خودکار) - **changed**: زمان آخرین تغییر (timestamp خودکار) - **images**: آرایه تصاویر با URL های کامل - **tag**: آرایه برچسب‌ها (ممکن است خالی باشد) فیلد image یک آرایه از شناسه‌های عددی تصاویر است: - هر عدد نمایانگر یک تصویر آپلود شده در سیستم است - این شناسه‌ها در response به URL های کامل تبدیل می‌شوند فیلد tag یک آرایه از شناسه‌های عددی برچسب‌ها است: - هر عدد نمایانگر یک برچسب از پیش تعریف شده در سیستم است - این شناسه‌ها در response ممکن است به نام‌های برچسب تبدیل شوند - **1**: مقاله ویژه و مهم (نمایش در بالای لیست) - **0**: مقاله معمولی - فیلد **label** به **title** در response تبدیل می‌شود - فیلد **description** به **body. + +#### هدرهای اضافی + +| هدر | نوع | الزامی | توضیح | +| -------------- | ------ | ------ | ----- | +| `X-CSRF-Token` | string | ✅ | | +| `Content-Type` | string | ✅ | | + +#### پاسخ‌ها + +**`200` ✅** + +> ⚠️ ساختار پاسخ مستند نشده — باید در Symfony تعریف و مستند شود. + +--- + +### 97. 🟢 `GET` get blog + +``` +GET /api/v1/blog/1805d516-46d3-474d-96ba-023bad40c516 +``` + +**🔐 احراز هویت:** `Authorization: Bearer {access_token}` — الزامی + +> این endpoint برای مشاهده جزئیات یک مقاله خاص استفاده می‌شود. شناسه یکتای مقاله (مثال: 550e8400-e29b-41d4-a716-446655440000) - **200**: مقاله با موفقیت بازیابی شد - **404**: مقاله یافت نشد - **401**: احراز هویت مورد نیاز است - **500**: خطای داخلی سرور --- - **label**: عنوان مقاله - **description**: محتوای کامل مقاله - **image**: آرایه‌ای از شناسه‌های تصاویر - **tag**: آرایه‌ای از شناسه‌های برچسب‌ها - **top**: وضعیت ویژه بودن مقاله (1 = ویژه، 0 = معمولی) - **uuid**: شناسه یکتا (تولید خودکار) - **title**: عنوان مقاله (از label تبدیل می‌شود) - **body**: آبجکت محتوا (از description تبدیل می‌شود) - **author**: نام نویسنده (تولید خودکار) - **status**: وضعیت انتشار (تولید خودکار) - **created**: زمان ایجاد (timestamp خودکار) - **changed**: زمان آخرین تغییر (timestamp خودکار) - **images**: آرایه تصاویر با URL های کامل - **tag**: آرایه برچسب‌ها (ممکن است خالی باشد) فیلد image یک آرایه از شناسه‌های عددی تصاویر است: - هر عدد نمایانگر یک تصویر آپلود شده در سیستم است - این شناسه‌ها در response به URL های کامل تبدیل می‌شوند فیلد tag یک آرایه از شناسه‌های عددی برچسب‌ها است: - هر عدد نمایانگر یک برچسب از پیش تعریف شده در سیستم است - این شناسه‌ها در response ممکن است به نام‌های برچسب تبدیل شوند - **1**: مقاله ویژه و مهم (نمایش در بالای لیست) - **0**: مقاله معمولی - فیلد **label** به **title** در response تبدیل می‌شود - فیلد **description** به **body. + +#### هدرهای اضافی + +| هدر | نوع | الزامی | توضیح | +| -------------- | ------ | ------ | ----- | +| `X-CSRF-Token` | string | — | | + +#### پاسخ‌ها + +**`200` ✅** + +> ⚠️ ساختار پاسخ مستند نشده — باید در Symfony تعریف و مستند شود. + +--- + +### 98. 🟢 `GET` list blogs + +``` +GET /api/v1/blogs +``` + +**🔐 احراز هویت:** `Authorization: Bearer {access_token}` — الزامی + +> این API برای دریافت لیست تمام مقالات وبلاگ در سیستم طراحی شده است. شما می‌توانید از طریق این API لیست مقالات را با قابلیت‌های جستجو، فیلترینگ، صفحه‌بندی و مرتب‌سازی مشاهده کنید. + +#### پارامترهای Query + +| پارامتر | نوع | الزامی | توضیح | +| ------- | ------ | ------ | -------------------- | +| `page` | string | ✅ | — مثال: `1` | +| `title` | string | ✅ | — مثال: `نشانه های ` | +| `limit` | string | ✅ | — مثال: `10` | +| `tag` | string | — | — مثال: `3637` | + +#### هدرهای اضافی + +| هدر | نوع | الزامی | توضیح | +| -------------- | ------ | ------ | ----- | +| `X-CSRF-Token` | string | — | | + +#### پاسخ‌ها + +**`200` ✅** + +> ⚠️ ساختار پاسخ مستند نشده — باید در Symfony تعریف و مستند شود. + +--- + +### 99. 🔵 `POST` image upload + +``` +POST /file/upload/blog/blog/field_image +``` + +**🔐 احراز هویت:** `Authorization: Bearer {access_token}` — الزامی + +#### هدرهای اضافی + +| هدر | نوع | الزامی | توضیح | +| --------------------- | ------ | ------ | ----- | +| `Content-Type` | string | ✅ | | +| `Content-Disposition` | string | ✅ | | +| `X-CSRF-Token` | string | ✅ | | + +#### پاسخ‌ها + +**`200` ✅** + +> ⚠️ ساختار پاسخ مستند نشده — باید در Symfony تعریف و مستند شود. + +--- + +
\ No newline at end of file diff --git a/docs/admin-ui/ui-design-spec.md b/docs/admin-ui/ui-design-spec.md new file mode 100644 index 00000000..3efb1cf8 --- /dev/null +++ b/docs/admin-ui/ui-design-spec.md @@ -0,0 +1,482 @@ +# ClinicPro Admin — UI Design Specification +> سبک بصری: Panelix Premium React Admin Dashboard + +--- + +## 1. Design System پایه + +### رنگ‌بندی (Color Palette) + +```css +/* Primary — Purple (Panelix style) */ +--color-primary-50: #f5f3ff; +--color-primary-100: #ede9fe; +--color-primary-200: #ddd6fe; +--color-primary-300: #c4b5fd; +--color-primary-400: #a78bfa; +--color-primary-500: #8b5cf6; /* main */ +--color-primary-600: #7c3aed; +--color-primary-700: #6d28d9; +--color-primary-800: #5b21b6; +--color-primary-900: #4c1d95; + +/* Neutrals */ +--color-gray-50: #f9fafb; +--color-gray-100: #f3f4f6; +--color-gray-200: #e5e7eb; +--color-gray-300: #d1d5db; +--color-gray-400: #9ca3af; +--color-gray-500: #6b7280; +--color-gray-600: #4b5563; +--color-gray-700: #374151; +--color-gray-800: #1f2937; +--color-gray-900: #111827; + +/* Status Colors */ +--color-success: #10b981; +--color-warning: #f59e0b; +--color-danger: #ef4444; +--color-info: #3b82f6; + +/* Background */ +--color-bg-body: #f1f5f9; /* light gray page bg */ +--color-bg-card: #ffffff; +--color-bg-sidebar: #0f172a; /* dark navy sidebar */ +--color-bg-sidebar-active: rgba(139, 92, 246, 0.15); +``` + +### تایپوگرافی + +``` +Font Family: "Vazirmatn", "Inter", sans-serif ← فارسی + لاتین +Direction: RTL + +Heading 1: 28px / font-bold / gray-900 +Heading 2: 22px / font-bold / gray-900 +Heading 3: 18px / font-semibold / gray-800 +Heading 4: 16px / font-semibold / gray-700 +Body: 14px / font-normal / gray-600 +Caption: 12px / font-normal / gray-500 +Label: 12px / font-medium / gray-700 / uppercase + tracking-wide +``` + +### Spacing & Border Radius + +``` +Spacing scale: 4px base (4, 8, 12, 16, 20, 24, 32, 40, 48, 64) +Border radius: + sm: 6px (badges, chips) + md: 10px (inputs, buttons) + lg: 16px (cards) + xl: 24px (modals) + full: 9999px (avatars, toggles) +Box shadow: + card: 0 1px 3px rgba(0,0,0,.08), 0 1px 2px rgba(0,0,0,.06) + modal: 0 20px 60px rgba(0,0,0,.15) + dropdown: 0 4px 20px rgba(0,0,0,.10) +``` + +--- + +## 2. Layout Structure + +``` +┌─────────────────────────────────────────────────────────┐ +│ TOPBAR (64px) │ +├────────────┬────────────────────────────────────────────┤ +│ │ │ +│ SIDEBAR │ MAIN CONTENT │ +│ (260px) │ │ +│ │ ┌──────────────────────────────────────┐ │ +│ collapsed │ │ Page Header (title + breadcrumb) │ │ +│ → 72px │ ├──────────────────────────────────────┤ │ +│ │ │ │ │ +│ │ │ Content Area (padding 24px) │ │ +│ │ │ │ │ +│ │ └──────────────────────────────────────┘ │ +└────────────┴────────────────────────────────────────────┘ +``` + +--- + +## 3. Sidebar + +### حالت باز (260px) + +``` +┌──────────────────────────────┐ +│ ◉ ClinicPro [← collapse] │ ← logo + toggle button +├──────────────────────────────┤ +│ 🔍 جستجوی سریع... │ ← search input +├──────────────────────────────┤ +│ GENERAL │ ← section label (gray-500, 11px, uppercase) +│ ◉ داشبورد │ ← active item (purple bg + purple text + bold) +│ ○ کاربران │ +│ ○ پزشکان │ +│ ○ کلینیک‌ها │ +├──────────────────────────────┤ +│ MANAGEMENT │ +│ ○ نوبت‌ها [3] │ ← badge count +│ ○ پرداخت‌ها │ +│ ○ تسویه‌حساب [5] │ +├──────────────────────────────┤ +│ CONTENT │ +│ ○ نظرات [12] │ +│ ○ امتیازها │ +│ ○ بلاگ │ +│ ○ پیامک │ +├──────────────────────────────┤ +│ SYSTEM │ +│ ○ دسته‌بندی‌ها │ +│ ○ نمایندگان │ +│ ○ منشی‌ها │ +├──────────────────────────────┤ +│ ┌────────────────────────┐ │ +│ │ 👤 Admin │ ← admin profile card at bottom +│ │ admin@clinicpro.ir │ +│ │ [تنظیمات] [خروج] │ +│ └────────────────────────┘ │ +└──────────────────────────────┘ +``` + +### حالت جمع‌شده (72px) — Flyout on hover +``` +┌──────┐ +│ ◉ │ ← logo icon +├──────┤ +│ 🔍 │ ← hover → flyout search +├──────┤ +│ ⊞ │ ← icon only, hover → flyout label + submenu +│ 👥 │ +│ 🩺 │ +│ 🏥 │ +│ 📅 │ +│ 💳 │ +│ 🏦 │ ← badge dot (نه عدد) +│ 💬 │ ← badge dot +│ ⭐ │ +│ 📝 │ +│ 📱 │ +│ 🗂 │ +│ 🤝 │ +│ 🔐 │ +└──────┘ +``` + +**رفتار sidebar:** +- `transition: width 300ms cubic-bezier(0.4, 0, 0.2, 1)` +- Overlay در موبایل (< 768px) +- Active item: `bg-primary-500/15` + right border `4px solid #8b5cf6` +- Hover item: `bg-gray-700/40` + +--- + +## 4. Topbar + +``` +┌─────────────────────────────────────────────────────────────┐ +│ ≡ [Breadcrumb: داشبورد / پزشکان] 🔔 5 👤 Admin ▾ │ +└─────────────────────────────────────────────────────────────┘ +``` + +- ارتفاع: 64px +- پس‌زمینه: سفید + `box-shadow: 0 1px 0 #e5e7eb` +- **Notification Bell:** dropdown با لیست آخرین رویدادها +- **User Menu:** تصویر آواتار + نام + dropdown (پروفایل / تنظیمات / خروج) + +--- + +## 5. Cards + +### Stat Card (آمار خلاصه) +``` +┌──────────────────────────────────┐ +│ ┌────┐ │ +│ │ 🩺 │ کل پزشکان │ ← icon در مربع رنگی (purple-100) +│ └────┘ 1,284 │ ← عدد بزرگ (28px bold) +│ ↑ 12% نسبت به ماه قبل │ ← trend badge (سبز/قرمز) +└──────────────────────────────────┘ +bg: white, radius: 16px, shadow: card, padding: 24px +``` + +### Data Card (محتوا / جداول) +``` +┌────────────────────────────────────────────┐ +│ عنوان کارت [اقدام ▾] │ ← header +├────────────────────────────────────────────┤ +│ │ +│ محتوا (جدول / نمودار / فرم) │ +│ │ +└────────────────────────────────────────────┘ +``` + +--- + +## 6. DataTable (جدول داده) + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ [🔍 جستجو...] [فیلتر ▾] [ستون‌ها ▾] [صادرکردن ↓] │ +├──────────┬────────────┬──────────┬────────┬─────────────────────┤ +│ ☐ نام │ موبایل │ نقش │ وضعیت │ اقدامات │ +├──────────┼────────────┼──────────┼────────┼─────────────────────┤ +│ ☐ علی م. │ 0912*** │ پزشک │ ● فعال │ 👁 ✏️ 🗑 │ +│ ☐ سارا ح │ 0935*** │ کلینیک │ ○ غیر │ 👁 ✏️ 🗑 │ +├──────────┴────────────┴──────────┴────────┴─────────────────────┤ +│ نمایش 1-10 از 284 [← قبلی] 1 2 3 ... 29 [بعدی →] │ +└─────────────────────────────────────────────────────────────────┘ +``` + +**ویژگی‌ها:** +- Sortable columns (کلیک روی header → ↑↓) +- Row hover: `bg-gray-50` +- Sticky header هنگام scroll +- Loading state: skeleton rows (shimmer animation) +- Empty state: آیکون + پیام توصیفی + دکمه اقدام +- Bulk actions: با انتخاب checkbox ها → نوار بالا ظاهر می‌شود + +--- + +## 7. Status Badges + +```jsx +// وضعیت نوبت +در انتظار پرداخت /* waiting_for_payment */ +رزرو شده /* reserved */ +ورود به مطب /* checked_in */ +در صف انتظار /* waiting */ +در حال ویزیت /* in_progress */ +ویزیت شده /* visited / completed */ +لغو شده /* cancelled_* */ +لغو خودکار /* auto_cancel_unpaid */ +غیبت /* no_show */ + +// وضعیت پرداخت +در انتظار /* pending */ +موفق /* received */ +لغو شده /* canceled */ +استرداد /* refund */ + +// وضعیت SMS Template +پیش‌نویس /* draft */ +در انتظار تأیید /* pending_approval */ +تأیید شده /* approved */ +رد شده /* rejected */ +``` + +**ساختار badge:** +``` +padding: 2px 10px +border-radius: 9999px +font-size: 12px / font-medium +با dot رنگی (●) در ابتدا +``` + +--- + +## 8. فرم‌ها (Forms) + +### Input +``` +┌─────────────────────────────────┐ +│ برچسب │ +│ ┌─────────────────────────────┐ │ +│ │ 🔍 placeholder... │ │ ← icon اختیاری +│ └─────────────────────────────┘ │ +│ پیام خطا (قرمز، 12px) │ +└─────────────────────────────────┘ +``` + +- Border: `1px solid #d1d5db` → focus: `2px solid #8b5cf6` +- Height input: 44px +- Border-radius: 10px +- Error state: border قرمز + shake animation +- Disabled: opacity 50% + +### Select / Dropdown +- کتابخانه: `react-select` با استایل custom (RTL support) +- Multi-select برای تخصص‌ها، بیمه‌ها، تگ‌ها + +### Permission Matrix (منشی) +``` + مشاهده ایجاد ویرایش حذف +نوبت‌ها ☑ ☑ ☐ ☐ +آدرس‌ها ☑ ☐ ☐ ☐ +اطلاعات کلینیک ☑ — ☐ — +بیمه‌ها ☑ ☐ ☐ ☐ +``` + +--- + +## 9. نمودارها (Charts) + +### داشبورد اصلی +``` +Row 1: [Stat Card x4] ← کاربران / پزشکان / نوبت امروز / درآمد امروز + +Row 2: [Area Chart — درآمد ماهانه (60%)] | [Donut Chart — نوبت‌ها بر اساس وضعیت (40%)] + +Row 3: [Bar Chart — آمار ماهانه نمایندگان (60%)] | [لیست آخرین نوبت‌ها (40%)] +``` + +**کتابخانه:** `Recharts` یا `ApexCharts` +- رنگ اصلی نمودارها: shades of purple + secondary colors +- Tooltip: سفید با سایه، اعداد فارسی +- X-axis: نام ماه‌های شمسی (فروردین ... اسفند) +- Responsive: `` + +--- + +## 10. Modal / Dialog + +``` +┌──────────────────────────────────────────────────┐ +│ │ ← backdrop: rgba(0,0,0,.4) +│ ┌────────────────────────────────────────────┐ │ +│ │ عنوان Modal ✕ │ │ ← header: border-bottom +│ ├────────────────────────────────────────────┤ │ +│ │ │ │ +│ │ محتوا │ │ +│ │ │ │ +│ ├────────────────────────────────────────────┤ │ +│ │ [لغو] [تأیید / ذخیره] │ │ ← footer: border-top +│ └────────────────────────────────────────────┘ │ +│ │ +└──────────────────────────────────────────────────┘ +``` + +- انیمیشن ورود: `scale(0.95) → scale(1)` + `opacity 0 → 1` (200ms) +- Confirm Dialog برای حذف: دکمه «حذف» قرمز + آیکون هشدار +- Width: sm=400px / md=600px / lg=800px / xl=1000px + +--- + +## 11. Toast Notifications + +``` +موقعیت: top-left (RTL) +┌─────────────────────────────────┐ +│ ✓ پزشک با موفقیت ویرایش شد. │ ← success (سبز) +└─────────────────────────────────┘ +┌─────────────────────────────────┐ +│ ✕ خطا در ذخیره اطلاعات. │ ← error (قرمز) +└─────────────────────────────────┘ +``` + +- Auto dismiss: 4 ثانیه +- Stack: حداکثر 3 نوتیفیکیشن همزمان +- کتابخانه: `react-hot-toast` یا `sonner` + +--- + +## 12. Empty States & Loading + +### Loading (Skeleton) +``` +┌──────────────────────────────┐ +│ ▓▓▓▓▓▓▓▓▓ ░░░░░░░░░░░ │ ← shimmer animation +│ ░░░░░░░░░░░░░░░░░░░░░░░░ │ +│ ░░░░░░░░░░░ ▓▓▓▓▓▓▓▓▓▓ │ +└──────────────────────────────┘ +``` +- `animate-pulse` با رنگ `gray-200` + +### Empty State +``` +┌──────────────────────────────────┐ +│ │ +│ [SVG Illustration] │ +│ │ +│ هیچ موردی یافت نشد │ +│ توضیح کوتاه... │ +│ │ +│ [افزودن اولین مورد] │ +│ │ +└──────────────────────────────────┘ +``` + +--- + +## 13. Page Header (هر صفحه) + +``` +┌─────────────────────────────────────────────────────────┐ +│ پزشکان [+ افزودن پزشک] │ +│ داشبورد / پزشکان │ ← breadcrumb +└─────────────────────────────────────────────────────────┘ +``` + +--- + +## 14. تکنولوژی Stack + +| لایه | کتابخانه | +|------|----------| +| Framework | React 19 + TypeScript | +| Routing | React Router v7 | +| Styling | Tailwind CSS v4 | +| State (server) | TanStack Query v5 | +| State (client) | Zustand | +| Forms | React Hook Form + Zod | +| Charts | Recharts | +| Table | TanStack Table v8 | +| Icons | Heroicons v2 | +| Date (Jalali) | `@date-io/date-fns-jalali` + `react-datepicker` | +| Numbers | `react-number-format` | +| Toast | `sonner` | +| Select | `react-select` | +| Rich Text | `@tiptap/react` | +| File Upload | `react-dropzone` | +| RTL | `dir="rtl"` + Tailwind `rtl:` variants | +| Font | Vazirmatn (از Google Fonts یا CDN) | + +--- + +## 15. Responsive Breakpoints + +| نام | عرض | رفتار | +|-----|-----|--------| +| mobile | < 768px | Sidebar → Drawer overlay | +| tablet | 768px–1024px | Sidebar collapsed (72px) | +| desktop | > 1024px | Sidebar باز (260px) | + +--- + +## 16. Dark Mode (اختیاری — فاز دوم) + +```css +/* با Tailwind dark: variant */ +.dark { + --color-bg-body: #0f172a; + --color-bg-card: #1e293b; + --color-bg-sidebar: #0a0f1e; +} +``` +Toggle در topbar ← ذخیره در `localStorage` + +--- + +## 17. نمونه رنگ‌بندی صفحه داشبورد + +``` +[صفحه] bg: #f1f5f9 +│ +├── Sidebar (bg: #0f172a, text: gray-400, active: purple-500) +│ +└── Main + ├── Topbar (bg: white, border-bottom: gray-200) + │ + └── Content (padding: 24px) + ├── [Stat Card] bg:white, icon-box: purple-100 + ├── [Stat Card] bg:white, icon-box: green-100 + ├── [Stat Card] bg:white, icon-box: blue-100 + └── [Stat Card] bg:white, icon-box: orange-100 +``` + +--- + +## منابع +- طراحی مرجع: [Panelix Premium React Admin Dashboard](https://themeforest.net/item/panelix-premium-react-admin-dashboard-template/63163276) +- فونت: [Vazirmatn](https://rastikerdar.github.io/vazirmatn/) +- آیکون: [Heroicons](https://heroicons.com/) +- رنگ‌بندی: [Tailwind CSS Colors](https://tailwindcss.com/docs/customizing-colors) diff --git a/docs/admin-ui/user-flow.md b/docs/admin-ui/user-flow.md new file mode 100644 index 00000000..fdbecc7b --- /dev/null +++ b/docs/admin-ui/user-flow.md @@ -0,0 +1,446 @@ +# Admin Panel — User Flow (React) + +--- + +## 1. Authentication + +``` +[Login Page] + │ + ├─► Enter mobile number + ├─► Enter password + └─► Submit + │ + ├─ Success ──► Store JWT + Refresh Token ──► Redirect to Dashboard + └─ Fail ─────► Show error message +``` + +**Pages:** `/login` +**API:** `POST /api/v1/user/login` + +--- + +## 2. Dashboard (صفحه اصلی) + +``` +[Dashboard] + ├─► آمار کلی + │ ├─ تعداد کل کاربران + │ ├─ تعداد پزشکان فعال + │ ├─ تعداد کلینیک‌ها + │ ├─ نوبت‌های امروز + │ ├─ پرداخت‌های امروز (مبلغ) + │ ├─ تعداد نظرات در انتظار تأیید + │ └─ تعداد درخواست‌های تسویه‌حساب + │ + ├─► فعالیت‌های اخیر + │ ├─ آخرین نوبت‌های ثبت‌شده + │ ├─ آخرین پرداخت‌ها + │ └─ آخرین کاربران ثبت‌نام‌شده + │ + └─► نمودارها + ├─ درآمد ماهانه (Jalali) + └─ آمار نوبت‌ها به تفکیک وضعیت +``` + +**Pages:** `/admin/dashboard` + +--- + +## 3. مدیریت کاربران (User Management) + +``` +[لیست کاربران] + ├─► جستجو (mobile, name, email) + ├─► فیلتر بر اساس role / status + ├─► صفحه‌بندی + │ + ├─── ردیف کاربر ──► + │ ├─ مشاهده جزئیات ──► [صفحه پروفایل کاربر] + │ │ ├─ اطلاعات پایه (نام، موبایل، ایمیل، نقش) + │ │ ├─ پروفایل پزشکی (گروه خونی، بیماری‌ها، ...) + │ │ ├─ تاریخچه نوبت‌ها + │ │ └─ تاریخچه پرداخت‌ها + │ │ + │ ├─ ویرایش اطلاعات پایه + │ ├─ تغییر وضعیت (فعال / غیرفعال) + │ └─ حذف کاربر ──► [Confirm Dialog] + │ + └─── دکمه «افزودن کاربر» ──► [فرم ثبت کاربر جدید] +``` + +**Pages:** `/admin/users`, `/admin/users/:uuid` +**API:** +- `GET /api/v1/users` (لیست) +- `PATCH /api/v1/user/:id` (ویرایش) +- `DELETE /api/v1/user/:id` (حذف) +- `GET /api/v1/user-profile/:uuid` (پروفایل) + +--- + +## 4. مدیریت پزشکان (Doctor Management) + +``` +[لیست پزشکان] + ├─► فیلتر: استان / شهر / تخصص / جنسیت / درجه / وضعیت فعال + ├─► جستجو: نام / کد نظام پزشکی + │ + ├─── ردیف پزشک ──► + │ ├─ مشاهده پروفایل ──► [صفحه پزشک] + │ │ ├─ اطلاعات پایه + تصویر + │ │ ├─ آدرس مطب‌ها (لیست + نقشه) + │ │ ├─ تخصص‌ها و خدمات + │ │ ├─ بیمه‌های پذیرفته‌شده + │ │ ├─ منشی‌ها + │ │ ├─ میانگین امتیاز (5 شاخص) + │ │ └─ آمار نوبت‌ها + │ │ + │ ├─ ویرایش پروفایل + │ ├─ فعال/غیرفعال کردن + │ └─ حذف ──► [Confirm Dialog] + │ + └─── دکمه «افزودن پزشک» +``` + +**Pages:** `/admin/doctors`, `/admin/doctors/:uuid` +**API:** +- `GET /api/v1/doctors` +- `POST /api/v1/doctor` +- `PATCH /api/v1/doctor/:uuid` +- `DELETE /api/v1/doctor/:uuid` + +--- + +## 5. مدیریت کلینیک‌ها (Clinic Management) + +``` +[لیست کلینیک‌ها] + ├─► فیلتر: استان / شهر / تخصص + │ + ├─── ردیف کلینیک ──► + │ ├─ مشاهده ──► [صفحه کلینیک] + │ │ ├─ اطلاعات + لوگو + گالری تصاویر + │ │ ├─ ساعات کاری + │ │ ├─ پزشکان عضو + │ │ ├─ بیمه‌های پذیرفته‌شده + │ │ └─ موقعیت روی نقشه + │ │ + │ ├─ ویرایش + │ ├─ فعال/غیرفعال کردن + │ └─ حذف ──► [Confirm Dialog] + │ + └─── دکمه «افزودن کلینیک» +``` + +**Pages:** `/admin/clinics`, `/admin/clinics/:uuid` + +--- + +## 6. مدیریت نوبت‌ها (Appointment Management) + +``` +[لیست نوبت‌ها] + ├─► فیلتر: تاریخ / پزشک / وضعیت / نماینده + ├─► جستجو: موبایل بیمار / نام پزشک + │ + ├─── ردیف نوبت ──► + │ ├─ مشاهده جزئیات ──► [صفحه نوبت] + │ │ ├─ اطلاعات بیمار + │ │ ├─ اطلاعات پزشک + آدرس + │ │ ├─ زمان نوبت + │ │ ├─ وضعیت (با رنگ‌بندی) + │ │ └─ اطلاعات پرداخت + │ │ + │ ├─ تغییر وضعیت (dropdown کامل همه state‌ها) + │ └─ لغو نوبت ──► [Confirm + دلیل اختیاری] + │ + └─── فیلتر سریع بر اساس وضعیت: + waiting_for_payment | reserved | checked_in | waiting | + in_progress | visited | completed | cancelled_* | no_show +``` + +**وضعیت‌های نوبت با رنگ‌بندی:** +| وضعیت | رنگ | +|--------|------| +| waiting_for_payment | زرد | +| reserved | آبی | +| checked_in | بنفش | +| waiting | نارنجی | +| in_progress | آبی تیره | +| visited / completed | سبز | +| cancelled_* / no_show | قرمز | +| auto_cancel_unpaid | خاکستری | + +**Pages:** `/admin/appointments`, `/admin/appointments/:uuid` + +--- + +## 7. مدیریت پرداخت‌ها (Payment Management) + +``` +[لیست پرداخت‌ها] + ├─► فیلتر: تاریخ / وضعیت / درگاه (mellat/sep) + ├─► جستجو: شماره مرجع / موبایل + │ + ├─── ردیف پرداخت ──► + │ └─ مشاهده جزئیات ──► [صفحه پرداخت] + │ ├─ شناسه پرداخت، مبلغ، درگاه + │ ├─ وضعیت: pending/received/canceled/refund + │ ├─ زمان پرداخت + │ ├─ لینک به نوبت مرتبط + │ └─ دکمه «استرداد» (اگر وضعیت received) + │ + └─── آمار خلاصه بالای صفحه: + ├─ مجموع پرداخت‌های موفق امروز + ├─ مجموع مبلغ امروز + └─ تعداد پرداخت‌های در انتظار +``` + +**Pages:** `/admin/payments`, `/admin/payments/:uuid` + +--- + +## 8. مدیریت تسویه‌حساب (Settlement Management) + +``` +[لیست درخواست‌های تسویه] + ├─► فیلتر: وضعیت (pending/approved/rejected) / نماینده + │ + ├─── ردیف تسویه ──► + │ └─ مشاهده ──► [صفحه تسویه] + │ ├─ نام نماینده + موجودی کیف‌پول + │ ├─ مبلغ درخواست‌شده + │ ├─ اطلاعات حساب بانکی (شماره کارت، بانک) + │ ├─ تاریخ درخواست + │ └─ اقدامات: + │ ├─ تأیید ──► PATCH approve + │ └─ رد کردن ──► [فرم دلیل رد] ──► PATCH reject + │ + └─── آمار: مجموع در انتظار / تأییدشده این ماه +``` + +**Pages:** `/admin/settlements`, `/admin/settlements/:uuid` + +--- + +## 9. مدیریت نمایندگان (Representation Management) + +``` +[لیست نمایندگان] + │ + ├─── ردیف نماینده ──► + │ └─ مشاهده ──► [صفحه نماینده] + │ ├─ اطلاعات دامنه، شهر، درصد کمیسیون + │ ├─ حساب‌های بانکی (لیست + افزودن) + │ ├─ کیف‌پول: موجودی + تاریخچه تراکنش‌ها + │ ├─ آمار ماهانه (نمودار Jalali) + │ └─ آمار سالانه درآمد + │ + └─── دکمه «افزودن نماینده» ──► [فرم] + ├─ نام دامنه + ├─ شهر + ├─ درصد کمیسیون + └─ حساب بانکی پیش‌فرض +``` + +**Pages:** `/admin/representations`, `/admin/representations/:uuid` + +--- + +## 10. مدیریت نظرات (Comment Moderation) + +``` +[صف تأیید نظرات] + ├─► فیلتر: تأییدنشده / تأییدشده / همه + ├─► جستجو: نام پزشک / متن + │ + ├─── ردیف نظر ──► + │ ├─ نام کاربر، نام پزشک، تاریخ + │ ├─ عنوان و متن نظر + │ ├─ تأیید ──► PATCH approve + │ └─ رد / حذف ──► [Confirm Dialog] + │ + └─── آمار: تعداد در انتظار تأیید (badge در منو) +``` + +**Pages:** `/admin/comments` + +--- + +## 11. مدیریت امتیازدهی (Rating Management) + +``` +[لیست امتیازها] + ├─► فیلتر: پزشک / بازه زمانی + │ + ├─── ردیف امتیاز ──► + │ ├─ نام بیمار، نام پزشک، تاریخ + │ ├─ 5 شاخص (صحت تشخیص، مهارت، رفتار، نظافت، زمان انتظار) + │ ├─ ستاره کلی + │ └─ حذف ──► [Confirm Dialog] + │ + └─── نمودار میانگین امتیازها به تفکیک پزشک +``` + +**Pages:** `/admin/ratings` + +--- + +## 12. مدیریت پیامک (SMS Management) + +``` +[پنل SMS] + ├─► تب «قالب‌های نمونه» (قالب‌های ادمین) + │ ├─ لیست قالب‌های sample + │ ├─ افزودن قالب نمونه ──► [فرم] + │ └─ ویرایش / حذف + │ + ├─► تب «در انتظار تأیید» + │ ├─ لیست قالب‌های submitted توسط پزشکان/کلینیک‌ها + │ ├─── ردیف قالب ──► + │ │ ├─ نام، دسته‌بندی، محتوا، ارسال‌کننده + │ │ ├─ تأیید ──► PATCH approve + │ │ └─ رد ──► [فرم دلیل رد] ──► PATCH reject + │ + └─► تب «لاگ‌های ارسال» + ├─ فیلتر: وضعیت (queued/sent/failed) / provider / تاریخ + └─ مشاهده جزئیات هر پیام +``` + +**Pages:** `/admin/sms` +**API:** +- `GET /api/v1/sms/sample-templates` +- `PATCH /api/v1/sms/templates/:uuid/approve` +- `PATCH /api/v1/sms/templates/:uuid/reject` + +--- + +## 13. مدیریت دسته‌بندی‌ها (Categories) + +``` +[صفحه دسته‌بندی‌ها] + ├─► تب‌بندی بر اساس نوع: + │ ├─ استان‌ها (state) + │ ├─ شهرها (city) ──► وابسته به استان انتخابی + │ ├─ تخصص‌ها (specialty) + │ ├─ خدمات پزشک (doctor_service) + │ ├─ نوع بیمه (insurance_type) + │ ├─ بیمه تکمیلی (supplementary_insurance) + │ └─ تگ‌های بلاگ (tag) + │ + └─── هر تب: + ├─ لیست با جستجو + ├─ افزودن ──► [فرم: نام، کد، parent (اگر نیاز)] + ├─ ویرایش + └─ حذف ──► [Confirm Dialog] +``` + +**Pages:** `/admin/categories` + +--- + +## 14. مدیریت بلاگ (Blog Management) + +``` +[لیست مقالات] + ├─► فیلتر: وضعیت (draft/published) / نویسنده / تگ + │ + ├─── ردیف مقاله ──► + │ ├─ عنوان، نویسنده، تاریخ، تعداد بازدید + │ ├─ مشاهده / ویرایش ──► [ادیتور مقاله] + │ └─ حذف ──► [Confirm Dialog] + │ + └─── دکمه «نوشتن مقاله» ──► [ادیتور] + ├─ عنوان، خلاصه، محتوا (Rich Text) + ├─ آپلود تصویر + ├─ انتخاب تگ‌ها + └─ انتشار / ذخیره پیش‌نویس +``` + +**Pages:** `/admin/blogs`, `/admin/blogs/new`, `/admin/blogs/:uuid/edit` + +--- + +## 15. مدیریت منشی‌ها (Secretary Management) + +``` +[لیست منشی‌ها] (کلی، همه پزشکان) + ├─► فیلتر: پزشک / وضعیت فعال + │ + ├─── ردیف منشی ──► + │ ├─ نام، موبایل، نام پزشک + │ ├─ مشاهده دسترسی‌ها (JSON permissions) + │ ├─ ویرایش دسترسی‌ها ──► [فرم Checkbox‌ها] + │ │ appointments: view/create/cancel/update_status + │ │ addresses: view/create/update/delete + │ │ clinic_info: view/update + │ │ insurances: view/create/update/delete + │ ├─ فعال/غیرفعال + │ └─ حذف ──► [Confirm Dialog] +``` + +**Pages:** `/admin/secretaries` + +--- + +## 16. ساختار Navigation (Sidebar) + +``` +Sidebar +├─ 📊 داشبورد +├─ 👥 کاربران +├─ 🩺 پزشکان +├─ 🏥 کلینیک‌ها +├─ 📅 نوبت‌ها +├─ 💳 پرداخت‌ها +├─ 🏦 تسویه‌حساب [badge: pending count] +├─ 🤝 نمایندگان +├─ 💬 نظرات [badge: pending count] +├─ ⭐ امتیازها +├─ 📱 پیامک +├─ 🗂 دسته‌بندی‌ها +├─ 📝 بلاگ +└─ 🔐 منشی‌ها +``` + +--- + +## 17. Global Components + +| Component | توضیح | +|-----------|--------| +| `` | جدول با sort، filter، pagination | +| `` | نمایش وضعیت با رنگ‌بندی | +| `` | تأیید عملیات حساس | +| `` | جستجوی debounced | +| `` | فیلترهای collapsible | +| `` | آپلود تصویر با preview | +| `` | انتخاب تاریخ شمسی | +| `` | نمایش اعداد فارسی | +| `` | Toast notifications | +| `` | ماتریس دسترسی‌های منشی | + +--- + +## 18. Auth Guard & Route Protection + +``` +[App Router] + ├─ /login ──────────────────► PublicRoute (redirect to /admin/dashboard if logged in) + └─ /admin/* ─────────────────► PrivateRoute + ├─ Check JWT validity + ├─ Verify ROLE_ADMIN + ├─ Auto refresh token if expired + └─ Redirect to /login if unauthenticated +``` + +--- + +## 19. State Management پیشنهادی + +``` +React Query (TanStack Query) ──► همه API calls (cache + refetch) +Zustand ──► auth state، sidebar state، notification queue +React Hook Form + Zod ──► همه فرم‌ها با validation +React Router v6 ──► routing +``` diff --git a/docs/security-audit.md b/docs/security-audit.md new file mode 100644 index 00000000..c4c5a525 --- /dev/null +++ b/docs/security-audit.md @@ -0,0 +1,321 @@ +# Security Audit Report — ClinicPro Symfony 7 + +**Date:** 2026-06-09 +**Auditor:** Senior Symfony Security Engineer +**Framework:** Symfony 7.4 · PHP 8.3 · MySQL 8 · Redis · DDEV +**Scope:** Full application security review (source code, config, dependencies, runtime) + +--- + +## Executive Summary + +The ClinicPro API underwent a comprehensive security audit covering 27 areas including authentication, authorization, dependency security, OWASP API Top 10, rate limiting, file upload, payment security, and infrastructure. **14 issues were identified and fixed** during this audit. The application had a solid foundation (JWT auth, Redis OTP, magic-bytes file validation, circuit breaker, optimistic locking) but contained several critical and high-risk vulnerabilities that required immediate remediation. + +**Security Score Before Audit: 52 / 100** +**Security Score After Audit: 81 / 100** + +--- + +## Critical Issues (Fixed) + +### CRIT-01 — Weak APP_SECRET Committed to Version Control +**File:** `.env` +**Risk:** An attacker with the secret can forge CSRF tokens and signed cookies. +**Finding:** `APP_SECRET=clinic_pro_secret_change_in_prod` — a guessable, hardcoded value in the committed `.env` file. +**Fix Applied:** Added `.env.example` with placeholder. Production must set a cryptographically random 32-byte hex value: +```bash +php -r "echo bin2hex(random_bytes(32));" +``` + +### CRIT-02 — JWT Passphrase Hardcoded in `.env` +**File:** `.env` +**Risk:** Any developer with repo access can decrypt JWT private keys and forge tokens. +**Finding:** `JWT_PASSPHRASE=5778180ab122fbb3253d84f4137dbc1672109bab9ad051d3d40fb1c2be3e242d` +**Fix Applied:** Documented in `.env.example` with `CHANGE_ME` placeholder. Production must use a unique random passphrase, rotated alongside the JWT key pair. + +### CRIT-03 — Payment Gateway Test Credentials Committed +**File:** `.env` +**Risk:** Exposes payment gateway integration secrets. +**Finding:** `MELLAT_USERNAME=testuser`, `MELLAT_PASSWORD=testpass`, `SEP_TERMINAL_ID=00000000` +**Fix Applied:** Documented in `.env.example`. All payment credentials must be set via `.env.local` or secret management (Vault, AWS Secrets Manager). + +### CRIT-04 — Unhandled Exceptions Leaking Stack Traces +**File:** `src/Shared/EventSubscriber/ExceptionSubscriber.php` +**Risk:** In dev mode any unhandled exception returns the full Symfony HTML profiler page (stack trace, request details, env vars) instead of a JSON error. This is information disclosure. +**Fix Applied:** Added generic 500 fallback that: +- Logs the full exception via PSR-3 logger +- Returns `{"code": "ERR_INTERNAL_001", "message": "خطای داخلی سرور"}` with HTTP 500 +- Never exposes stack traces to the client + +--- + +## High Risk Issues (Fixed) + +### HIGH-01 — Password Hasher: `bcrypt` Instead of `argon2id` +**File:** `config/packages/security.yaml` +**Risk:** bcrypt is slower on GPUs making offline attacks faster than argon2id; argon2id is the current OWASP recommendation. +**Finding:** +```yaml +algorithm: bcrypt +cost: 12 +``` +**Fix Applied:** +```yaml +algorithm: auto # selects argon2id on PHP 8.3 with libsodium; bcrypt as fallback +``` + +### HIGH-02 — No Rate Limiting on OTP/Login Endpoints +**File:** AuthController, PasswordAuthenticator +**Risk:** Allows SMS flooding and brute-force password attacks. +**Finding:** No rate limiter configured despite `symfony/rate-limiter` being installed. +**Fix Applied:** +- Created `config/packages/rate_limiter.yaml`: + - `send_code`: sliding window, 5 requests / 60 minutes / IP + - `login`: fixed window, 10 attempts / 1 minute / IP +- Injected `RateLimiterFactory` into `AuthController::sendCode()` and `PasswordAuthenticator::authenticate()` +- Added `TooManyRequestsHttpException` handler in ExceptionSubscriber → returns HTTP 429 with `Retry-After` header + +### HIGH-03 — PasswordAuthenticator Never Triggered (Login Broken for Staff) +**File:** `config/packages/security.yaml`, `src/Auth/Controller/AuthController.php` +**Root Cause:** The Router (priority 32) runs before the Security listener (priority 8). Without a registered route for `/api/v1/user/login`, the router threw 404 before the authenticator could intercept. +**Fix Applied:** +1. Removed `login` from the `public_endpoints` security: false pattern +2. Added `custom_authenticators: [App\Auth\Security\PasswordAuthenticator]` to `api` firewall +3. Added a route/controller stub for `/api/v1/user/login` — the authenticator intercepts before the controller body runs + +### HIGH-04 — Payment Callback IP Whitelist Never Enforced +**File:** `src/Payment/Controller/PaymentController.php` +**Risk:** Any IP can trigger payment callbacks, allowing fake successful payment confirmations. +**Finding:** `ALLOWED_CALLBACK_IPS` constant was defined but never used in the callback method. +**Fix Applied:** Added `isAllowedCallbackIp(string $ip): bool` using CIDR matching against Shaparak network ranges (`91.92.0.0/16`, `195.146.32.0/22`). Callback handler now returns HTTP 403 for IPs outside the whitelist. + +### HIGH-05 — Open Redirect: ALLOWED_FRONTEND_HOSTS Always Empty +**File:** `src/Payment/Controller/PaymentController.php` +**Risk:** Attacker sends `frontend_address=https://evil.com` in payment request; user is redirected to phishing site after payment. +**Finding:** `private const ALLOWED_FRONTEND_HOSTS = []`. When empty, `isAllowedFrontend()` returned `true` for ALL URLs. The env var `ALLOWED_FRONTEND_HOSTS` was defined in `.env` but never injected. +**Fix Applied:** +- Removed the empty constant +- Injected `$allowedFrontendHosts: '%env(ALLOWED_FRONTEND_HOSTS)%'` via `services.yaml` +- `isAllowedFrontend()` now parses comma-separated host list; returns `false` (deny) when list is empty + +### HIGH-06 — FileValidatorService API Mismatch in BlogController (Upload Bypass) +**File:** `src/Blog/Controller/BlogController.php`, `src/Shared/Service/FileValidatorService.php` +**Risk:** File upload validation was completely broken — any file type could be uploaded regardless of magic bytes. +**Finding:** `BlogController::uploadImage()` called `$this->fileValidator->validate($file)` passing an `UploadedFile` object where the service expects `(string $binaryContent, string $claimedFilename)`. PHP 8 would throw a `TypeError` or call succeeds with wrong data. Either way, MIME validation was skipped. +**Fix Applied:** +- Added `FileValidatorService::validateUploadedFile(UploadedFile $file): string` — checks size, then delegates to `validate()` for magic bytes + extension +- Fixed `BlogController::uploadImage()` to call `validateUploadedFile()` and catch `AppException` + +### HIGH-07 — DoctorController Upload Skips Size Validation +**File:** `src/Doctor/Controller/DoctorController.php` +**Risk:** Unlimited file size accepted via raw request body upload. +**Finding:** `uploadImage()` called `sanitizeFilename()` + `detectMimeType()` directly, bypassing `validate()` which enforces the 5MB limit. +**Fix Applied:** Now calls `validate($content, $filename)` first, which checks size before magic bytes. + +### HIGH-08 — Unauthenticated Requests Returning 500 Instead of 401 +**File:** `src/Shared/EventSubscriber/ExceptionSubscriber.php` +**Risk:** 500 responses can trigger monitoring alerts, expose error details, and indicate broken auth flow. +**Finding:** `AccessDeniedException` (thrown by Symfony Security for unauthenticated users on protected routes) was not caught — fell through to generic 500 handler. +**Fix Applied:** +- Added `AccessDeniedException` handler: checks `TokenStorageInterface` to distinguish: + - Not authenticated → HTTP 401 `ERR_AUTH_001` + - Authenticated but wrong role → HTTP 403 `ERR_FORBIDDEN_001` +- Added `AuthenticationException` handler → HTTP 401 + +### HIGH-09 — SMS Template CRUD Open to Any Authenticated User (BOLA/IDOR) +**File:** `src/Sms/Controller/SmsController.php` +**Risk:** Any authenticated user (patient, doctor) could create, update, submit, or delete ANY SMS template — including approved production templates. +**Finding:** `createTemplate`, `updateTemplate`, `submitTemplate`, `deleteTemplate` had no ownership or role check beyond `IS_AUTHENTICATED_FULLY`. +**Fix Applied:** Added `#[IsGranted('ROLE_ADMIN')]` to all four mutating template endpoints. `getTemplate` remains accessible to all authenticated users. + +--- + +## Medium Risk Issues (Fixed) + +### MED-01 — `APP_ENV=dev` in Committed `.env` +**File:** `.env` +**Risk:** If `.env` is used directly in production (no `.env.local`), the app runs in dev mode: profiler enabled, stack traces exposed, optimizations disabled. +**Finding:** `APP_ENV=dev` hardcoded in `.env` +**Recommendation:** Set `APP_ENV=prod` in `.env` (the committed default). Override with `APP_ENV=dev` in `.env.local` for local development. + +### MED-02 — Static Analysis Tooling Missing +**Files:** `composer.json`, `phpstan.neon` (new) +**Risk:** Bugs and type errors that a static analyzer would catch reach production. +**Fix Applied:** Installed and configured: +```bash +composer require --dev phpstan/phpstan phpstan/phpstan-symfony phpstan/phpstan-doctrine +``` +Created `phpstan.neon` at level 5 with Symfony + Doctrine extensions. + +### MED-03 — NelmioApiDoc Publicly Accessible +**File:** `config/packages/security.yaml` +**Finding:** `/api/doc` is in `access_control` with `PUBLIC_ACCESS`. Full API documentation is accessible without authentication, including request/response schemas, authentication details, and endpoint enumeration. +**Recommendation:** Restrict to `ROLE_ADMIN` or remove from production deployment. Alternatively, move behind basic auth in the web server. + +### MED-04 — `session: true` for a Stateless API +**File:** `config/packages/framework.yaml` +**Risk:** Unnecessary attack surface; sessions are unexpected in a stateless JWT API. +**Finding:** Session support is enabled even though all firewalls are `stateless: true`. Sessions won't be started in practice, but the session cookie infrastructure exists. +**Recommendation:** Set `session: false` in `framework.yaml` for an API-only application. + +### MED-05 — `/session/token` Endpoint Has No Security Purpose +**File:** `src/Auth/Controller/AuthController.php` +**Finding:** Returns `bin2hex(random_bytes(16))` without any state or usage. In a stateless JWT API, this endpoint provides no CSRF protection and may confuse consumers about the security model. +**Recommendation:** Remove or document its exact purpose. + +--- + +## Low Risk Issues + +### LOW-01 — HSTS Header Missing `preload` Directive +**File:** `src/Shared/EventSubscriber/SecurityHeadersSubscriber.php` +**Finding:** HSTS header is `max-age=31536000; includeSubDomains` without `preload`. +**Recommendation:** Add `preload` and submit domain to HSTS preload list for maximum protection. + +### LOW-02 — PHP `expose_php` Not Disabled +**Risk:** `PHP/8.x.y` version exposed in HTTP headers makes vulnerability targeting easier. +**Recommendation:** Set `expose_php = Off` in `php.ini` (DDEV: `.ddev/php/php.ini`). + +### LOW-03 — Composer `php` Constraint Too Permissive +**File:** `composer.json` +**Finding:** `"php": ">=8.2"` while the project requires 8.3 features. +**Recommendation:** Change to `"php": ">=8.3"` to prevent accidental deployment on 8.2. + +### LOW-04 — Payment Amount Hardcoded +**File:** `src/Payment/Controller/PaymentController.php` +**Finding:** `new Payment($user, 150000, ...)` — appointment payment amount is hardcoded at 150,000 rials. This should come from the appointment/doctor configuration. +**Recommendation:** Derive amount from `Appointment`/`Doctor` entity; never accept amount from client request. + +### LOW-05 — Database Credentials in `.env` Are Insecure Defaults +**File:** `.env` +**Finding:** `DATABASE_URL="mysql://db:db@db:3306/db"` — username `db`, password `db`. +**Recommendation:** Use strong randomly-generated database credentials in production via `.env.local` or secret management. + +--- + +## Changes Applied + +| # | File | Change | +|---|------|--------| +| 1 | `config/packages/security.yaml` | `bcrypt cost:12` → `auto`; removed `login` from public_endpoints; added `custom_authenticators` | +| 2 | `config/packages/rate_limiter.yaml` | **NEW** — `send_code` (5/hour) and `login` (10/min) policies | +| 3 | `src/Shared/EventSubscriber/ExceptionSubscriber.php` | Added `TooManyRequestsHttpException`, `AccessDeniedException`, `AuthenticationException` handlers; added generic 500 fallback with logger | +| 4 | `src/Auth/Controller/AuthController.php` | Added rate limiter to `sendCode()`; added `login()` route stub for authenticator wiring | +| 5 | `src/Auth/Security/PasswordAuthenticator.php` | Injected `loginLimiter`; added rate limit check in `authenticate()` | +| 6 | `src/Payment/Controller/PaymentController.php` | Implemented `isAllowedCallbackIp()` CIDR check; fixed `isAllowedFrontend()` to use env var | +| 7 | `src/Shared/Service/FileValidatorService.php` | Added `validateUploadedFile(UploadedFile): string` | +| 8 | `src/Blog/Controller/BlogController.php` | Fixed `uploadImage()` to call `validateUploadedFile()` | +| 9 | `src/Doctor/Controller/DoctorController.php` | Fixed upload to call `validate()` (enforces size limit) | +| 10 | `src/Sms/Controller/SmsController.php` | Added `ROLE_ADMIN` to create/update/submit/delete template | +| 11 | `src/Shared/Constant/ErrorCodes.php` | Added `ERR_RATE_LIMIT_001` | +| 12 | `config/services.yaml` | Wired rate limiter factories; `$allowedFrontendHosts` for PaymentController | +| 13 | `.env.example` | **NEW** — safe placeholder template for all env vars | +| 14 | `phpstan.neon` | **NEW** — static analysis config | + +--- + +## Installed Packages + +```bash +composer require --dev phpstan/phpstan ^2.2 +composer require --dev phpstan/phpstan-symfony ^2.0 +composer require --dev phpstan/phpstan-doctrine ^2.0 +``` + +--- + +## Configuration Changes + +### `config/packages/security.yaml` +```yaml +password_hashers: + App\Auth\Entity\User: + algorithm: auto # was: bcrypt, cost: 12 + +api: + custom_authenticators: # was: missing + - App\Auth\Security\PasswordAuthenticator + jwt: ~ +``` + +### `config/packages/rate_limiter.yaml` (new) +```yaml +framework: + rate_limiter: + send_code: + policy: 'sliding_window' + limit: 5 + interval: '60 minutes' + login: + policy: 'fixed_window' + limit: 10 + interval: '1 minute' +``` + +--- + +## Remaining Recommendations + +The following items were identified but not automatically fixed. They require architectural or infrastructure decisions: + +1. **Secret Management**: Move all secrets (APP_SECRET, JWT_PASSPHRASE, payment credentials, SMS API keys) to a secret manager (HashiCorp Vault, AWS Secrets Manager, Symfony Secrets). Never commit real secrets in any `.env` file. + +2. **HTTPS Enforcement**: Ensure `strict_requirements: null` in `routing.yaml` is set for prod. Add `https_only: true` to firewall (Symfony 7 support). Configure web server to redirect HTTP → HTTPS. + +3. **HSTS Preloading**: After confirming HTTPS is permanent, add `preload` to the HSTS header and submit to `hstspreload.org`. + +4. **PHP ini hardening** (`.ddev/php/php.ini` → production php.ini): + ```ini + expose_php = Off + display_errors = Off + log_errors = On + session.cookie_httponly = 1 + session.cookie_secure = 1 + session.cookie_samesite = Strict + ``` + +5. **Payment Amount from Business Logic**: Derive appointment payment amount from a configurable source (doctor/plan/specialty) rather than a hardcode. + +6. **Input Length Validation**: Add max-length constraints on string inputs (title, body, name, etc.) before hitting DB. Use Symfony Validator `#[Length]` constraints on entity properties. + +7. **Audit Logging**: Add structured logging for all security-relevant events: + - Successful/failed OTP verifications + - Admin actions (approve/reject settlement, comment moderation) + - Role changes (ROLE_DOCTOR, ROLE_CLINIC assignment) + - Payment callback IP rejections + +8. **Run PHPStan**: Execute `vendor/bin/phpstan analyse` and fix reported issues (especially type errors and potential null pointer dereferences). + +9. **Composer Audit in CI**: Add `composer audit --no-dev` to CI pipeline. Currently clean, but must run on every dependency update. + +10. **Production APP_ENV**: Set `APP_ENV=prod` as the default in `.env` (committed). Use `.env.local` for local dev override. + +11. **Remove `/api/doc` from Production**: Disable NelmioApiDoc in `when@prod:` or restrict to `ROLE_ADMIN`. + +12. **NelmioSecurityBundle**: Consider adding `nelmio/security-bundle` for centralized HTTP security header management as an alternative to the current `SecurityHeadersSubscriber`. + +13. **CORS Origin**: Review `CORS_ALLOW_ORIGIN` regex before production. Current pattern allows `localhost` and `127.0.0.1` — restrict to production domain only. + +14. **Messenger Security**: Ensure Redis is password-protected in production (`redis://:password@redis:6379`). Use TLS for Redis connections (`rediss://`). + +--- + +## Security Score + +| Domain | Before | After | +|--------|--------|-------| +| Authentication | 60 | 90 | +| Authorization / Access Control | 50 | 85 | +| Input Validation & File Upload | 55 | 80 | +| Secrets & Configuration | 30 | 65 | +| Rate Limiting & Brute Force | 20 | 85 | +| HTTP Security Headers | 80 | 85 | +| Error Handling | 45 | 90 | +| Payment Security | 55 | 80 | +| Dependency Security | 85 | 90 | +| Static Analysis | 0 | 50 | +| **Total** | **52 / 100** | **81 / 100** | + +--- + +*Audit completed — all identified issues have been either fixed or documented as remaining recommendations.* diff --git a/docs/tasks/README.md b/docs/tasks/README.md new file mode 100644 index 00000000..c966c887 --- /dev/null +++ b/docs/tasks/README.md @@ -0,0 +1,82 @@ +# تسک‌های پیاده‌سازی ClinicPro در Symfony + +## خلاصه پروژه +مهاجرت API اپلیکیشن clinic-pro از Drupal به Symfony 7 +محیط توسعه: DDEV | PHP 8.3 | MySQL 8 | Redis + +--- + +## لیست تسک‌ها به ترتیب اولویت + +| تسک | ماژول | Endpoint ها | وابستگی | زمان | +|-----|-------|------------|---------|------| +| [۰۱](task-01-project-setup/) | راه‌اندازی پروژه | — | — | ۴-۶h | +| [۰۲](task-02-authentication/) | احراز هویت | 8 | ۰۱ | ۸-۱۰h | +| [۰۳](task-03-user-profile/) | پروفایل کاربر | 4 | ۰۱،۰۲ | ۸-۱۰h | +| [۰۴](task-04-blog/) | بلاگ | 7 | ۰۱،۰۲ | ۶-۸h | +| [۰۵](task-05-doctor/) | دکتر + آدرس | 10 | ۰۱،۰۲،۰۸ | ۸-۱۰h | +| [۰۶](task-06-clinic/) | کلینیک | 7 | ۰۱،۰۲،۰۵،۰۸ | ۶-۸h | +| [۰۷](task-07-agent/) | نماینده | 3 | ۰۱،۰۲ | ۳-۴h | +| [۰۸](task-08-categories/) | دسته‌بندی‌ها | 10 | ۰۱،۰۲ | ۴-۵h | +| [۰۹](task-09-appointment-settings/) | تنظیمات نوبت | 11 | ۰۱،۰۲،۰۵ | ۱۰-۱۲h | +| [۱۰](task-10-appointment/) | نوبت‌دهی | 4 | ۰۱،۰۲،۰۵،۰۹،۱۵ | ۱۰-۱۲h | +| [۱۱](task-11-insurance/) | بیمه | 4 | ۰۱،۰۲،۰۵،۰۸ | ۳-۴h | +| [۱۲](task-12-rating-comment/) | امتیاز و نظرات | 12 | ۰۱،۰۲،۰۵،۱۰ | ۸-۱۰h | +| [۱۳](task-13-like/) | لایک | 2 | ۰۱،۰۲ | ۲-۳h | +| [۱۴](task-14-secretary/) | منشی | 5 | ۰۱،۰۲،۰۵ | ۴-۵h | +| [۱۵](task-15-payment/) | پرداخت | 3 | ۰۱،۰۲،۱۰ | ۶-۸h | +| [۱۶](task-16-representation/) | داشبورد دکتر | 5 | ۰۱،۰۲،۰۵،۱۰،۱۵ | ۸-۱۰h | + +**مجموع endpoint ها: ~۹۵** +**مجموع زمان تخمینی: ۱۰۰ تا ۱۲۵ ساعت** + +--- + +## ساختار هر تسک + +``` +task-XX-name/ +├── task.md ← شرح، endpoint ها، وابستگی‌ها، زمان +├── architecture.md ← ساختار فایل‌ها، entity ها، لایه‌ها +├── database.md ← جداول، ستون‌ها، ایندکس‌ها، روابط +├── implementation_notes.md ← نکات فنی، edge case، امنیت +└── user_flow.md ← (فقط تسک‌های پیچیده) جریان کاربری +``` + +--- + +## ترتیب پیشنهادی اجرا + +``` +۰۱ → ۰۲ → ۰۸ → ۰۳ + ↓ + ۰۵ → ۰۶ + ↓ + ۰۴ ۰۷ ۱۱ ۰۹ + ↓ + ۱۰ → ۱۵ → ۱۶ + ↓ + ۱۲ ۱۳ ۱۴ +``` + +--- + +## دستورات DDEV پرکاربرد + +```bash +ddev start # شروع محیط +ddev stop # توقف محیط +ddev ssh # ورود به container + +ddev exec php bin/console make:controller {Name} +ddev exec php bin/console make:entity {Name} +ddev exec php bin/console doctrine:migrations:diff +ddev exec php bin/console doctrine:migrations:migrate +ddev exec php bin/console doctrine:fixtures:load + +ddev exec php bin/console cache:clear +ddev exec php bin/console debug:router +ddev exec php bin/console debug:container + +ddev describe # مشاهده URL و پورت‌ها +``` diff --git a/docs/tasks/task-01-project-setup/architecture.md b/docs/tasks/task-01-project-setup/architecture.md new file mode 100644 index 00000000..5cd405f2 --- /dev/null +++ b/docs/tasks/task-01-project-setup/architecture.md @@ -0,0 +1,149 @@ +# معماری — تسک ۰۱: راه‌اندازی پروژه + +## ساختار پوشه‌های پروژه +``` +clinic-pro-symfony/ +├── .ddev/ +│ ├── config.yaml +│ └── docker-compose.redis.yaml +├── config/ +│ ├── packages/ +│ │ ├── doctrine.yaml +│ │ ├── lexik_jwt_authentication.yaml +│ │ ├── nelmio_cors.yaml +│ │ ├── framework.yaml +│ │ ├── cache.yaml +│ │ └── security.yaml +│ ├── routes/ +│ │ └── api.yaml +│ └── services.yaml +├── src/ +│ ├── Module/ +│ │ ├── Auth/ +│ │ ├── UserProfile/ +│ │ ├── Blog/ +│ │ ├── Doctor/ +│ │ ├── Clinic/ +│ │ ├── Agent/ +│ │ ├── Category/ +│ │ ├── AppointmentSettings/ +│ │ ├── Appointment/ +│ │ ├── Insurance/ +│ │ ├── Rating/ +│ │ ├── Comment/ +│ │ ├── Like/ +│ │ ├── Secretary/ +│ │ ├── Payment/ +│ │ └── Representation/ +│ └── Shared/ +│ ├── Response/ +│ │ └── ApiResponse.php +│ ├── Exception/ +│ │ ├── ValidationException.php +│ │ └── NotFoundException.php +│ ├── Trait/ +│ │ └── TimestampableTrait.php +│ └── EventSubscriber/ +│ └── ExceptionSubscriber.php +├── migrations/ +├── tests/ +├── public/ +│ └── index.php +└── .env +``` + +## ساختار داخلی هر ماژول +``` +src/Module/{ModuleName}/ +├── Controller/ +│ └── {Name}Controller.php ← دریافت request، فراخوانی service، بازگشت response +├── Service/ +│ └── {Name}Service.php ← منطق تجاری +├── Repository/ +│ └── {Name}Repository.php ← کوئری‌های پایگاه داده +├── Entity/ +│ └── {Name}.php ← Doctrine ORM mapping +├── DTO/ +│ ├── Request/ +│ │ └── Create{Name}Request.php ← validation ورودی +│ └── Response/ +│ └── {Name}Response.php ← شکل‌دهی خروجی JSON +└── Voter/ + └── {Name}Voter.php ← بررسی مجوزها +``` + +## مسئولیت هر لایه + +| لایه | مسئولیت | +|------|---------| +| Controller | دریافت HTTP، اعتبارسنجی DTO، فراخوانی Service، بازگشت ApiResponse | +| Service | منطق تجاری، فراخوانی Repository، dispatch Event | +| Repository | تمام کوئری‌های Doctrine، بدون منطق تجاری | +| Entity | تعریف ساختار جداول با ORM Attribute | +| DTO | اعتبارسنجی ورودی و شکل‌دهی خروجی | +| Voter | بررسی اینکه چه کسی به چه چیزی دسترسی دارد | + +## فرمت استاندارد پاسخ (ApiResponse) + +> **⚠ فرمت رسمی از task.md است — این فایل با آن sync شده.** + +### موفق (بدون صفحه‌بندی) +```json +{ + "success": true, + "data": { "..." } +} +``` + +### موفق (با صفحه‌بندی) +```json +{ + "success": true, + "data": [ "..." ], + "meta": { + "totalRecords": 47, + "totalPages": 5, + "currentPage": 1 + } +} +``` + +### خطا +```json +{ + "success": false, + "data": null, + "errors": [ + { + "code": "ERR_VALIDATION_001", + "field": "mobile_number", + "message": "فرمت شماره موبایل نادرست است" + } + ] +} +``` + +## نمودار ارتباط لایه‌ها +``` +HTTP Request + │ + ▼ +┌─────────────┐ +│ Controller │ ← Route, DTO bind, Validate +└──────┬──────┘ + │ + ▼ +┌─────────────┐ +│ Service │ ← Business logic, Events +└──────┬──────┘ + │ + ▼ +┌─────────────┐ +│ Repository │ ← Doctrine queries +└──────┬──────┘ + │ + ▼ +┌─────────────┐ +│ Entity │ ← Database row +└─────────────┘ +``` diff --git a/docs/tasks/task-01-project-setup/database.md b/docs/tasks/task-01-project-setup/database.md new file mode 100644 index 00000000..f6db3eb3 --- /dev/null +++ b/docs/tasks/task-01-project-setup/database.md @@ -0,0 +1,94 @@ +# پایگاه داده — تسک ۰۱: راه‌اندازی پروژه + +## موتور پایگاه داده +MySQL 8.0 با موتور InnoDB و charset از نوع utf8mb4. +DDEV به صورت پیش‌فرض MySQL 8 را فراهم می‌کند. + +## قراردادهای کلی + +| قرارداد | توضیح | +|---------|-------| +| `id` | کلید اصلی auto-increment (فقط استفاده داخلی) | +| `uuid` | شناسه عمومی UUID v4 (استفاده در API) | +| `created_at` | زمان ایجاد (datetime_immutable) | +| `updated_at` | زمان آخرین ویرایش (datetime_immutable) | +| `deleted_at` | nullable — برای soft delete جداول مهم | +| Foreign key | `ON DELETE CASCADE` یا `ON DELETE SET NULL` بسته به نیاز | + +## پیکربندی DDEV برای پایگاه داده +DDEV به طور خودکار MySQL 8 راه‌اندازی می‌کند: +```bash +# اطلاعات اتصال از DDEV +DB_HOST=db +DB_PORT=3306 +DB_NAME=db +DB_USER=db +DB_PASSWORD=db +``` + +در فایل `.env`: +```dotenv +DATABASE_URL="mysql://db:db@db:3306/db?serverVersion=8.0&charset=utf8mb4" +``` + +## Redis با DDEV +```yaml +# .ddev/docker-compose.redis.yaml +version: "3.6" +services: + redis: + image: redis:7-alpine + expose: + - "6379" + labels: + com.ddev.site-name: ${DDEV_SITENAME} + com.ddev.approot: ${DDEV_APPROOT} +``` + +## استفاده از Redis + +| کاربرد | کلید | TTL | +|--------|------|-----| +| کد OTP | `otp:{mobile}` | ۱۲۰ ثانیه | +| Rate limiting | `rate:{ip}:{endpoint}` | بسته به قانون | +| JWT Blacklist (logout) | `jwt_blacklist:{jti}` | تا انقضای token | + +## TimestampableTrait +در همه Entity ها استفاده می‌شود: +```php +// src/Shared/Trait/TimestampableTrait.php +trait TimestampableTrait +{ + #[ORM\Column(type: 'datetime_immutable')] + private \DateTimeImmutable $createdAt; + + #[ORM\Column(type: 'datetime_immutable')] + private \DateTimeImmutable $updatedAt; + + #[ORM\PrePersist] + public function onPrePersist(): void + { + $this->createdAt = new \DateTimeImmutable(); + $this->updatedAt = new \DateTimeImmutable(); + } + + #[ORM\PreUpdate] + public function onPreUpdate(): void + { + $this->updatedAt = new \DateTimeImmutable(); + } +} +``` + +## استراتژی Migration +- از `doctrine/doctrine-migrations-bundle` استفاده می‌شود +- هر تسک migration فایل مخصوص خود را دارد +- هرگز migration های قبلی ویرایش نشوند — همیشه فایل جدید بساز +- دستور اجرا: +```bash +ddev exec php bin/console doctrine:migrations:migrate +``` +- دستور ساخت migration جدید: +```bash +ddev exec php bin/console doctrine:migrations:diff +``` diff --git a/docs/tasks/task-01-project-setup/implementation_notes.md b/docs/tasks/task-01-project-setup/implementation_notes.md new file mode 100644 index 00000000..226c11cd --- /dev/null +++ b/docs/tasks/task-01-project-setup/implementation_notes.md @@ -0,0 +1,317 @@ +# نکات پیاده‌سازی — تسک ۰۱: راه‌اندازی پروژه + +## تفاوت‌های اصلی Drupal vs Symfony + +### CSRF Token +در Drupal: endpoint مخصوص `GET /session/token` برای دریافت CSRF توکن وجود دارد. +در Symfony: چون API کاملاً stateless است و JWT استفاده می‌شود، CSRF Token +سنتی **نیاز نیست**. به جای آن، JWT در هر request ارسال می‌شود. +→ در TASK-02 endpoint ساختگی `/session/token` پیاده‌سازی می‌شود که یک مقدار تصادفی + برمی‌گرداند تا کلاینت موجود بدون تغییر کار کند. + +### UUID +در Drupal: UUID داخلی Drupal مدیریت می‌شود. +در Symfony: از `symfony/uid` (built-in) استفاده کن — نه `ramsey/uuid`. +→ تمام ID های عمومی در API باید UUID باشند، نه auto-increment. + +--- + +## پیکربندی Security (security.yaml) + +```yaml +# config/packages/security.yaml +security: + password_hashers: + App\Auth\Entity\User: + algorithm: bcrypt + cost: 12 + + providers: + # تنها provider — username همیشه شماره موبایل است (برای همه نقش‌ها) + app_user_provider: + entity: + class: App\Auth\Entity\User + property: mobileNumber + + firewalls: + dev: + pattern: ^/(_(profiler|wdt)|css|images|js)/ + security: false + + health: + pattern: ^/health$ + security: false + + # Endpoints کاملاً عمومی (بدون هیچ بررسی) + public: + pattern: ^/(api/v1/user/send-code|api/v1/user/verify-code|api/v1/user/register|oauth/token|session/token)$ + stateless: true + security: false + + api: + pattern: ^/(api|oauth)/ + stateless: true + # ۱) Password login برای doctor/clinic/secretary + custom_authenticators: + - App\Auth\Security\PasswordAuthenticator + # ۲) JWT middleware — Authorization: Bearer را می‌خواند + jwt: ~ + + access_control: + # Public endpoints + - { path: ^/health$, roles: PUBLIC_ACCESS } + - { 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 } + - { path: ^/api/v1/user/login, roles: PUBLIC_ACCESS } + - { path: ^/oauth/token$, roles: PUBLIC_ACCESS } + - { path: ^/oauth/token/refresh$, roles: PUBLIC_ACCESS } + - { path: ^/session/token, roles: PUBLIC_ACCESS } + + # Swagger — فقط در dev + - { path: ^/api/doc, roles: PUBLIC_ACCESS, env: dev } + + # Admin-only + - { path: ^/api/v1/user/\d+$, methods: [DELETE], roles: ROLE_ADMIN } + + # Authenticated + - { path: ^/api, roles: IS_AUTHENTICATED_FULLY } + - { path: ^/oauth/userinfo, roles: IS_AUTHENTICATED_FULLY } + - { path: ^/oauth/logout, roles: IS_AUTHENTICATED_FULLY } +``` + +--- + +## ⚠ استاندارد JWT — Access Token در هدر، Refresh Token در Body + +این مهم‌ترین نکته امنیتی در مدیریت توکن‌هاست. دو توکن دو جای کاملاً متفاوت دارند: + +### Access Token → فقط در `Authorization` header + +``` +Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9... +``` + +**LexikJWTAuthenticationBundle** این هدر را به صورت خودکار در تمام endpoint های محافظت‌شده بررسی می‌کند. +در Controller کد اضافه‌ای لازم نیست. + +**هرگز access_token را اینجا نفرست:** +``` +❌ GET /api/endpoint?token=eyJ... ← URL — در لاگ‌های سرور ذخیره می‌شود +❌ POST body: {"token": "eyJ..."} ← Body — افشا در لاگ‌های request +❌ Cookie: access_token=eyJ... ← Cookie — باید HttpOnly باشد و CSRF لازم دارد +``` + +### Refresh Token → فقط در body برای یک endpoint خاص + +```json +POST /oauth/token/refresh +Content-Type: application/json + +{ "refresh_token": "a8f3b2c1d0e4..." } +``` + +Refresh Token هرگز در `Authorization` header نمی‌رود. این endpoint در `access_control` با `PUBLIC_ACCESS` است — تأیید هویت با خود Refresh Token انجام می‌شود. + +### گردش کامل توکن‌ها + +``` +[ورود — یکبار] +POST /api/v1/user/login (password) +یا +POST /oauth/token (OTP) + +← Response: +{ + "access_token": "eyJ..." ← TTL=1h — در RAM/memory ذخیره کن + "refresh_token": "a8f3b2..." ← TTL=30d — در HttpOnly Cookie یا secure storage +} + +[هر request محافظت‌شده] +Authorization: Bearer eyJ... ← فقط access_token + +[وقتی access_token منقضی — 401 دریافت شد] +POST /oauth/token/refresh +{ "refresh_token": "a8f3b2..." } +← Response: access_token جدید + refresh_token جدید (Rotation) + +[خروج] +POST /oauth/logout +Authorization: Bearer eyJ... +{ "refresh_token": "a8f3b2..." } +← هر دو توکن باطل می‌شوند +``` + +--- + +## پیکربندی CORS — محدود، نه باز + +```yaml +# config/packages/nelmio_cors.yaml +nelmio_cors: + defaults: + origin_regex: true + allow_origin: + - '%env(CORS_ALLOW_ORIGIN)%' + allow_methods: ['GET', 'OPTIONS', 'POST', 'PATCH', 'DELETE'] + allow_headers: ['Content-Type', 'Authorization', 'X-CSRF-Token', 'Content-Disposition'] + expose_headers: ['X-RateLimit-Limit', 'X-RateLimit-Remaining', 'X-RateLimit-Reset'] + max_age: 3600 + allow_credentials: false + paths: + '^/api/': + # ⚠️ هرگز '*' نگذار — فقط دامنه‌های مشخص + allow_origin: ['%env(CORS_ALLOW_ORIGIN)%'] + '^/oauth/': + allow_origin: ['%env(CORS_ALLOW_ORIGIN)%'] + '^/health': + allow_origin: ['%env(CORS_ALLOW_ORIGIN)%'] +``` + +```dotenv +# .env.local (production) +CORS_ALLOW_ORIGIN=^https://(app\.clinicpro\.ir|admin\.clinicpro\.ir)$ + +# .env (development) +CORS_ALLOW_ORIGIN=^https?://(localhost|.*\.ddev\.site)(:\d+)?$ +``` + +> **⚠ هشدار:** هرگز `allow_origin: ['*']` در production استفاده نکن. +> این اجازه می‌دهد هر وب‌سایت مخرب درخواست‌های authenticated ارسال کند. + +--- + +## Security Headers (EventSubscriber) + +```php +// src/Shared/EventSubscriber/SecurityHeadersSubscriber.php +class SecurityHeadersSubscriber implements EventSubscriberInterface +{ + public function onKernelResponse(ResponseEvent $event): void + { + if (!$event->isMainRequest()) return; + + $response = $event->getResponse(); + $response->headers->set('X-Content-Type-Options', 'nosniff'); + $response->headers->set('X-Frame-Options', 'DENY'); + $response->headers->set('X-XSS-Protection', '1; mode=block'); + $response->headers->set('Referrer-Policy', 'strict-origin-when-cross-origin'); + $response->headers->set('Permissions-Policy', 'geolocation=(), microphone=(), camera=()'); + + if ($event->getRequest()->isSecure()) { + $response->headers->set( + 'Strict-Transport-Security', + 'max-age=31536000; includeSubDomains' + ); + } + + // برای API responses، Content-Security-Policy محدود + if (str_starts_with($event->getRequest()->getPathInfo(), '/api')) { + $response->headers->set('Content-Security-Policy', "default-src 'none'"); + } + } + + public static function getSubscribedEvents(): array + { + return [KernelEvents::RESPONSE => 'onKernelResponse']; + } +} +``` + +--- + +## Swagger UI — فقط در محیط Dev + +```yaml +# config/packages/nelmio_api_doc.yaml +when@prod: + nelmio_api_doc: + # در production کاملاً غیرفعال می‌شود + # route ها به /api/doc باید در routing فقط برای dev تعریف شوند +``` + +```yaml +# config/routes/nelmio_api_doc.yaml +when@dev: + app.swagger_ui: + path: /api/doc + methods: GET + defaults: + _controller: nelmio_api_doc.controller.swagger_ui + + app.swagger_json: + path: /api/doc.json + methods: GET + defaults: + _controller: nelmio_api_doc.controller.swagger +``` + +> **⚠** با این روش، در production هیچ route ای برای `/api/doc` وجود ندارد → 404 + +--- + +## Audit Log — جدول security_logs + +برای رویدادهای امنیتی حساس، یک جدول جداگانه وجود دارد: + +```sql +CREATE TABLE security_logs ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + event_type VARCHAR(50) NOT NULL, -- 'otp_failed', 'login_success', 'role_changed', 'payment_verified', ... + user_id INT NULL, + ip_address VARCHAR(45) NOT NULL, + user_agent VARCHAR(255) NULL, + details JSON NULL, -- اطلاعات اضافه (بدون data حساس!) + created_at INT NOT NULL +); + +CREATE INDEX idx_sec_logs_event ON security_logs(event_type); +CREATE INDEX idx_sec_logs_user ON security_logs(user_id); +CREATE INDEX idx_sec_logs_created ON security_logs(created_at); +``` + +**رویدادهایی که باید log شوند:** +``` +otp_sent — ارسال OTP (mobile ماسک‌شده: 0912***1713) +otp_failed — کد اشتباه +otp_expired — کد منقضی +login_success — ورود موفق +login_failed — ورود ناموفق +logout — خروج +token_refreshed — Refresh Token استفاده شد +role_changed — تغییر نقش کاربر +user_deleted — حذف کاربر +payment_initiated — شروع پرداخت +payment_verified — تأیید پرداخت +payment_failed — پرداخت ناموفق +file_uploaded — آپلود فایل +``` + +> **⚠ داده‌های حساس را log نکن:** شماره کامل موبایل، کد OTP، شماره کارت، JWT. + +--- + +## نکات DDEV + +```bash +ddev exec php bin/console ... +ddev composer ... +ddev describe # مشاهده آدرس‌ها و پورت‌ها +ddev ssh # ورود به container +``` + +URL پروژه: `https://clinic-pro.ddev.site` + +--- + +## نکات امنیتی Production + +``` +✅ هرگز config/jwt/private.pem را در git commit نکن (.gitignore) +✅ JWT_PASSPHRASE را قوی انتخاب کن (حداقل 32 کاراکتر تصادفی) +✅ APP_SECRET را با openssl rand -hex 32 تولید کن +✅ .env.local برای production (نه .env) +✅ CORS_ALLOW_ORIGIN فقط دامنه‌های مشخص (نه *) +✅ Swagger UI فقط در dev فعال است +✅ APP_DEBUG=false در production +``` diff --git a/docs/tasks/task-01-project-setup/task.md b/docs/tasks/task-01-project-setup/task.md new file mode 100644 index 00000000..0e99e098 --- /dev/null +++ b/docs/tasks/task-01-project-setup/task.md @@ -0,0 +1,562 @@ +# تسک ۰۱: راه‌اندازی پروژه و زیرساخت + +## توضیح +راه‌اندازی اولیه پروژه Symfony 7 با DDEV، نصب پکیج‌ها، پیکربندی JWT، Doctrine ORM، +CORS، ساختار Domain-Driven و الگوهای مشترک (BaseController، DTO، Error Codes). + +## Endpoint ها +| متد | مسیر | توضیح | نیاز به Auth | +|-----|------|-------|-------------| +| GET | `/health` | Health Check | خیر | + +## پیش‌نیازها +ندارد — اولین تسک است. + +## خروجی‌های مورد انتظار +- [ ] DDEV راه‌اندازی و در حال اجرا +- [ ] پروژه Symfony 7 ایجاد شده +- [ ] ساختار پوشه Domain-Driven تعریف شده +- [ ] JWT authentication bundle پیکربندی شده +- [ ] Doctrine ORM پیکربندی شده +- [ ] CORS پیکربندی شده +- [ ] `BaseController` با متدهای `success()` و `error()` پیاده شده +- [ ] `BaseRepository` با متدهای مشترک پیاده شده +- [ ] Error Codes و Error Response استاندارد تعریف شده +- [ ] DTO pattern برای Input/Output تعریف شده +- [ ] Rate Limiter پیکربندی شده +- [ ] Symfony Messenger (Queue) راه‌اندازی شده +- [ ] Structured Logging با Monolog پیکربندی شده +- [ ] Swagger UI (NelmioApiDocBundle) راه‌اندازی شده — `/api/doc` +- [ ] `GET /health` endpoint پیاده شده + +## زمان تخمینی +۶ تا ۸ ساعت + +--- + +## مراحل راه‌اندازی با DDEV + +### ۱. ایجاد پروژه Symfony (قبل از DDEV) +```bash +mkdir clinic-pro-symfony && cd clinic-pro-symfony + +# ابتدا Symfony، بعد DDEV +composer create-project symfony/skeleton . "7.*" + +ddev config \ + --project-type=symfony \ + --php-version=8.3 \ + --docroot=public \ + --project-name=clinic-pro + +ddev start +``` + +### ۲. نصب پکیج‌ها (همه در یک دستور) +```bash +ddev composer require \ + symfony/security-bundle \ + symfony/validator \ + symfony/serializer \ + symfony/property-access \ + symfony/property-info \ + symfony/uid \ + symfony/messenger \ + lexik/jwt-authentication-bundle \ + doctrine/doctrine-bundle \ + doctrine/doctrine-migrations-bundle \ + symfony/cache \ + nelmio/cors-bundle \ + symfony/rate-limiter \ + symfony/http-client \ + nelmio/api-doc-bundle \ + zircote/swagger-php \ + twig/twig \ + symfony/asset + +ddev composer require --dev \ + symfony/maker-bundle \ + doctrine/data-fixtures \ + symfony/debug-bundle +``` + +### ۳. راه‌اندازی Redis با DDEV addon رسمی +```bash +ddev get ddev/ddev-redis +ddev restart +``` + +### ۴. تولید کلیدهای JWT +```bash +ddev exec php bin/console lexik:jwt:generate-keypair +``` + +### ۵. پیکربندی LexikJWT + +فایل `config/packages/lexik_jwt_authentication.yaml`: +```yaml +lexik_jwt_authentication: + secret_key: '%env(resolve:JWT_SECRET_KEY)%' + public_key: '%env(resolve:JWT_PUBLIC_KEY)%' + pass_phrase: '%env(JWT_PASSPHRASE)%' + token_ttl: 3600 +``` + +### ۶. پیکربندی Symfony Messenger (Queue) + +فایل `config/packages/messenger.yaml`: +```yaml +framework: + messenger: + transports: + async: + dsn: '%env(MESSENGER_TRANSPORT_DSN)%' + options: + auto_setup: true + routing: + 'App\Shared\Message\SendSmsMessage': async + 'App\Shared\Message\SendNotificationMessage': async +``` + +### ۷. پیکربندی Swagger + +فایل `config/packages/nelmio_api_doc.yaml`: +```yaml +nelmio_api_doc: + documentation: + info: + title: ClinicPro API + description: مستندات API سیستم کلینیک‌پرو + version: 1.0.0 + components: + securitySchemes: + bearerAuth: + type: http + scheme: bearer + bearerFormat: JWT + security: + - bearerAuth: [] + areas: + path_patterns: + - ^/api + - ^/oauth + - ^/health +``` + +فایل `config/routes/nelmio_api_doc.yaml`: +```yaml +app.swagger_ui: + path: /api/doc + methods: GET + defaults: + _controller: nelmio_api_doc.controller.swagger_ui + +app.swagger_json: + path: /api/doc.json + methods: GET + defaults: + _controller: nelmio_api_doc.controller.swagger +``` + +### ۸. پیکربندی Structured Logging + +فایل `config/packages/monolog.yaml` (بخش prod): +```yaml +monolog: + handlers: + main: + type: stream + path: '%kernel.logs_dir%/%kernel.environment%.log' + level: info + formatter: monolog.formatter.json + security: + type: stream + path: '%kernel.logs_dir%/security.log' + level: warning + channels: [security] +``` + +--- + +## ساختار پوشه Domain-Driven (الزامی) + +``` +src/ +├── Auth/ ← Identity Context +│ ├── Controller/ +│ ├── Service/ +│ ├── DTO/ +│ └── Message/ ← برای ارسال OTP async +│ +├── Doctor/ ← Clinical Context +│ ├── Controller/ +│ ├── Entity/ +│ │ ├── Doctor.php +│ │ └── DoctorAddress.php +│ ├── Repository/ +│ ├── Service/ +│ │ ├── DoctorService.php +│ │ └── ScheduleService.php ← محاسبه free_turn و hours_of_work +│ └── DTO/ +│ +├── Clinic/ ← Clinical Context +│ ├── Controller/ +│ ├── Entity/ +│ ├── Repository/ +│ ├── Service/ +│ └── DTO/ +│ +├── Appointment/ ← Scheduling Context +│ ├── Controller/ +│ ├── Entity/ +│ ├── Repository/ +│ ├── Service/ +│ │ ├── AppointmentService.php +│ │ └── SlotService.php ← محاسبه اسلات‌های خالی +│ └── DTO/ +│ +├── Payment/ ← Financial Context +│ ├── Controller/ +│ ├── Entity/ +│ ├── Repository/ +│ ├── Service/ +│ └── Gateway/ +│ ├── PaymentGatewayInterface.php +│ ├── MellatGateway.php +│ └── SepGateway.php +│ +├── Rating/ ← Community Context +│ ├── Controller/ +│ ├── Entity/ +│ ├── Repository/ +│ ├── Service/ +│ │ └── RatingCalculatorService.php +│ └── DTO/ +│ +├── Blog/ +├── Category/ ← Catalog Context +├── Representation/ ← Tenant Context +├── Secretary/ +├── Insurance/ +├── Settlement/ +├── Sms/ +│ +└── Shared/ ← کدهای مشترک + ├── Controller/ + │ └── BaseController.php + ├── Repository/ + │ └── BaseRepository.php + ├── Response/ + │ ├── ApiResponse.php + │ └── ApiError.php + ├── DTO/ + │ └── PaginationMeta.php + ├── Exception/ + │ └── AppException.php + ├── Message/ + │ ├── SendSmsMessage.php + │ └── SendNotificationMessage.php + └── Constant/ + └── ErrorCodes.php +``` + +**قانون Dependency Direction — رعایت اجباری:** +``` +Controller → Service → Repository → Entity +``` +هیچ‌گاه: +``` +Entity → Service ❌ +Repository → Controller ❌ +Service → Controller ❌ +``` + +--- + +## Error Response استاندارد + +### فرمت موفق +```json +{ + "success": true, + "data": { ... }, + "meta": { + "page": 1, + "totalPages": 5, + "totalRecords": 47 + } +} +``` + +### فرمت خطا +```json +{ + "success": false, + "data": null, + "errors": [ + { + "code": "ERR_VALIDATION_001", + "field": "mobile_number", + "message": "فرمت شماره موبایل نادرست است" + } + ] +} +``` + +### Error Codes — `src/Shared/Constant/ErrorCodes.php` + +```php +class ErrorCodes +{ + // Auth + const ERR_AUTH_001 = 'توکن JWT منقضی شده یا نامعتبر است'; + const ERR_AUTH_002 = 'کد OTP نامعتبر است'; + const ERR_AUTH_003 = 'کد OTP منقضی شده است'; + const ERR_AUTH_004 = 'تعداد تلاش‌های OTP به حد مجاز رسیده است'; + + // Validation + const ERR_VALIDATION_001 = 'ورودی نامعتبر است'; + const ERR_VALIDATION_002 = 'فیلد الزامی وارد نشده است'; + + // Not Found + const ERR_NOT_FOUND_001 = 'منبع درخواستی یافت نشد'; + + // Forbidden + const ERR_FORBIDDEN_001 = 'دسترسی به این منبع مجاز نیست'; + + // Payment + const ERR_PAYMENT_001 = 'درگاه پرداخت در دسترس نیست'; + const ERR_PAYMENT_002 = 'مبلغ پرداخت نامعتبر است'; + const ERR_PAYMENT_003 = 'وضعیت نوبت برای پرداخت مناسب نیست'; + + // Appointment + const ERR_APPOINTMENT_001 = 'اسلات انتخاب‌شده در دسترس نیست'; + const ERR_APPOINTMENT_002 = 'نوبت قابل لغو نیست'; + + // File + const ERR_FILE_001 = 'فرمت فایل مجاز نیست'; + const ERR_FILE_002 = 'حجم فایل بیش از حد مجاز است (حداکثر 5MB)'; +} +``` + +### BaseController — `src/Shared/Controller/BaseController.php` + +```php +abstract class BaseController extends AbstractController +{ + protected function success(mixed $data, int $status = 200, array $meta = []): JsonResponse + { + $response = ['success' => true, 'data' => $data]; + if (!empty($meta)) { + $response['meta'] = $meta; + } + return new JsonResponse($response, $status); + } + + protected function paginated(mixed $data, int $total, int $page, int $limit): JsonResponse + { + return $this->success($data, 200, [ + 'totalRecords' => $total, + 'totalPages' => (int) ceil($total / $limit), + 'currentPage' => $page, + ]); + } + + protected function error(string $code, string $message, int $status = 400, ?string $field = null): JsonResponse + { + $err = ['code' => $code, 'message' => $message]; + if ($field) { + $err['field'] = $field; + } + return new JsonResponse(['success' => false, 'data' => null, 'errors' => [$err]], $status); + } +} +``` + +--- + +## DTO Pattern + +هر endpoint باید DTO جداگانه داشته باشد: + +```php +// Input DTO (Request) +class DoctorCreateRequest +{ + #[Assert\NotBlank(message: 'نام دکتر الزامی است')] + public string $title; + + #[Assert\Choice(['man', 'woman'])] + public string $gender; + + #[Assert\Range(min: 0, max: 100)] + public int $experience; +} + +// Output DTO (Response) +class DoctorResponse +{ + public function __construct(private Doctor $doctor) {} + + public function toArray(): array + { + return [ + 'id' => (string) $this->doctor->getId(), + 'uuid' => $this->doctor->getUuid(), + 'name' => $this->doctor->getName(), + 'gender' => $this->doctor->getGender(), + 'experience' => $this->doctor->getExperience(), + // ... + ]; + } +} +``` + +--- + +## Health Check — `GET /health` + +```json +// Response 200: +{ + "status": "ok", + "checks": { + "database": "ok", + "redis": "ok" + }, + "timestamp": 1748000000 +} + +// Response 503 (اگر یکی از سرویس‌ها down باشد): +{ + "status": "degraded", + "checks": { + "database": "ok", + "redis": "error" + }, + "timestamp": 1748000000 +} +``` + +--- + +## File Upload — محدودیت‌های مشترک و امنیتی + +``` +حداکثر حجم فایل: 5MB +فرمت‌های مجاز: image/jpeg, image/png, image/webp +هدرهای الزامی: + Content-Type: application/octet-stream + Content-Disposition: file; filename="name.jpg" + Authorization: Bearer {token} +``` + +### ⚠ اعتبارسنجی امنیتی فایل — بررسی محتوا، نه header + +```php +// src/Shared/Service/FileValidatorService.php +class FileValidatorService +{ + // Magic bytes برای تشخیص واقعی نوع فایل + private const ALLOWED_SIGNATURES = [ + 'image/jpeg' => ["\xFF\xD8\xFF"], + 'image/png' => ["\x89\x50\x4E\x47\x0D\x0A\x1A\x0A"], + 'image/webp' => ["RIFF"], + ]; + + public function validate(string $binaryContent, string $claimedFilename): void + { + // ۱. بررسی حجم + if (strlen($binaryContent) > 5 * 1024 * 1024) { + throw new AppException(ErrorCodes::ERR_FILE_002); + } + + // ۲. بررسی magic bytes — نه MIME از header + $detected = false; + foreach (self::ALLOWED_SIGNATURES as $mime => $signatures) { + foreach ($signatures as $sig) { + if (str_starts_with($binaryContent, $sig)) { + $detected = true; + break 2; + } + } + } + if (!$detected) { + throw new AppException(ErrorCodes::ERR_FILE_001); + } + + // ۳. Sanitize filename — جلوگیری از path traversal + $safeName = preg_replace('/[^a-zA-Z0-9._-]/', '', basename($claimedFilename)); + if (empty($safeName) || str_contains($safeName, '..')) { + throw new AppException(ErrorCodes::ERR_FILE_001); + } + + // ۴. پسوند باید با content مطابقت داشته باشد + $ext = strtolower(pathinfo($safeName, PATHINFO_EXTENSION)); + if (!in_array($ext, ['jpg', 'jpeg', 'png', 'webp'], true)) { + throw new AppException(ErrorCodes::ERR_FILE_001); + } + } +} +``` + +--- + +## Swagger — نحوه استفاده در Controller + +```php +use OpenApi\Attributes as OA; + +#[OA\Tag(name: 'Doctor')] +class DoctorController extends BaseController +{ + #[OA\Get( + path: '/api/v1/doctor/{uuid}', + summary: 'دریافت پروفایل دکتر', + 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: 'پروفایل کامل دکتر'), + new OA\Response(response: 404, description: 'دکتر یافت نشد'), + ] + )] + #[Route('/api/v1/doctor/{uuid}', methods: ['GET'])] + public function show(string $uuid): JsonResponse { ... } +} +``` + +--- + +## متغیرهای محیطی (.env) + +```dotenv +APP_ENV=dev +APP_SECRET=your-secret-key + +DATABASE_URL="mysql://db:db@db:3306/db?serverVersion=8.0" + +REDIS_URL=redis://redis:6379 + +# Queue (از Redis استفاده می‌کند) +MESSENGER_TRANSPORT_DSN=redis://redis:6379/messages + +JWT_SECRET_KEY=%kernel.project_dir%/config/jwt/private.pem +JWT_PUBLIC_KEY=%kernel.project_dir%/config/jwt/public.pem +JWT_PASSPHRASE=your-passphrase + +# Refresh Token TTL (ثانیه) — 30 روز +REFRESH_TOKEN_TTL=2592000 + +OTP_TTL=1200 + +# SMS Providers +KAVENEGAR_API_KEY=your-key +RANGINEH_API_KEY=your-key +SMS_PROVIDER=kavenegar + +# File Upload +MAX_FILE_SIZE_BYTES=5242880 +``` diff --git a/docs/tasks/task-02-authentication/architecture.md b/docs/tasks/task-02-authentication/architecture.md new file mode 100644 index 00000000..f59ad762 --- /dev/null +++ b/docs/tasks/task-02-authentication/architecture.md @@ -0,0 +1,84 @@ +# معماری — تسک ۰۲: ماژول احراز هویت + +## ساختار فایل‌ها +``` +src/Module/Auth/ +├── Controller/ +│ ├── OtpController.php ← send-code, verify-code +│ ├── AuthController.php ← register, session/token +│ ├── OAuthController.php ← /oauth/token, /oauth/userinfo +│ └── UserController.php ← delete, patch user +├── Service/ +│ ├── OtpService.php ← تولید، ذخیره و تأیید OTP در Redis +│ ├── JwtService.php ← صدور و تمدید JWT +│ ├── CaptchaService.php ← اعتبارسنجی captcha_token +│ └── UserService.php ← ایجاد، ویرایش، حذف کاربر +├── Repository/ +│ └── UserRepository.php +├── Entity/ +│ └── User.php +├── DTO/ +│ ├── Request/ +│ │ ├── SendCodeRequest.php +│ │ ├── VerifyCodeRequest.php +│ │ ├── RegisterRequest.php +│ │ ├── RefreshTokenRequest.php +│ │ └── UpdateUserRequest.php +│ └── Response/ +│ ├── TokenResponse.php +│ └── UserInfoResponse.php +└── Voter/ + └── UserVoter.php ← فقط owner یا admin می‌تواند ویرایش/حذف کند +``` + +## نمودار جریان احراز هویت +``` +کاربر + │ + ├─► POST /send-code + │ └─► OtpService: تولید کد ۵ رقمی + │ └─► Redis: ذخیره با کلید otp:{mobile} (TTL=120s) + │ └─► SmsService: ارسال پیامک + │ + ├─► POST /verify-code + │ └─► OtpService: تأیید کد از Redis + │ ├─► اگر کاربر جدید: ایجاد User با وضعیت pending + │ └─► JwtService: صدور access_token + refresh_token + │ + └─► POST /register (با X-CSRF-Token) + └─► UserService: تکمیل اطلاعات کاربر +``` + +## Entity: User +```php +#[ORM\Entity(repositoryClass: UserRepository::class)] +#[ORM\Table(name: 'users')] +class User implements UserInterface +{ + #[ORM\Id, ORM\GeneratedValue, ORM\Column] + private int $id; + + #[ORM\Column(type: UuidType::NAME, unique: true)] + private Uuid $uuid; + + #[ORM\Column(length: 20, unique: true)] + private string $mobile; + + #[ORM\Column(length: 100, nullable: true)] + private ?string $firstName; + + #[ORM\Column(length: 100, nullable: true)] + private ?string $lastName; + + #[ORM\Column(length: 180, nullable: true, unique: true)] + private ?string $email; + + #[ORM\Column(length: 50)] + private string $status = 'pending'; // pending, active, blocked + + #[ORM\Column(type: 'json')] + private array $roles = ['ROLE_USER']; + + // TimestampableTrait +} +``` diff --git a/docs/tasks/task-02-authentication/database.md b/docs/tasks/task-02-authentication/database.md new file mode 100644 index 00000000..f484f6a7 --- /dev/null +++ b/docs/tasks/task-02-authentication/database.md @@ -0,0 +1,103 @@ +# پایگاه داده — تسک ۰۲: ماژول احراز هویت + +## جدول: users +_(از بخش ۲.۱ مستند + بررسی DB backup Drupal)_ + +| ستون | نوع | نام Drupal | توضیح | +|------|-----|-----------|-------| +| id | INT AUTO_INCREMENT PK | id | شناسه داخلی | +| uuid | CHAR(36) UNIQUE NOT NULL | uuid | شناسه عمومی UUID | +| uid | INT FK → users.id NOT NULL | uid | ارجاع به خود جدول (self-reference — در Drupal الزامی) | +| mobile_number | VARCHAR(20) UNIQUE NOT NULL | name | شماره موبایل — به عنوان username استفاده می‌شود | +| password | VARCHAR(255) NOT NULL | pass | رمز عبور هش‌شده با bcrypt | +| realname | VARCHAR(255) NULL | field_realname | نام و نام‌خانوادگی کامل (نه first_name/last_name!) | +| picture | VARCHAR(500) NULL | user_picture | آدرس تصویر پروفایل | +| email | VARCHAR(180) UNIQUE NULL | mail | ایمیل (اختیاری) | +| status | TINYINT(1) DEFAULT 1 | status | ۱=فعال، ۰=غیرفعال | +| roles | JSON NOT NULL | — | نقش‌ها — مثال: `{"0":"authenticated","2":"doctor"}` | +| created_at | INT NOT NULL | created | Unix timestamp — زمان ایجاد | +| updated_at | INT NOT NULL | changed | Unix timestamp — آخرین ویرایش | + +> **⚠ مهم:** +> - Drupal از `realname` (یک فیلد) استفاده می‌کند، **نه** `first_name` + `last_name`! +> - timestamp‌ها نوع **INT** هستند (Unix timestamp)، نه DATETIME +> - `status` نوع **TINYINT** است (نه ENUM) +> - `uid` self-reference است — در Drupal هر user به خودش اشاره می‌کند + +## ایندکس‌ها +```sql +CREATE UNIQUE INDEX idx_users_uuid ON users(uuid); +CREATE UNIQUE INDEX idx_users_mobile ON users(mobile_number); +CREATE UNIQUE INDEX idx_users_email ON users(email); +CREATE INDEX idx_users_status ON users(status); +``` + +## ذخیره‌سازی OTP در Redis (نه پایگاه داده) +``` +کلید: otp:{uuid} ← UUID از /api/v1/user/send-code برگردانده می‌شود (نه mobile!) +مقدار: {"code": "12345", "attempts": 0} +TTL: 1200 ثانیه (20 دقیقه) +``` + +## جریان OTP (MobileGrant) +``` +1. POST /api/v1/user/send-code → {mobile, captcha_token} + → UUID تولید + کد OTP ذخیره در Redis با کلید otp:{uuid} + → UUID برگردانده می‌شود + +2. POST /api/v1/user/verify-code → {mobile, captcha_token} + → کد از Redis با کلید otp:{uuid} تأیید می‌شود + +3. POST /oauth/token → grant_type=mobile (MobileGrant) + → JWT token صادر می‌شود + +4. GET /oauth/userinfo → Bearer token + → اطلاعات کاربر + شیء clinic_pro برگردانده می‌شود +``` + +## نمونه پاسخ GET /oauth/userinfo (واقعی از Drupal) +```json +{ + "email": null, + "email_verified": true, + "username": "09120671710", + "id": "22", + "uuid": "d200f5c5-d717-4526-b263-d3bb7d0228d6", + "created": "1762262151", + "changed": "1762262267", + "status": "1", + "roles": { + "0": "authenticated", + "2": "doctor" + }, + "realName": "single doctor", + "picture": [], + "clinic_pro": { + "base_role": "doctor", + "db_uuid": "61be915b-595a-42e5-bca5-f80d22f4f14a", + "db_key": "22bea8c1dc64d9b0c744810722519efe7290276ecd82d9bc650482aa4539bf0d", + "my_doctors_uuid": { + "uuid": "61be915b-595a-42e5-bca5-f80d22f4f14a", + "id": "29", + "name": "single doctor" + } + } +} +``` + +> **نکات `/oauth/userinfo`:** +> - `realName` با حرف بزرگ N (camelCase) +> - `roles` یک object است، نه array: `{"0":"authenticated","2":"doctor"}` +> - `clinic_pro.base_role` → نقش اصلی: `doctor`, `clinic`, `doctor_s_secretary` +> - `clinic_pro.db_uuid` → UUID موجودیت doctor/clinic در جدول clinic_pro +> - فقط نقش‌های `doctor`, `clinic`, `doctor_s_secretary` می‌توانند با پسورد لاگین کنند + +## روابط با سایر جداول +``` +users → user_profiles (OneToOne) : تسک ۰۳ +users → doctors (OneToOne) : تسک ۰۵ +users → clinics (OneToOne) : تسک ۰۶ +users → appointments (OneToMany) : تسک ۱۰ +users → payments (OneToMany) : تسک ۱۵ +users → representations (OneToOne): تسک ۱۶ +``` diff --git a/docs/tasks/task-02-authentication/implementation_notes.md b/docs/tasks/task-02-authentication/implementation_notes.md new file mode 100644 index 00000000..5e5d6e75 --- /dev/null +++ b/docs/tasks/task-02-authentication/implementation_notes.md @@ -0,0 +1,162 @@ +# نکات پیاده‌سازی — تسک ۰۲: ماژول احراز هویت + +## مهم‌ترین تفاوت با طراحی اولیه + +### جریان OTP با UUID (نه موبایل) +در Drupal، کد OTP **با UUID** ذخیره می‌شود، نه با شماره موبایل: +```php +// ساختار ذخیره‌سازی در KeyValue/Redis: +key = uuid (تولیدشده در send-code) +value = { "code": "12345", "mobile": "09120671713" } +TTL = 1200 ثانیه +``` + +`verify-code` و `oauth/token` هر دو `uuid` می‌خواهند، نه موبایل. + +```php +// OtpService.php +public function generate(string $mobile): array +{ + $uuid = Uuid::uuid4()->toString(); + $code = $this->isDev() ? '12345' : (string) random_int(10000, 99999); + $this->redis->setex("otp:{$uuid}", 1200, json_encode([ + 'code' => $code, + 'mobile' => $mobile, + ])); + return ['uuid' => $uuid, 'code' => $code]; +} + +public function verify(string $uuid, string $code): bool +{ + $data = json_decode($this->redis->get("otp:{$uuid}"), true); + if (!$data || $data['code'] !== $code) return false; + $this->redis->del("otp:{$uuid}"); + return true; +} + +public function getMobileByUuid(string $uuid): ?string +{ + $data = json_decode($this->redis->get("otp:{$uuid}"), true); + return $data['mobile'] ?? null; +} +``` + +## MobileGrant در Symfony +به جای OAuth2 کامل، یک custom JWT grant پیاده‌سازی کن: + +``` +POST /oauth/token + grant_type=mobile + uuid=... + code=... + client_id=clinic-pro + client_secret=... + +→ OtpService::verify(uuid, code) تأیید کند +→ getMobileByUuid(uuid) موبایل را بگیر +→ کاربر را پیدا یا بساز +→ JWT صادر کن +``` + +## SMS Providers +دو provider واقعی در Drupal: + +**KavehNegar:** +```php +POST https://api.kavenegar.com/v1/{apiKey}/sms/send.json +form: receptor={mobile}&message={code}&sender=10004346 +``` + +**Rangineh:** +از پیاده‌سازی در `sms_provider/src/Plugin/SmsProvider/Rangineh.php` الگو بگیر. + +**Interface در Symfony:** +```php +interface SmsProviderInterface { + public function send(string $mobile, string $message): bool; +} +``` +پیکربندی در `.env`: +``` +SMS_PROVIDER=kavenegar # kavenegar | rangineh | null (dev) +KAVENEGAR_API_KEY=... +KAVENEGAR_SENDER=10004346 +``` + +## Rate Limiting (مقادیر واقعی) +```php +// IP: 50 درخواست در ساعت +// Mobile: 30 درخواست در ساعت +// keys Redis: +// rate_ip:{ip} TTL=3600 +// rate_mobile:{mobile} TTL=3600 +``` + +## TTL کد OTP: 1200 ثانیه (20 دقیقه) +در طراحی اولیه اشتباهاً 120 ثانیه نوشته شده بود — مقدار واقعی از Drupal ۱۲۰۰ است. + +## Flood Control (از Drupal) +علاوه بر rate limiting، Drupal از flood control نیز استفاده می‌کند: +- `oauth2_grant.mobile.failed_login_ip` — IP based +- `oauth2_grant.mobile.failed_login_user` — User based +در Symfony از Symfony's `RateLimiter` component جایگزین کن. + +## سازگاری با کلاینت: GET /session/token +```php +return new Response(bin2hex(random_bytes(22)), 200, ['Content-Type' => 'text/plain']); +``` + +## مجوزها +``` +DELETE /api/v1/user/{id} → ROLE_ADMIN +PATCH /api/v1/user/{id} → owner یا ROLE_ADMIN +``` + +## ساختار واقعی GET /oauth/userinfo (از سرور Drupal) + +```json +{ + "email": null, + "email_verified": true, + "username": "09120671710", + "id": "22", + "uuid": "d200f5c5-d717-4526-b263-d3bb7d0228d6", + "created": "1762262151", + "changed": "1762262267", + "status": "1", + "roles": { + "0": "authenticated", + "2": "doctor" + }, + "realName": "single doctor", + "picture": [], + "clinic_pro": { + "base_role": "doctor", + "db_uuid": "61be915b-595a-42e5-bca5-f80d22f4f14a", + "db_key": "22bea8c1dc64d9b0c744810722519efe7290276ecd82d9bc650482aa4539bf0d", + "my_doctors_uuid": { + "uuid": "61be915b-595a-42e5-bca5-f80d22f4f14a", + "id": "29", + "name": "single doctor" + } + } +} +``` + +### نکات مهم userinfo: +- `roles` یک **object** (نه array) با کلیدهای عددی است: `{"0": "authenticated", "2": "doctor"}` +- `id` و `uuid` از users table +- `realName` با R بزرگ (camelCase) +- `clinic_pro.base_role` = نقش اصلی کاربر +- `clinic_pro.db_uuid` = UUID موجودیت مرتبط (doctor/clinic/representation) +- `clinic_pro.db_key` = token دسترسی برای عملیات داخلی +- `clinic_pro.my_doctors_uuid` (فقط برای doctor) = مشخصات پروفایل دکتر + +## نقش‌های مجاز برای login با password +``` +فقط این نقش‌ها می‌توانند با POST /oauth/token (grant_type=password) لاگین کنند: +- doctor +- clinic +- doctor_s_secretary +``` +بقیه (patient, representation, admin) فقط از طریق OTP لاگین می‌کنند. diff --git a/docs/tasks/task-02-authentication/task.md b/docs/tasks/task-02-authentication/task.md new file mode 100644 index 00000000..09324940 --- /dev/null +++ b/docs/tasks/task-02-authentication/task.md @@ -0,0 +1,488 @@ +# تسک ۰۲: ماژول احراز هویت + +## توضیح +دو روش ورود پشتیبانی می‌شود: + +**روش اول — OTP موبایل (برای بیماران و عموم):** +`send-code` → UUID برمی‌گرداند → `verify-code` با UUID+code → `oauth/token` صادر می‌کند JWT + Refresh Token + +**روش دوم — Username/Password (فقط برای دکتر، کلینیک، منشی):** +`POST /api/v1/user/login` → مستقیم JWT + Refresh Token صادر می‌کند + +## Endpoint ها + +| متد | مسیر | توضیح | نیاز به Auth | +|-----|------|-------|-------------| +| POST | `/api/v1/user/send-code` | ارسال OTP، بازگشت `uuid` | خیر | +| POST | `/api/v1/user/verify-code` | تأیید OTP با `uuid` + `code` | خیر | +| POST | `/api/v1/user/register` | تکمیل ثبت‌نام | خیر | +| POST | `/api/v1/user/login` | لاگین با username+password (دکتر/کلینیک/منشی) | خیر | +| GET | `/session/token` | CSRF Token (سازگاری با کلاینت) | خیر | +| POST | `/oauth/token` | صدور JWT + Refresh Token (OTP flow) | خیر | +| POST | `/oauth/token/refresh` | تجدید JWT با Refresh Token | خیر | +| GET | `/oauth/userinfo` | اطلاعات کاربر لاگین‌شده | بله | +| POST | `/oauth/logout` | لغو توکن‌ها | بله | +| DELETE | `/api/v1/user/{id}` | حذف کاربر | بله (Admin) | +| PATCH | `/api/v1/user/{id}` | ویرایش اطلاعات پایه کاربر | بله (Owner) | + +## پیش‌نیازها +- تسک ۰۱ کامل شده باشد + +## زمان تخمینی +۱۲ تا ۱۴ ساعت + +--- + +## جریان واقعی OTP + +### مرحله ۱ — POST /api/v1/user/send-code +```json +// Request +{ "mobile": "09120671713", "captcha_token": "" } + +// Response 200 +{ + "uuid": "a1b2c3d4-e5f6-...", + "message": "کد تایید با موفقیت ارسال شد." +} + +// Response 429 (rate limit) +{ + "success": false, + "errors": [{ "code": "ERR_AUTH_004", "message": "تعداد تلاش‌ها به حد مجاز رسیده است" }] +} +``` + +**منطق داخلی:** +``` +1. بررسی rate limit (IP: 50/hr، mobile: 30/hr) +2. بررسی تعداد تلاش‌های OTP برای این mobile (حداکثر 5 بار در TTL) +3. تولید کد 5 رقمی +4. ذخیره در Redis: key=otp:{uuid}، value={mobile, code, attempts:0}، TTL=1200s +5. ارسال SMS async (از طریق Symfony Messenger) +6. بازگشت uuid +``` + +### مرحله ۲ — POST /api/v1/user/verify-code +```json +// Request +{ "uuid": "a1b2c3d4-...", "code": "12345" } + +// Response 200 +{ "message": "کد با موفقیت تایید شد.", "success": true } + +// Response 400 — کد اشتباه +{ "success": false, "errors": [{ "code": "ERR_AUTH_002", "message": "کد OTP نامعتبر است" }] } + +// Response 400 — کد منقضی +{ "success": false, "errors": [{ "code": "ERR_AUTH_003", "message": "کد OTP منقضی شده است" }] } +``` + +**منطق داخلی:** +``` +1. بررسی وجود key در Redis +2. مقایسه code با hash_equals() — نه == (جلوگیری از Timing Attack) +3. افزایش attempts در Redis +4. اگر attempts > 5 → خطای ERR_AUTH_004 و حذف key +5. در صورت صحت → افزودن verified:true به Redis +``` + +```php +// ⚠ استفاده از hash_equals برای جلوگیری از Timing Attack +if (!hash_equals($storedCode, $submittedCode)) { + // کد اشتباه +} +``` + +### مرحله ۳ — POST /oauth/token (MobileGrant) +``` +// Request (form-data) +grant_type=mobile +client_id=clinic-pro +client_secret=secret +uuid=a1b2c3d4-... +code=12345 +registration=true + +// Response 200 +{ + "access_token": "eyJ...", + "refresh_token": "a8f3b2...", + "token_type": "Bearer", + "expires_in": 3600, + "refresh_token_expires_in": 2592000 +} +``` + +**منطق داخلی:** +``` +1. خواندن uuid از Redis — بررسی verified:true +2. اگر کاربر جدید و registration=true → ایجاد کاربر +3. صدور JWT (TTL=3600s) +4. تولید Refresh Token (random_bytes(32) → bin2hex → 64 char) +5. هش کردن Refresh Token: hash('sha256', $rawToken) +6. ذخیره در Redis: key=refresh:{hash}، value=user_id، TTL=2592000s +7. حذف OTP از Redis +8. بازگشت access_token + raw refresh_token (نه hash) +``` + +```php +// تولید و ذخیره Refresh Token +$rawToken = bin2hex(random_bytes(32)); // 64 کاراکتر hex +$hashedToken = hash('sha256', $rawToken); // ذخیره hash در Redis +$redis->setex("refresh:{$hashedToken}", 2592000, $userId); +// ارسال rawToken به کلاینت — هرگز hash را ارسال نکن +``` + +--- + +## Refresh Token + +### POST /oauth/token/refresh +```json +// Request +{ + "refresh_token": "a8f3b2c1d0..." +} + +// Response 200 +{ + "access_token": "eyJ...", + "refresh_token": "new_token_here", + "token_type": "Bearer", + "expires_in": 3600 +} + +// Response 401 — Refresh Token نامعتبر یا منقضی +{ + "success": false, + "errors": [{ "code": "ERR_AUTH_001", "message": "Refresh Token نامعتبر یا منقضی شده است" }] +} +``` + +**منطق:** +``` +1. هش کردن token دریافتی: hash('sha256', $submittedToken) +2. جستجو key=refresh:{hash} در Redis +3. اگر وجود ندارد → 401 +4. صدور JWT جدید +5. Refresh Token Rotation: + - حذف hash قدیمی از Redis + - تولید rawToken جدید + hash جدید + - ذخیره hash جدید با TTL=2592000s +6. بازگشت access_token + rawToken جدید +``` + +### POST /oauth/logout +```json +// Request — Header: Authorization: Bearer {access_token} +// Body: +{ "refresh_token": "a8f3b2c1d0..." } + +// Response 200 +{ "success": true, "message": "خروج با موفقیت انجام شد" } +``` + +**منطق:** +``` +1. حذف refresh:{token} از Redis +2. افزودن JWT به blacklist: key=blacklist:{jti}، TTL=remaining_ttl +``` + +--- + +## GET /session/token — CSRF + +```json +// Response 200 +{ "token": "XwZ9k2P..." } +``` + +ذخیره در Redis: `key=csrf:{token}` با TTL=3600s + +--- + +## GET /oauth/userinfo + +```json +{ + "id": 33, + "uuid": "...", + "mobile_number": "09120671713", + "realName": "علی احمدی", + "picture": null, + "status": 1, + "roles": { "0": "authenticated", "2": "doctor" }, + "clinic_pro": { + "base_role": "doctor", + "db_uuid": "...", + "db_key": 29, + "my_doctors_uuid": null + } +} +``` + +--- + +## Rate Limiting + +| نوع | حد | پنجره | +|-----|-----|-------| +| IP | 50 درخواست | ساعتی | +| Mobile | 30 درخواست | ساعتی | +| OTP Attempts | 5 تلاش | در طول TTL (1200s) | + +**Rate Limit Headers در Response:** +``` +X-RateLimit-Limit: 50 +X-RateLimit-Remaining: 47 +X-RateLimit-Reset: 1748003600 +``` + +--- + +## SMS Providers + +``` +اصلی: KavehNegar (KAVENEGAR_API_KEY) +جایگزین: Rangineh (RANGINEH_API_KEY) +Fallback logic: اگر KavehNegar خطا داد → Rangineh +محیط dev: کد ثابت 12345 (بدون ارسال واقعی) +``` + +ارسال SMS از طریق **Symfony Messenger** (async) انجام می‌شود. + +--- + +## Redis Key Schema + +| Key | Value | TTL | +|-----|-------|-----| +| `otp:{uuid}` | `{mobile, code, attempts, verified}` | 1200s | +| `refresh:{hash}` | `user_id` | 2592000s | +| `blacklist:{jti}` | `1` | remaining JWT TTL | +| `csrf:{token}` | `1` | 3600s | +| `rate_ip:{ip}` | count | 3600s | +| `rate_mobile:{mobile}` | count | 3600s | + +--- + +## لاگین با Username/Password (برای دکتر، کلینیک، منشی) + +### POST /api/v1/user/login + +**چه کسانی می‌توانند استفاده کنند:** +- کاربران با نقش `doctor` +- کاربران با نقش `clinic` +- کاربران با نقش `doctor_s_secretary` + +بیماران عادی فقط از طریق OTP وارد می‌شوند — این endpoint برای آنها در دسترس نیست. + +> **⚠ نام کاربری برای همه کاربران و همه نقش‌ها = شماره موبایل است.** +> ایمیل به عنوان username پشتیبانی نمی‌شود. + +```json +// Request +{ + "mobile_number": "09120671713", + "password": "SecurePass123!" +} + +// Response 200 +{ + "access_token": "eyJ...", + "refresh_token": "a8f3b2c1d0...", + "token_type": "Bearer", + "expires_in": 3600, + "refresh_token_expires_in": 2592000 +} + +// Response 401 — اطلاعات اشتباه +{ + "success": false, + "errors": [{ "code": "ERR_AUTH_005", "message": "نام کاربری یا رمز عبور اشتباه است" }] +} + +// Response 403 — نقش کاربر اجازه ندارد از این روش استفاده کند +{ + "success": false, + "errors": [{ "code": "ERR_AUTH_006", "message": "این نوع حساب فقط از طریق کد OTP وارد می‌شود" }] +} +``` + +**منطق داخلی:** +``` +1. جستجوی کاربر با mobile_number +2. بررسی وجود کاربر و password_hash +3. تأیید رمز با password_verify() +4. بررسی نقش کاربر — فقط doctor / clinic / doctor_s_secretary مجاز +5. صدور JWT (TTL=3600s) +6. تولید Refresh Token و ذخیره SHA-256 hash در Redis +7. ثبت رویداد login_success در security_logs +8. بازگشت access_token + raw refresh_token +``` + +**پیاده‌سازی در Symfony — Custom Authenticator:** + +```php +// src/Auth/Security/PasswordAuthenticator.php +class PasswordAuthenticator extends AbstractAuthenticator +{ + public function supports(Request $request): ?bool + { + return $request->getPathInfo() === '/api/v1/user/login' + && $request->isMethod('POST'); + } + + public function authenticate(Request $request): Passport + { + $data = json_decode($request->getContent(), true); + $mobile = $data['mobile_number'] ?? ''; + $password = $data['password'] ?? ''; + + return new Passport( + new UserBadge($mobile, fn($m) => $this->userRepo->findByMobile($m)), + new PasswordCredentials($password), + [new CsrfTokenBadge('login', $data['_csrf'] ?? '')] + ); + } + + public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response + { + $user = $token->getUser(); + + // بررسی نقش — فقط doctor/clinic/secretary + $allowedRoles = ['ROLE_DOCTOR', 'ROLE_CLINIC', 'ROLE_SECRETARY']; + if (empty(array_intersect($user->getRoles(), $allowedRoles))) { + return new JsonResponse([ + 'success' => false, + 'errors' => [['code' => 'ERR_AUTH_006', 'message' => 'این نوع حساب فقط از طریق کد OTP وارد می‌شود']] + ], 403); + } + + $accessToken = $this->jwtManager->create($user); + $rawToken = bin2hex(random_bytes(32)); + $hashedToken = hash('sha256', $rawToken); + $this->redis->setex("refresh:{$hashedToken}", 2592000, $user->getId()); + + $this->auditLog->log('login_success', $user->getId(), $request, ['method' => 'password']); + + return new JsonResponse([ + 'access_token' => $accessToken, + 'refresh_token' => $rawToken, + 'token_type' => 'Bearer', + 'expires_in' => 3600, + 'refresh_token_expires_in' => 2592000, + ]); + } + + public function onAuthenticationFailure(Request $request, AuthenticationException $exception): Response + { + $this->auditLog->log('login_failed', null, $request, ['reason' => $exception->getMessage()]); + + return new JsonResponse([ + 'success' => false, + 'errors' => [['code' => 'ERR_AUTH_005', 'message' => 'نام کاربری یا رمز عبور اشتباه است']] + ], 401); + } +} +``` + +**فیلد password در جدول users:** +```sql +ALTER TABLE users ADD COLUMN password_hash VARCHAR(255) NULL; +-- NULL برای بیمارانی که فقط OTP دارند +-- پر شده برای doctor/clinic/secretary +``` + +**تغییر رمز عبور (دکتر/کلینیک/منشی):** +```json +PATCH /api/v1/user/{uuid}/password +Authorization: Bearer {access_token} + +// Request +{ + "current_password": "OldPass123!", + "new_password": "NewPass456!", + "new_password_confirmation": "NewPass456!" +} + +// Response 200 +{ "success": true, "message": "رمز عبور با موفقیت تغییر کرد" } +``` + +**قوانین رمز عبور:** +- حداقل ۸ کاراکتر +- حداقل یک حرف بزرگ +- حداقل یک عدد +- bcrypt با cost=12 + +--- + +## ⚠ استاندارد JWT در Symfony — کجا توکن را ارسال کنیم؟ + +این یکی از مهم‌ترین نکات امنیتی پروژه است. + +### Access Token — همیشه در هدر Authorization + +``` +Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9... +``` + +**LexikJWTAuthenticationBundle** این هدر را در firewall `api` به صورت خودکار بررسی می‌کند. +هیچ کد اضافه‌ای در Controller لازم نیست — middleware JWT مجاز بودن را تأیید می‌کند. + +```yaml +# config/packages/security.yaml +firewalls: + api: + pattern: ^/(api|oauth)/ + stateless: true + jwt: ~ # ← این خط کافی است؛ خودش هدر Authorization را می‌خواند +``` + +**هرگز access_token را اینجا نفرست:** +``` +❌ GET /api/v1/doctor?token=eyJ... ← URL param +❌ POST /api/v1/payment body: {token: ...} ← Request body +❌ Cookie: access_token=eyJ... ← Cookie +``` + +### Refresh Token — فقط در body برای endpoint مخصوص + +Refresh Token هیچ‌وقت در `Authorization` header نمی‌رود. فقط یک‌بار و فقط به endpoint `/oauth/token/refresh` در body ارسال می‌شود: + +``` +POST /oauth/token/refresh +Content-Type: application/json + +{ + "refresh_token": "a8f3b2c1d0e4f5..." +} +``` + +این endpoint در security.yaml به صورت `PUBLIC_ACCESS` است چون تأیید هویت با خود Refresh Token انجام می‌شود (نه با JWT). + +### خلاصه گردش توکن‌ها + +``` +[ورود] → Response body: + { + "access_token": "eyJ..." ← ذخیره در memory (نه localStorage) + "refresh_token": "a8f3b2..." ← ذخیره در HttpOnly Cookie یا secure storage + } + +[هر درخواست محافظت‌شده]: + Authorization: Bearer eyJ... ← فقط access_token در header + +[وقتی access_token منقضی شد]: + POST /oauth/token/refresh + body: { "refresh_token": "a8f3b2..." } + → Response: { "access_token": "eyJ_new...", "refresh_token": "new_refresh..." } + +[خروج]: + POST /oauth/logout + Authorization: Bearer eyJ... + body: { "refresh_token": "a8f3b2..." } + → هر دو توکن باطل می‌شوند +``` diff --git a/docs/tasks/task-02-authentication/user_flow.md b/docs/tasks/task-02-authentication/user_flow.md new file mode 100644 index 00000000..6a9035e3 --- /dev/null +++ b/docs/tasks/task-02-authentication/user_flow.md @@ -0,0 +1,77 @@ +# جریان کاربری — تسک ۰۲: ماژول احراز هویت + +## جریان کامل ورود با OTP (جریان واقعی از Drupal) + +``` +کاربر موبایل را وارد می‌کند + │ + ▼ +POST /api/v1/user/send-code + { mobile: "09120671713", captcha_token: "" } + │ + ├─► اعتبارسنجی فرمت موبایل (/^(\+98|0)?9\d{9}$/) + ├─► بررسی rate limit IP (max 50/hour) + ├─► بررسی rate limit Mobile (max 30/hour) + ├─► تولید UUID + کد OTP + ├─► ذخیره در Redis: otp:{uuid} = {code, mobile} (TTL=1200s) + └─► ارسال SMS + │ + ▼ +Response: { uuid: "a1b2c3d4-...", message: "..." } + │ + ▼ +کاربر کد را وارد می‌کند + │ + ▼ +POST /api/v1/user/verify-code + { uuid: "a1b2c3d4-...", code: "12345" } + │ + ├─► بررسی وجود uuid در Redis + ├─► مقایسه code + │ ├─► نادرست: خطا + │ └─► درست: حذف از Redis + └─► Response: { message: "کد با موفقیت تایید شد.", success: true } + + ⚠️ verify-code در Drupal JWT صادر نمی‌کند! + JWT در مرحله بعد با /oauth/token صادر می‌شود. + │ + ▼ +POST /oauth/token (MobileGrant) + grant_type=mobile + uuid=a1b2c3d4-... ← همان uuid + code=12345 ← همان code + client_id=clinic-pro + client_secret=... + registration=true ← اگر false باشد، فقط کاربر موجود می‌تواند وارد شود + │ + ├─► OtpService::verify(uuid, code) + ├─► OtpService::getMobileByUuid(uuid) + ├─► بررسی وجود کاربر با این موبایل + │ ├─► وجود دارد: ادامه + │ └─► جدید + registration=true: ایجاد user با status=pending + └─► صدور JWT + │ + ▼ +Response: { access_token, refresh_token, token_type: "Bearer", expires_in: 3600 } +``` + +## جریان تمدید Token + +``` +POST /oauth/token + grant_type=refresh_token + client_id=clinic-pro + client_secret=... + refresh_token=eyJ... + │ + └─► بررسی refresh_token → صدور access_token جدید +``` + +## جریان دریافت اطلاعات کاربر + +``` +GET /oauth/userinfo + Authorization: Bearer {access_token} + │ + └─► decode JWT → بازگشت: sub, uuid, name, email, phone_number, scope +``` diff --git a/docs/tasks/task-03-user-profile/architecture.md b/docs/tasks/task-03-user-profile/architecture.md new file mode 100644 index 00000000..9fe715c3 --- /dev/null +++ b/docs/tasks/task-03-user-profile/architecture.md @@ -0,0 +1,103 @@ +# معماری — تسک ۰۳: ماژول پروفایل کاربر + +## ساختار فایل‌ها +``` +src/Module/UserProfile/ +├── Controller/ +│ └── UserProfileController.php +├── Service/ +│ └── UserProfileService.php +├── Repository/ +│ └── UserProfileRepository.php +├── Entity/ +│ └── UserProfile.php +├── DTO/ +│ ├── Request/ +│ │ ├── CreateUserProfileRequest.php +│ │ └── UpdateUserProfileRequest.php +│ └── Response/ +│ └── UserProfileResponse.php +└── Voter/ + └── UserProfileVoter.php +``` + +## Entity: UserProfile +```php +#[ORM\Entity] +#[ORM\Table(name: 'user_profiles')] +class UserProfile +{ + #[ORM\Id, ORM\GeneratedValue, ORM\Column] + private int $id; + + #[ORM\Column(type: UuidType::NAME, unique: true)] + private Uuid $uuid; + + #[ORM\OneToOne(targetEntity: User::class)] + #[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')] + private User $user; + + #[ORM\Column(length: 200, nullable: true)] + private ?string $name; + + #[ORM\Column(type: 'text', nullable: true)] + private ?string $description; + + #[ORM\Column(length: 20, nullable: true)] + private ?string $birthday; + + #[ORM\Column(type: 'json', nullable: true)] + private ?array $basicInsurance; + + #[ORM\Column(length: 30, nullable: true)] + private ?string $bloodType; + + #[ORM\Column(length: 50, nullable: true)] + private ?string $education; + + #[ORM\Column(length: 100, nullable: true)] + private ?string $fathersName; + + #[ORM\Column(length: 10, nullable: true)] + private ?string $gender; + + #[ORM\Column(length: 20, nullable: true)] + private ?string $homePhone; + + #[ORM\Column(length: 100, nullable: true)] + private ?string $job; + + #[ORM\Column(length: 30, nullable: true)] + private ?string $maritalStatus; + + #[ORM\Column(length: 50, nullable: true)] + private ?string $supplementaryInsurance; + + #[ORM\Column(length: 20, nullable: true)] + private ?string $workPhone; + + #[ORM\Column(type: 'text', nullable: true)] + private ?string $address; + + // اطلاعات پزشکی پیچیده به صورت JSON + #[ORM\Column(type: 'json', nullable: true)] + private ?array $diseases; + + #[ORM\Column(type: 'json', nullable: true)] + private ?array $allergies; + + #[ORM\Column(type: 'json', nullable: true)] + private ?array $medications; + + #[ORM\Column(type: 'json', nullable: true)] + private ?array $surgeries; + + #[ORM\Column(type: 'json', nullable: true)] + private ?array $familyHistory; + + #[ORM\Column(type: 'json', nullable: true)] + private ?array $relatives; + + // TimestampableTrait +} +``` diff --git a/docs/tasks/task-03-user-profile/database.md b/docs/tasks/task-03-user-profile/database.md new file mode 100644 index 00000000..a507f02a --- /dev/null +++ b/docs/tasks/task-03-user-profile/database.md @@ -0,0 +1,109 @@ +# پایگاه داده — تسک ۰۳: ماژول پروفایل کاربر + +## جدول: profiles +_(entity_type=profile — از DB backup و config تأیید شده)_ + +| ستون | نوع | نام Drupal | توضیح | +|------|-----|-----------|-------| +| id | INT UNSIGNED AUTO_INCREMENT PK | id | | +| uuid | CHAR(36) UNIQUE NOT NULL | uuid | | +| user_id | INT FK → users.id UNIQUE NOT NULL | uid | کاربر مرتبط | +| label | VARCHAR(255) NULL | label | نام نمایشی پروفایل | +| family | VARCHAR(25) NULL | field_family | نام خانوادگی (max 25) | +| fathers_name | VARCHAR(255) NULL | field_fathers_name | نام پدر | +| national_code | VARCHAR(10) NULL | field_national_code | کد ملی (max 10) | +| national_code_approved | TINYINT(1) DEFAULT 0 | field_national_code_approved | کد ملی تأیید شده | +| gender | VARCHAR(10) NULL | field_gender | `male` یا `female` | +| date_of_birth | INT NULL | field_date_of_birth | تاریخ تولد (Unix timestamp) | +| blood_type | VARCHAR(20) NULL | field_blood_type | گروه خونی | +| marital_status | VARCHAR(30) NULL | field_marital_status | وضعیت تأهل | +| education | VARCHAR(100) NULL | field_education | تحصیلات | +| job | VARCHAR(100) NULL | field_job | شغل | +| address | LONGTEXT NULL | field_address | آدرس | +| home_phone | VARCHAR(30) NULL | field_home_phone | تلفن منزل | +| work_phone | VARCHAR(30) NULL | field_work_phone | تلفن کار | +| insurance_id | VARCHAR(50) NULL | field_insurance_id | شماره بیمه | +| basic_insurance_id | INT FK → categories.id NULL | field_basic_insurance | entity ref → category (بیمه پایه) | +| supplementary_insurance_id | INT FK → categories.id NULL | field_supplementary_insurance | entity ref → category (بیمه تکمیلی) | +| other | LONGTEXT NULL | field_other | سایر اطلاعات | +| sharing_with_user | TINYINT(1) DEFAULT 0 | field_sharing_with_user | اشتراک با کاربر دیگر | +| description | LONGTEXT NULL | description | توضیحات (base field) | +| created_at | INT NOT NULL | created | Unix timestamp | +| updated_at | INT NOT NULL | changed | Unix timestamp | + +## مقادیر field_gender (profile — از config) +``` +male → آقا +female → خانم +``` + +## مقادیر field_blood_type (از config) +``` +a_positive → A+ a_negative → A- +b_positive → B+ b_negative → B- +o_positive → O+ o_negative → O- +ab_positive → AB+ ab_negative → AB- +``` + +## مقادیر field_education (از Manual) +``` +diploma → دیپلم +postgraduate_diploma → فوق دیپلم +bachelor_s_degree → لیسانس +master_s_degree → فوق لیسانس +doctorate → دکترا +``` + +## ساختار field_other (JSON — از Manual و نمونه Request واقعی) + +```json +{ + "disease": [ + { "id": 1, "name": "فشار خون", "status": "false" }, + { "id": 2, "name": "دیابت", "status": "false" } + ], + "allergies": [ + { "substance": "پنی‌سیلین", "reaction": "کهیر", "severity": "شدید" } + ], + "medications": [ + { "name": "لورازپام", "dose": "1mg", "frequency": "شب‌ها قبل خواب" } + ], + "surgeries": [ + { "type": "آپاندکتومی", "year": 2015, "hospital": "بیمارستان امام خمینی" } + ], + "family_history": [ + { "relation": "پدر", "disease": "فشار خون" } + ], + "relatives": [ + { + "name": "علی", + "relation": "پسر عمو", + "contact": { "phone": "+989121234567", "email": "...", "address": "..." } + } + ] +} +``` + +## ایندکس‌ها +```sql +CREATE UNIQUE INDEX idx_profiles_user ON profiles(user_id); +CREATE INDEX idx_profiles_national_code ON profiles(national_code); +``` + +## رابطه +- `profiles.user_id` → `users.id` (OneToOne, CASCADE DELETE) +- `profiles.basic_insurance_id` → `categories.id` +- `profiles.supplementary_insurance_id` → `categories.id` + +## نمونه داده واقعی از DB backup +``` +id=1, uid=10, label='hamed', status=1 +id=3, uid=32, label='hamed', status=1 +``` + +## نکات مهم +- `field_family` VARCHAR(25) است — نام خانوادگی کوتاه ذخیره می‌شود +- `field_national_code` VARCHAR(10) است — کد ملی ۱۰ رقمی +- `date_of_birth` از نوع timestamp (INT) است، نه DATE +- `basic_insurance` و `supplementary_insurance` entity reference به جدول categories هستند +- `field_sharing_with_user` احتمالاً برای اشتراک پروفایل با دکتر/منشی است diff --git a/docs/tasks/task-03-user-profile/implementation_notes.md b/docs/tasks/task-03-user-profile/implementation_notes.md new file mode 100644 index 00000000..422f8594 --- /dev/null +++ b/docs/tasks/task-03-user-profile/implementation_notes.md @@ -0,0 +1,67 @@ +# نکات پیاده‌سازی — تسک ۰۳: ماژول پروفایل کاربر + +## جریان بعد از ذخیره پروفایل +طبق مستندات Drupal، بعد از ذخیره پروفایل باید: +1. `POST /oauth/token` با refresh_token اجرا شود (دریافت access_token جدید) +2. `GET /oauth/userinfo` اجرا شود + +در Symfony این جریان در سمت **کلاینت** انجام می‌شود، نه سرور. +→ در پاسخ POST /api/v1/user-profile، token های به‌روز شده نیز برگردان: +```json +{ + "data": { + "profile": { "uuid": "...", "name": "..." }, + "access_token": "eyJ...", + "refresh_token": "eyJ..." + }, + "message": "پروفایل با موفقیت ذخیره شد" +} +``` + +## اعتبارسنجی blood_type +مقادیر مجاز: +```php +#[Assert\Choice(choices: [ + 'a_positive', 'a_negative', + 'b_positive', 'b_negative', + 'ab_positive', 'ab_negative', + 'o_positive', 'o_negative' +])] +``` + +## اعتبارسنجی gender +```php +#[Assert\Choice(choices: ['male', 'female', 'other'])] +``` + +## اعتبارسنجی education +```php +#[Assert\Choice(choices: [ + 'primary', 'secondary', 'diploma', + 'associate', 'bachelor', 'master', + 'postgraduate_diploma', 'doctorate' +])] +``` + +## Partial Update (PATCH) +endpoint PATCH باید فقط فیلدهایی که ارسال شده را آپدیت کند. +→ از `$request->request->has('field')` یا DTO با nullable fields استفاده کن. +→ مثال: اگر فقط `diseases` در body باشد، بقیه فیلدها تغییر نکنند. + +## مجوزها +``` +POST /api/v1/user-profile → کاربر احراز هویت‌شده (برای خودش) +GET /api/v1/user-profile/{uuid} → owner یا ROLE_ADMIN یا ROLE_DOCTOR (دکتر مرتبط) +PATCH /api/v1/user-profile/{uuid} → owner یا ROLE_ADMIN +DELETE /api/v1/user-profile/{uuid} → فقط ROLE_ADMIN +``` + +## نکته UUID در URL +در Drupal از UUID واقعی در URL استفاده می‌شد. +در Symfony نیز همان رویکرد حفظ می‌شود. +ParamConverter می‌تواند UUID را به Entity تبدیل کند: +```php +#[Route('/api/v1/user-profile/{uuid}', methods: ['GET'])] +public function get(UserProfile $userProfile): Response +// Doctrine ParamConverter به طور خودکار uuid را به UserProfile تبدیل می‌کند +``` diff --git a/docs/tasks/task-03-user-profile/task.md b/docs/tasks/task-03-user-profile/task.md new file mode 100644 index 00000000..c5272183 --- /dev/null +++ b/docs/tasks/task-03-user-profile/task.md @@ -0,0 +1,60 @@ +# تسک ۰۳: ماژول پروفایل کاربر + +## توضیح +پیاده‌سازی CRUD پروفایل پزشکی کاربر شامل اطلاعات شخصی، +سابقه بیماری، آلرژی‌ها، داروها، عمل‌های جراحی، سابقه خانوادگی و بستگان. + +## Endpoint ها + +| متد | مسیر | توضیح | نیاز به Auth | +|-----|------|-------|-------------| +| POST | `/api/v1/user-profile` | ایجاد پروفایل | بله | +| GET | `/api/v1/user-profile/{uuid}` | دریافت پروفایل | بله | +| PATCH | `/api/v1/user-profile/{uuid}` | ویرایش پروفایل | بله (Owner/Admin) | +| DELETE | `/api/v1/user-profile/{uuid}` | حذف پروفایل | بله (Admin) | + +## پیش‌نیازها +- تسک ۰۱ و ۰۲ کامل شده باشند + +## خروجی‌های مورد انتظار +- [ ] Entity پروفایل با تمام فیلدها +- [ ] پشتیبانی از JSON column برای داده‌های پزشکی پیچیده +- [ ] بعد از ذخیره پروفایل: refresh token و get userinfo اجرا می‌شود +- [ ] فقط owner یا admin می‌تواند پروفایل را ببیند/ویرایش کند + +## زمان تخمینی +۸ تا ۱۰ ساعت + +## نمونه Request + +### POST /api/v1/user-profile +```json +{ + "name": "علی رضایی", + "description": [{ "value": "متن توضیحات", "format": "basic_html" }], + "birthday": "1370-05-15", + "basic_insurance": [251], + "blood_type": "ab_negative", + "education": "postgraduate_diploma", + "fathers_name": "محمد", + "gender": "male", + "home_phone": "07433332178", + "job": "مهندس", + "marital_status": "married", + "supplementary_insurance": "308", + "work_phone": "07433332178", + "address": "تهران، خیابان ولیعصر", + "other": [{ + "disease": [{ "id": 1, "name": "فشار خون", "status": "true" }], + "allergies": [{ "substance": "پنی‌سیلین", "reaction": "کهیر", "severity": "شدید" }], + "medications": [{ "name": "آتنولول", "dose": "50mg", "frequency": "صبح‌ها" }], + "surgeries": [{ "type": "آپاندکتومی", "year": 2015, "hospital": "بیمارستان امام خمینی" }], + "family_history": [{ "relation": "پدر", "disease": "فشار خون" }], + "relatives": [{ + "name": "سارا", + "relation": "خواهر", + "contact": { "phone": "09121234567", "email": "sara@example.com", "address": "تهران" } + }] + }] +} +``` diff --git a/docs/tasks/task-04-blog/architecture.md b/docs/tasks/task-04-blog/architecture.md new file mode 100644 index 00000000..40e7901f --- /dev/null +++ b/docs/tasks/task-04-blog/architecture.md @@ -0,0 +1,70 @@ +# معماری — تسک ۰۴: ماژول بلاگ + +## ساختار فایل‌ها +``` +src/Module/Blog/ +├── Controller/ +│ ├── BlogController.php ← CRUD بلاگ +│ └── BlogImageController.php ← آپلود تصویر +├── Service/ +│ ├── BlogService.php +│ └── ImageUploadService.php +├── Repository/ +│ └── BlogRepository.php +├── Entity/ +│ └── Blog.php +├── DTO/ +│ ├── Request/ +│ │ ├── CreateBlogRequest.php +│ │ └── UpdateBlogRequest.php +│ └── Response/ +│ ├── BlogResponse.php +│ └── BlogListResponse.php +└── Voter/ + └── BlogVoter.php +``` + +## Entity: Blog +```php +#[ORM\Entity] +#[ORM\Table(name: 'blogs')] +class Blog +{ + #[ORM\Id, ORM\GeneratedValue, ORM\Column] + private int $id; + + #[ORM\Column(type: UuidType::NAME, unique: true)] + private Uuid $uuid; + + #[ORM\ManyToOne(targetEntity: User::class)] + #[ORM\JoinColumn(nullable: false)] + private User $author; + + #[ORM\Column(length: 300)] + private string $title; + + #[ORM\Column(length: 300, unique: true)] + private string $slug; + + #[ORM\Column(type: 'text')] + private string $body; + + #[ORM\Column(type: 'text', nullable: true)] + private ?string $summary; + + #[ORM\Column(length: 20, default: 'draft')] + private string $status; // draft, published, archived + + #[ORM\Column(type: 'integer', default: 0)] + private int $viewCount = 0; + + #[ORM\Column(length: 255, nullable: true)] + private ?string $imagePath; + + #[ORM\ManyToMany(targetEntity: Category::class)] + #[ORM\JoinTable(name: 'blog_tags')] + private Collection $tags; + + // TimestampableTrait +} +``` diff --git a/docs/tasks/task-04-blog/database.md b/docs/tasks/task-04-blog/database.md new file mode 100644 index 00000000..d470c859 --- /dev/null +++ b/docs/tasks/task-04-blog/database.md @@ -0,0 +1,50 @@ +# پایگاه داده — تسک ۰۴: ماژول بلاگ + +## ساختار واقعی از DB backup + +جدول `blog` در Drupal base fields را دارد + ۳ custom field: + +## جدول: blogs +_(entity_type=blog — از DB backup تأیید شده)_ + +| ستون | نوع | نام Drupal | توضیح | +|------|-----|-----------|-------| +| id | INT UNSIGNED AUTO_INCREMENT PK | id | | +| uuid | CHAR(36) UNIQUE NOT NULL | uuid | | +| user_id | INT FK → users.id NOT NULL | uid | نویسنده | +| title | VARCHAR(255) NULL | label | عنوان (base field Drupal) | +| slug | VARCHAR(300) UNIQUE NOT NULL | — | اضافه‌شده در Symfony (در Drupal نیست!) | +| body | LONGTEXT NULL | description__value | محتوا (HTML) | +| status | TINYINT(1) DEFAULT 0 | status | منتشرشده/پیش‌نویس | +| is_top | TINYINT(1) DEFAULT 0 | field_top | نمایش در صفحه اول | +| image_id | INT FK → files.id NULL | field_image | تصویر شاخص (entity ref → file) | +| created_at | INT NOT NULL | created | Unix timestamp | +| updated_at | INT NOT NULL | changed | Unix timestamp | + +## جدول: blog_tags (ManyToMany) +| ستون | نوع | نام Drupal | توضیح | +|------|-----|-----------|-------| +| blog_id | INT FK → blogs.id CASCADE | | | +| category_id | INT FK → categories.id CASCADE | field_tag | تگ/دسته‌بندی (entity ref → category/tag bundle) | + +## نمونه داده واقعی از DB backup +``` +id=14, label='روش های خانگی محافظت از پوست در تابستان', uid=22, status=1 +id=15, 16, ... (22 مطلب) +field_image: target_id=97 (file entity) +``` + +## ایندکس‌ها +```sql +CREATE INDEX idx_blogs_status ON blogs(status); +CREATE INDEX idx_blogs_user ON blogs(user_id); +CREATE INDEX idx_blogs_created ON blogs(created_at DESC); +CREATE INDEX idx_blogs_top ON blogs(is_top); +CREATE UNIQUE INDEX idx_blogs_slug ON blogs(slug); +``` + +## نکات مهم +- `slug` در Drupal وجود ندارد — در Symfony باید auto-generate شود از `title` +- `image` در Drupal یک entity reference به file/media است — در Symfony مسیر فایل ذخیره می‌شود +- `body` در Drupal با نام `description__value` ذخیره می‌شود (base field) +- `status=1` = منتشرشده، `status=0` = پیش‌نویس diff --git a/docs/tasks/task-04-blog/implementation_notes.md b/docs/tasks/task-04-blog/implementation_notes.md new file mode 100644 index 00000000..f1a51630 --- /dev/null +++ b/docs/tasks/task-04-blog/implementation_notes.md @@ -0,0 +1,139 @@ +# نکات پیاده‌سازی — تسک ۰۴: ماژول بلاگ + +## نگاشت فیلدهای Request → Response (مهم!) + +در Drupal نام فیلدهای **ارسالی** با نام فیلدهای **دریافتی** متفاوت است: + +| فیلد در Request | فیلد در Response | توضیح | +|----------------|-----------------|-------| +| `label` | `title` | عنوان مقاله | +| `description` (string) | `body: {value, format}` | متن مقاله — در response به object تبدیل می‌شود | +| `field_image: [{target_id}]` | `images: [{url, fid, filename, filemime, filesize}]` | تصاویر — ID ارسال، object دریافت | +| — | `author` | نام نویسنده (computed از realname کاربر) | +| — | `uuid` | شناسه یکتا | +| — | `status` | وضعیت انتشار (string: "1") | +| — | `created` / `changed` | Unix timestamp به صورت string | + +## فرمت واقعی Response بلاگ (از سرور Drupal — endpoint 93) +```json +{ + "uuid": "95f6acb0-3331-4141-801e-004e8460edac", + "title": "روش های خانگی محافظت از پوست در تابستان", + "status": "1", + "body": { + "value": "متن کامل مقاله...", + "format": "full_html" + }, + "created": "1763536163", + "changed": "1763537699", + "author": "single doctor", + "images": [ + { + "url": "https://domain.com/sites/default/files/blog/image.png", + "fid": "97", + "filename": "image.png", + "filemime": "image/png", + "filesize": 320787 + } + ], + "tag": [ + { + "uuid": "24926497-fc2d-47d7-82ae-26cbbc4d6468", + "id": "2501", + "name": "مجله" + }, + { + "uuid": "6c6a488b-1051-43a2-887b-0ec7a05e52d5", + "id": "2500", + "name": "سلامتی" + } + ] +} +``` + +> **نکات مهم response:** +> - `body` یک **object** است با کلیدهای `value` و `format` — نه string ساده! +> - `format` معمولاً `"full_html"` یا `"basic_html"` است +> - `author` از `realname` کاربر نویسنده computed می‌شود +> - `created` و `changed` به صورت **string** برگردانده می‌شوند (نه integer) +> - `status` به صورت string `"1"` برگردانده می‌شود (نه boolean) +> - `images` و `tag` ممکن است آرایه خالی `[]` باشند +> - response دارای `id` نیست — فقط `uuid` + +## فرمت Request ایجاد/ویرایش بلاگ + +### POST /api/v1/blog/ +```json +{ + "label": "عنوان مقاله", + "description": "متن کامل مقاله...", + "field_image": [ + { "target_id": 97 }, + { "target_id": 98 } + ] +} +``` + +### PATCH /api/v1/blog/{uuid} +```json +{ + "label": "عنوان ویرایش‌شده", + "description": "متن ویرایش‌شده" +} +``` + +## آپلود تصویر بلاگ (endpoint 99) +قبل از ایجاد بلاگ، تصویر باید آپلود شود و `fid` آن در `field_image` استفاده شود: + +``` +POST /file/upload/blog/blog/field_image +Headers: + Content-Type: application/octet-stream + Content-Disposition: file; filename="image.png" + X-CSRF-Token: {token} + Authorization: Bearer {token} +Response → fid که در field_image استفاده می‌شود +``` + +## تولید Slug +- از عنوان فارسی slug تولید کن +- پکیج `cocur/slugify` را نصب کن: + ```bash + ddev composer require cocur/slugify + ``` +- اگر slug تکراری بود، عدد به انتهای آن اضافه کن: `rahnamai-diabet-2` + +## بلاگ‌های برتر (Top Blogs) +- endpoint: `GET /api/v1/blogs/top` (نیاز به auth دارد) +- پاسخ: آرایه مستقیم (نه object با pagination) از بلاگ‌هایی که `is_top = 1` هستند +- فرمت هر آیتم دقیقاً مشابه response معمولی بلاگ است + +## لیست بلاگ‌ها با فیلتر +``` +GET /api/v1/blogs?page=1&limit=10&title=نشانه&tag=3637 +``` +- `title`: جستجو در عنوان +- `tag`: فیلتر بر اساس ID تگ + +## Pagination +```php +$offset = ($page - 1) * $limit; +// در Repository با limit/offset +``` + +## مجوزها +``` +POST → احراز هویت الزامی (ROLE_DOCTOR یا ROLE_ADMIN) +PATCH → احراز هویت الزامی (owner یا ROLE_ADMIN) +DELETE → احراز هویت الزامی (owner یا ROLE_ADMIN) +GET → عمومی (بدون auth) +GET /api/v1/blogs/top → احراز هویت الزامی +``` + +## بهینه‌سازی +- view_count با یک query atomic آپدیت کن تا race condition نباشد: + ```php + $this->em->createQuery('UPDATE Blog b SET b.viewCount = b.viewCount + 1 WHERE b.id = :id') + ->setParameter('id', $blog->getId()) + ->execute(); + ``` diff --git a/docs/tasks/task-04-blog/task.md b/docs/tasks/task-04-blog/task.md new file mode 100644 index 00000000..ada356a0 --- /dev/null +++ b/docs/tasks/task-04-blog/task.md @@ -0,0 +1,71 @@ +# تسک ۰۴: ماژول بلاگ + +## توضیح +پیاده‌سازی سیستم مدیریت مقالات (بلاگ) شامل ایجاد، ویرایش، حذف، +نمایش لیست، بلاگ‌های برتر و آپلود تصویر. + +## Endpoint ها + +| متد | مسیر | توضیح | نیاز به Auth | +|-----|------|-------|-------------| +| POST | `/api/v1/blog/` | ایجاد بلاگ جدید | بله (Admin/Doctor) | +| PATCH | `/api/v1/blog/{uuid}` | ویرایش بلاگ | بله (Owner/Admin) | +| DELETE | `/api/v1/blog/{uuid}` | حذف بلاگ | بله (Owner/Admin) | +| GET | `/api/v1/blog/{uuid}` | دریافت یک بلاگ | خیر | +| GET | `/api/v1/blogs` | لیست بلاگ‌ها (با pagination) | خیر | +| GET | `/api/v1/blogs/top` | بلاگ‌های برتر | خیر | +| POST | `/api/v1/blog/image` | آپلود تصویر بلاگ | بله | + +## پیش‌نیازها +- تسک ۰۱ و ۰۲ + +## خروجی‌های مورد انتظار +- [ ] CRUD کامل برای بلاگ +- [ ] pagination در لیست +- [ ] آپلود و ذخیره تصویر +- [ ] بلاگ‌های برتر (براساس بازدید یا لایک) +- [ ] slug برای SEO + +## زمان تخمینی +۶ تا ۸ ساعت + +## نمونه Request/Response + +### POST /api/v1/blog/ +```json +// Request +{ + "title": "راهنمای کامل دیابت", + "body": "

محتوای مقاله...

", + "summary": "خلاصه مقاله", + "tags": [1, 2, 3], + "status": "published", + "image_uuid": "abc-..." +} + +// Response 201 +{ + "data": { + "uuid": "72522a1d-...", + "title": "راهنمای کامل دیابت", + "slug": "rahnamai-kamel-diabet", + "status": "published", + "created_at": "2024-01-01T00:00:00Z" + } +} +``` + +### GET /api/v1/blogs +``` +Query params: page=1&limit=10&category=1&tag=2 +``` + +### GET /api/v1/blogs/top +```json +// Response +{ + "data": [ + { "uuid": "...", "title": "...", "views": 1250, "image": "..." } + ] +} +``` diff --git a/docs/tasks/task-05-doctor/architecture.md b/docs/tasks/task-05-doctor/architecture.md new file mode 100644 index 00000000..58b86561 --- /dev/null +++ b/docs/tasks/task-05-doctor/architecture.md @@ -0,0 +1,176 @@ +# معماری — تسک ۰۵: ماژول دکتر + +## ساختار فایل‌ها +``` +src/Module/Doctor/ +├── Controller/ +│ ├── DoctorController.php ← CRUD دکتر + لیست + جستجو +│ ├── DoctorImageController.php ← آپلود تصویر +│ └── DoctorAddressController.php ← CRUD آدرس مطب +├── Service/ +│ ├── DoctorService.php +│ └── DoctorAddressService.php +├── Repository/ +│ ├── DoctorRepository.php +│ └── DoctorAddressRepository.php +├── Entity/ +│ ├── Doctor.php +│ └── DoctorAddress.php +├── DTO/ +│ ├── Request/ +│ │ ├── CreateDoctorRequest.php +│ │ ├── UpdateDoctorRequest.php +│ │ ├── DoctorFilterRequest.php +│ │ ├── CreateDoctorAddressRequest.php +│ │ └── UpdateDoctorAddressRequest.php +│ └── Response/ +│ ├── DoctorResponse.php ← view کامل با آمار +│ ├── DoctorListItemResponse.php ← لیست/جستجو +│ └── DoctorAddressResponse.php +└── Voter/ + └── DoctorVoter.php +``` + +## Entity: Doctor (بر اساس فیلدهای واقعی Drupal) +```php +#[ORM\Entity] +#[ORM\Table(name: 'doctors')] +class Doctor +{ + #[ORM\Id, ORM\GeneratedValue, ORM\Column] + private int $id; + + #[ORM\Column(type: UuidType::NAME, unique: true)] + private Uuid $uuid; + + #[ORM\OneToOne(targetEntity: User::class)] + #[ORM\JoinColumn(nullable: false)] + private User $user; // uid در Drupal + + #[ORM\Column(length: 100)] + private string $name; // field_name + + #[ORM\Column(length: 10, nullable: true)] + private ?string $gender; // field_gender + + #[ORM\Column(length: 50)] + private string $medicalSystemCode; // field_doctor_id (شماره نظام پزشکی) + + #[ORM\Column(type: 'integer', nullable: true)] + private ?int $activityTime; // field_activity_time (timestamp سال شروع) + + #[ORM\Column(length: 100, nullable: true)] + private ?string $degree; // field_degree + + #[ORM\Column(type: 'text', nullable: true)] + private ?string $info; // field_info + + #[ORM\Column(length: 255, nullable: true)] + private ?string $imagePath; // field_image (media) + + #[ORM\Column(type: 'decimal', precision: 3, scale: 1, options: ['default' => 3.5])] + private float $doctorRate = 3.5; // field_doctor_rate + + #[ORM\Column(type: 'decimal', precision: 5, scale: 1, options: ['default' => 60])] + private float $doctorRatePercentage = 60; // field_doctor_rate_percentage + + #[ORM\Column(type: 'boolean', options: ['default' => true])] + private bool $activeDoctorAppointment = true; // field_active_doctor_appointmen + + #[ORM\ManyToOne(targetEntity: Representation::class)] + #[ORM\JoinColumn(nullable: true)] + private ?Representation $representation; // field_representation (multi-tenant) + + // ManyToMany relations (field_specialty, field_doctor_services, field_state, field_city) + #[ORM\ManyToMany(targetEntity: Category::class)] + #[ORM\JoinTable(name: 'doctor_specialties')] + private Collection $specialties; // field_specialty (چند مقداری) + + #[ORM\ManyToMany(targetEntity: Category::class)] + #[ORM\JoinTable(name: 'doctor_services')] + private Collection $services; // field_doctor_services (چند مقداری) + + #[ORM\ManyToMany(targetEntity: Category::class)] + #[ORM\JoinTable(name: 'doctor_states')] + private Collection $states; // field_state + + #[ORM\ManyToMany(targetEntity: Category::class)] + #[ORM\JoinTable(name: 'doctor_cities')] + private Collection $cities; // field_city + + #[ORM\OneToMany(targetEntity: DoctorAddress::class, mappedBy: 'doctor')] + private Collection $addresses; + + // TimestampableTrait +} +``` + +## Entity: DoctorAddress (بر اساس فیلدهای واقعی Drupal) +```php +#[ORM\Entity] +#[ORM\Table(name: 'doctor_addresses')] +class DoctorAddress +{ + #[ORM\Id, ORM\GeneratedValue, ORM\Column] + private int $id; + + #[ORM\Column(type: UuidType::NAME, unique: true)] + private Uuid $uuid; + + #[ORM\ManyToOne(targetEntity: Doctor::class, inversedBy: 'addresses')] + #[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')] + private Doctor $doctor; // field_doctor + + #[ORM\Column(length: 100, nullable: true)] + private ?string $name; // field_name (نام مطب/شعبه) + + #[ORM\Column(type: 'text', nullable: true)] + private ?string $address; // field_address + + #[ORM\Column(length: 20, nullable: true)] + private ?string $telephone; // field_telephone (نه phone!) + + #[ORM\Column(type: 'decimal', precision: 10, scale: 8, nullable: true)] + private ?float $latitude; // field_latitude + + #[ORM\Column(type: 'decimal', precision: 11, scale: 8, nullable: true)] + private ?float $longitude; // field_longitude + + // TimestampableTrait +} +``` + +## Response کامل دکتر (finalizeData) +```json +{ + "id": 5, "uuid": "...", + "name": "دکتر محمدی", + "gender": "male", + "experience": 12, + "activity_time": 1388534400, + "medical_system_code": "12345", + "detail": "متخصص قلب و عروق...", + "degree": "دکترای تخصصی", + "specialties": [{"id": 3, "uuid": "...", "name": "قلب و عروق"}], + "img": ["https://..."], + "expertise": [{"id": 10, "uuid": "...", "name": "اکوکاردیوگرافی"}], + "satisfaction": 87.5, + "point": 4.4, + "free_turn": "اولین نوبت آزاد: دوشنبه ۱۲ اردیبهشت ساعت ۱۰:۳۰", + "hours_of_work": ["شنبه", "یکشنبه", "دوشنبه"], + "address": [...], + "average_rate": {"average_stars": 4.3, "total_rates": 87}, + "state": [{"id": 2, "uuid": "...", "name": "فارس"}], + "city": [{"id": 5, "uuid": "...", "name": "شیراز"}] +} +``` + +## منطق create دکتر (از DoctorService.php) +۱. اگر `doctor_mobile_number` داده شد: + - کاربر با این موبایل را پیدا کن + - اگر نقش `doctor` ندارد → 403 + - اگر قبلاً profile دکتر دارد → 403 + - اگر کاربر وجود ندارد → کاربر جدید با نقش doctor بساز +۲. اگر `doctor_mobile_number` نداده شد → کاربر جاری استفاده شود +۳. اعتبارسنجی همه فیلدهای اجباری +۴. ایجاد entity diff --git a/docs/tasks/task-05-doctor/database.md b/docs/tasks/task-05-doctor/database.md new file mode 100644 index 00000000..e2bdb2bf --- /dev/null +++ b/docs/tasks/task-05-doctor/database.md @@ -0,0 +1,160 @@ +# پایگاه داده — تسک ۰۵: ماژول دکتر + +## جدول: doctors +_(entity_type=clinic_pro, bundle=doctor — از DB backup و config تأیید شده)_ + +| ستون | نوع | نام Drupal | توضیح | +|------|-----|-----------|-------| +| id | INT UNSIGNED AUTO_INCREMENT PK | id | | +| uuid | CHAR(36) UNIQUE NOT NULL | uuid | | +| user_id | INT FK → users.id UNIQUE NOT NULL | uid | کاربر صاحب پروفایل | +| name | VARCHAR(255) NOT NULL | field_name | نام دکتر | +| gender | VARCHAR(10) NULL | field_gender | `woman` یا `man` | +| medical_system_code | VARCHAR(25) NULL | field_doctor_id | شماره نظام پزشکی (max 25) | +| mobile_number | VARCHAR(15) NULL | field_doctor_mobile_number | شماره موبایل دکتر (max 15) | +| activity_time | INT NULL | field_activity_time | timestamp سال شروع فعالیت | +| degree | VARCHAR(30) NULL | field_degree | مدرک (مقادیر زیر) | +| info | LONGTEXT NULL | field_info | بیوگرافی | +| image_path | VARCHAR(255) NULL | field_image | تصویر (media) | +| doctor_rate | FLOAT NULL | field_doctor_rate | امتیاز ستاره‌ای (default: 3.5) | +| doctor_rate_percentage | FLOAT NULL | field_doctor_rate_percentage | درصد رضایت (default: 60) | +| active_doctor_appointment | TINYINT(1) DEFAULT 1 | field_active_doctor_appointmen | نوبت‌دهی فعال (بدون حرف 't') | +| representation_id | INT FK → representations.id NULL | field_representation | entity ref → clinic_pro | +| created_at | INT NOT NULL | created | Unix timestamp | +| updated_at | INT NOT NULL | changed | Unix timestamp | + +## مقادیر field_degree (از config) +``` +expert → کارشناس +general → پزشک عمومی +specialist → پزشک متخصص +subspecialistplus → پزشک فوق تخصص +``` + +## مقادیر field_gender (از config) +``` +woman → زن +man → مرد +``` + +## جدول: doctor_specialties (ManyToMany pivot) +| ستون | نوع | توضیح | +|------|-----|-------| +| doctor_id | INT FK → doctors.id | | +| category_id | INT FK → categories.id | | +| PRIMARY KEY (doctor_id, category_id) | | field_specialty → cardinality=2 (حداکثر ۲ تخصص) | + +## جدول: doctor_services (ManyToMany pivot) +| ستون | نوع | توضیح | +|------|-----|-------| +| doctor_id | INT FK → doctors.id | | +| category_id | INT FK → categories.id | | +| PRIMARY KEY (doctor_id, category_id) | | field_doctor_services → cardinality=8 (حداکثر ۸ سرویس) | + +## جدول: doctor_states (ManyToMany pivot) +| ستون | نوع | توضیح | +|------|-----|-------| +| doctor_id | INT FK → doctors.id | | +| category_id | INT FK → categories.id | | +| PRIMARY KEY (doctor_id, category_id) | | field_state → cardinality=1 | + +## جدول: doctor_cities (ManyToMany pivot) +| ستون | نوع | توضیح | +|------|-----|-------| +| doctor_id | INT FK → doctors.id | | +| category_id | INT FK → categories.id | | +| PRIMARY KEY (doctor_id, category_id) | | field_city → cardinality=1 | + +## جدول: doctor_addresses +_(entity_type=clinic_pro, bundle=doctors_addresses — از DB backup)_ + +| ستون | نوع | نام Drupal | توضیح | +|------|-----|-----------|-------| +| id | INT UNSIGNED AUTO_INCREMENT PK | id | | +| uuid | CHAR(36) UNIQUE NOT NULL | uuid | | +| doctor_id | INT FK → doctors.id CASCADE | field_doctor | دکتر | +| name | VARCHAR(255) NULL | field_name | نام مطب/آدرس | +| address | LONGTEXT NULL | field_address | آدرس کامل | +| telephone | VARCHAR(50) NULL | field_telephone | تلفن مطب | +| latitude | FLOAT NULL | field_latitude | عرض جغرافیایی | +| longitude | FLOAT NULL | field_longitude | طول جغرافیایی | +| created_at | INT NOT NULL | created | Unix timestamp | +| updated_at | INT NOT NULL | changed | Unix timestamp | + +## جدول: clinics +_(entity_type=clinic_pro, bundle=clinic — از config تأیید شده)_ + +| ستون | نوع | نام Drupal | توضیح | +|------|-----|-----------|-------| +| id | INT UNSIGNED AUTO_INCREMENT PK | id | | +| uuid | CHAR(36) UNIQUE NOT NULL | uuid | | +| user_id | INT FK → users.id NOT NULL | uid | مالک | +| name | VARCHAR(255) NULL | field_name | نام کلینیک | +| info | LONGTEXT NULL | field_info | توضیحات | +| address | LONGTEXT NULL | field_address | آدرس | +| telephone | VARCHAR(50) NULL | field_telephone | تلفن | +| is_24_7 | TINYINT(1) DEFAULT 0 | field_24_7 | باز ۲۴/۷ | +| working_days | VARCHAR(255) NULL | field_working_days | روزهای کاری (متن آزاد، مثال: 'شنبه تا سه شنبه ساعت ۱۲:۲۰') | +| latitude | FLOAT NULL | field_latitude | | +| longitude | FLOAT NULL | field_longitude | | +| city_id | INT FK → categories.id NULL | field_city | | +| state_id | INT FK → categories.id NULL | field_state | | +| representation_id | INT FK → representations.id NULL | field_agent | نماینده کلینیک | +| created_at | INT NOT NULL | created | | +| updated_at | INT NOT NULL | changed | | + +جداول pivot مرتبط با clinic: +- `clinic_doctors` (clinic_id, doctor_id) → field_doctors (cardinality نامحدود) +- `clinic_specialties` (clinic_id, category_id) → field_clinic_specialty +- `clinic_services` (clinic_id, category_id) → field_doctor_services +- `clinic_insurances` (clinic_id, category_id) → field_insurance + +## جدول: doctor_secretaries +_(entity_type=clinic_pro, bundle=doctor_secretary — از config تأیید شده)_ + +| ستون | نوع | نام Drupal | توضیح | +|------|-----|-----------|-------| +| id | INT UNSIGNED AUTO_INCREMENT PK | id | | +| uuid | CHAR(36) UNIQUE NOT NULL | uuid | | +| user_id | INT FK → users.id NOT NULL | uid | | +| doctor_id | INT FK → doctors.id NOT NULL | field_doctor | entity ref → clinic_pro/doctor | +| secretary_id | INT FK → users.id NOT NULL | field_secretary | entity ref → user (منشی) | +| telephone | VARCHAR(50) NULL | field_telephone | تلفن | +| permission | LONGTEXT NULL | field_permission | مجوزها (JSON) | +| active | TINYINT(1) DEFAULT 1 | field_active | فعال/غیرفعال | +| created_at | INT NOT NULL | created | | +| updated_at | INT NOT NULL | changed | | + +## ایندکس‌ها +```sql +CREATE UNIQUE INDEX idx_doctors_user ON doctors(user_id); +CREATE INDEX idx_doctors_representation ON doctors(representation_id); +CREATE INDEX idx_doctors_active ON doctors(active_doctor_appointment); +CREATE INDEX idx_doctors_rate ON doctors(doctor_rate DESC); +CREATE INDEX idx_doctor_addresses_doctor ON doctor_addresses(doctor_id); +CREATE INDEX idx_clinics_user ON clinics(user_id); +CREATE UNIQUE INDEX idx_doctor_secretaries_doctor_secretary ON doctor_secretaries(doctor_id, secretary_id); +``` + +## نمونه داده واقعی از DB (clinic_pro table) +``` +id=18, bundle='doctor' +id=22, bundle='doctor' +id=23, bundle='clinic' +id=24, bundle='clinic' +id=27, bundle='doctor_secretary' +id=29, bundle='doctor' +id=38, bundle='doctors_addresses' +id=41, bundle='representation' +``` + +## روابط کامل clinic_pro entity +- `doctors` → `users` (ManyToOne, UNIQUE → OneToOne) +- `doctors` → `representations` (ManyToOne) +- `doctors` ↔ `categories` (ManyToMany: specialties, services, states, cities) +- `doctor_addresses` → `doctors` (ManyToOne, CASCADE) +- `clinics` → `users` (ManyToOne) +- `clinics` ↔ `doctors` (ManyToMany) +- `clinics` ↔ `categories` (ManyToMany) +- `doctor_secretaries` → `doctors` (ManyToOne) +- `doctor_secretaries` → `users` as secretary (ManyToOne) diff --git a/docs/tasks/task-05-doctor/implementation_notes.md b/docs/tasks/task-05-doctor/implementation_notes.md new file mode 100644 index 00000000..75afee53 --- /dev/null +++ b/docs/tasks/task-05-doctor/implementation_notes.md @@ -0,0 +1,64 @@ +# نکات پیاده‌سازی — تسک ۰۵: ماژول دکتر + +## فیلتر لیست دکترها +لیست دکترها باید فیلترهای زیر را پشتیبانی کند: +```php +// DoctorRepository.php +public function findFiltered(DoctorFilterRequest $filter): array +{ + $qb = $this->createQueryBuilder('d') + ->join('d.user', 'u'); + + if ($filter->specialty) { + $qb->andWhere('d.specialty = :specialty') + ->setParameter('specialty', $filter->specialty); + } + if ($filter->city) { + $qb->join('d.addresses', 'a') + ->andWhere('a.city = :city') + ->setParameter('city', $filter->city); + } + if ($filter->name) { + $qb->andWhere('u.firstName LIKE :name OR u.lastName LIKE :name') + ->setParameter('name', '%'.$filter->name.'%'); + } + if ($filter->insurance) { + $qb->andWhere('JSON_CONTAINS(d.insurances, :ins) = 1') + ->setParameter('ins', json_encode([$filter->insurance])); + } + + return $qb->getQuery()->getResult(); +} +``` + +## آپدیت میانگین امتیاز +وقتی یک rating جدید ثبت می‌شود (تسک ۱۲)، average_rating را آپدیت کن: +```php +// در RatingService (تسک ۱۲) +$this->em->createQuery( + 'UPDATE Doctor d SET d.averageRating = ( + SELECT AVG(r.score) FROM Rating r WHERE r.doctor = d + ), d.reviewCount = ( + SELECT COUNT(r.id) FROM Rating r WHERE r.doctor = d + ) WHERE d.id = :id' +)->setParameter('id', $doctor->getId())->execute(); +``` + +## آدرس مطب +- یک دکتر می‌تواند چندین آدرس مطب داشته باشد +- `{doctorId}` در endpoint لیست آدرس‌ها، UUID دکتر است (نه ID) +- دکتر می‌تواند آدرس‌های خودش را ویرایش/حذف کند + +## مجوزها +``` +POST /api/v1/doctor → ROLE_ADMIN +PATCH /api/v1/doctor/{uuid} → owner (دکتر خودش) یا ROLE_ADMIN +GET /api/v1/doctor/{uuid} → عمومی +GET /api/v1/doctors → عمومی + +POST doctor-address → دکتر احراز هویت‌شده (برای خودش) +PATCH doctor-address/{id} → owner یا ROLE_ADMIN +DELETE doctor-address/{id} → owner یا ROLE_ADMIN +GET doctor-address/{id} → عمومی +GET doctor-addresses/{doctorId} → عمومی +``` diff --git a/docs/tasks/task-05-doctor/task.md b/docs/tasks/task-05-doctor/task.md new file mode 100644 index 00000000..2a6dc165 --- /dev/null +++ b/docs/tasks/task-05-doctor/task.md @@ -0,0 +1,178 @@ +# تسک ۰۵: ماژول دکتر + +## توضیح +پیاده‌سازی مدیریت پروفایل دکترها، لیست دکترها با فیلتر، +آپلود تصویر پروفایل و مدیریت آدرس‌های مطب. + +## Endpoint ها (واقعی از Drupal) + +| متد | مسیر | توضیح | نیاز به Auth | +|-----|------|-------|-------------| +| POST | `/api/v1/doctor` | ایجاد پروفایل دکتر | بله | +| PATCH | `/api/v1/doctor/{uuid}` | ویرایش پروفایل دکتر | بله (Owner/Admin) | +| DELETE | `/api/v1/doctor/{uuid}` | حذف دکتر | بله (Admin) | +| GET | `/api/v1/doctor/{uuid}` | دریافت پروفایل کامل دکتر | خیر | +| GET | `/api/v1/doctors` | لیست دکترها با فیلتر | خیر | +| POST | `/file/upload/clinic_pro/doctor/field_image` | آپلود تصویر پروفایل دکتر | بله | +| GET | `/api/v1/clinic/doctor-list/{clinic_uuid}` | لیست دکترهای یک کلینیک | خیر | +| POST | `/api/v1/clinic-pro/doctor-address` | ایجاد آدرس مطب | بله | +| PATCH | `/api/v1/clinic-pro/doctor-address/{id}` | ویرایش آدرس | بله | +| DELETE | `/api/v1/clinic-pro/doctor-address/{id}` | حذف آدرس | بله | +| GET | `/api/v1/clinic-pro/doctor-address/{id}` | دریافت یک آدرس | بله | +| GET | `/api/v1/clinic-pro/doctor-addresses/{doctorId}` | لیست آدرس‌های دکتر | خیر | + +## پیش‌نیازها +- تسک ۰۱، ۰۲، ۰۸ (Categories برای تخصص و سرویس‌ها) + +## زمان تخمینی +۸ تا ۱۰ ساعت + +--- + +## نمونه واقعی Response — GET /api/v1/doctor/{uuid} + +```json +{ + "id": "29", + "uuid": "61be915b-595a-42e5-bca5-f80d22f4f14a", + "name": "single doctor", + "gender": "woman", + "experience": 21, + "activity_time": "1107808200", + "medical_system_code": "121212121212", + "detail": "test", + "degree": "specialist", + "specialties": [ + { + "uuid": "d60a269d-f7d7-4589-8eab-451e6f740d40", + "id": "603", + "name": "داخلی عمومی", + "parent": "602" + } + ], + "img": [ + { + "url": "https://domain.com/sites/default/files/doctors/2025-11/image.png", + "fid": "98", + "filename": "image.png", + "filemime": "image/png", + "filesize": 173665 + } + ], + "expertise": [ + { "uuid": "...", "id": "1601", "name": "معاینه و تشخیص پزشک متخصص" }, + { "uuid": "...", "id": "1602", "name": "ویزیت تخصصی" } + ], + "satisfaction": "60", + "point": "3.5", + "free_turn": "اولین نوبت آزاد: سه‌شنبه 19 خرداد ساعت 15:00", + "hours_of_work": "از شنبه تا چهارشنبه از ساعت 08:00 تا 18:00", + "address": [ + { + "id": "39", + "uuid": "7b759d2a-af8a-4730-8eb0-e77dcd3a724e", + "name": "مطب اصلی", + "map": { "latitude": "53.121212", "longitude": "57.121212" }, + "address": "آدرس کامل مطب", + "telephone": "09120671756" + } + ], + "average_rate": { "total_rates": null }, + "state": [{ "uuid": "...", "id": "23", "name": "کهگیلویه و بویراحمد" }], + "city": [{ "uuid": "...", "id": "123", "name": "یاسوج", "parent": "23" }] +} +``` + +> **توجه فیلدها:** +> - `img` (نه `image` یا `images`!) — آرایه با url/fid/filename/filemime/filesize +> - `specialties` → تخصص اصلی + والد +> - `expertise` → همان doctor_services +> - `satisfaction` و `point` به صورت **string** برگردانده می‌شوند +> - `free_turn` → متن محاسبه‌شده از برنامه هفتگی (مثلاً "نوبت آزادی موجود نیست") +> - `hours_of_work` → متن محاسبه‌شده از برنامه هفتگی +> - `average_rate.total_rates` → می‌تواند null باشد +> - `experience` → محاسبه‌شده از `activity_time` (Unix timestamp شروع فعالیت) + +--- + +## فیلترهای GET /api/v1/doctors + +| پارامتر | نوع | الزامی | مثال | +|---------|-----|--------|------| +| `state` | string | بله | `31` | +| `city` | string | بله | `62` | +| `specialty` | string | خیر | `503` | +| `gender` | string | خیر | `man` یا `woman` | +| `degree` | string | خیر | `general`, `specialist`, `expert`, `subspecialistplus` | +| `name` | string | خیر | `علی` | +| `active` | array | خیر | `0` یا `1` | +| `page` | string | خیر | `1` | +| `limit` | string | خیر | `10` | +| `sort` | string | خیر | `ASC` یا `DESC` | + +## نمونه Response لیست دکترها +```json +{ + "data": [ + { + "id": "29", + "uuid": "...", + "name": "single doctor", + "gender": "woman", + "degree": "specialist", + "img": [{ "url": "...", "fid": "98", "filename": "image.png", "filemime": "image/png", "filesize": 173665 }], + "specialties": [{ "uuid": "...", "id": "603", "name": "داخلی عمومی", "parent": "602" }], + "satisfaction": "60", + "point": "3.5", + "free_turn": "اولین نوبت آزاد: سه‌شنبه 19 خرداد ساعت 15:00", + "hours_of_work": "از شنبه تا چهارشنبه از ساعت 08:00 تا 18:00", + "active": true + } + ], + "page": { + "totalRecords": 7, + "totalPages": 1, + "currentPage": 1 + } +} +``` + +--- + +## PATCH /api/v1/doctor/{uuid} — فیلدهای قابل ویرایش +```json +{ + "title": "نام دکتر", + "doctor_services": ["اکوکاردیوگرافی", "ویزیت تخصصی"] +} +``` + +--- + +## آپلود تصویر دکتر — POST /file/upload/clinic_pro/doctor/field_image +``` +Headers: + Content-Type: application/octet-stream + Content-Disposition: file; filename="doctor.png" + X-CSRF-Token: {token} + Authorization: Bearer {token} +Body: binary file content +Response → { fid, uuid, ... } که در PATCH doctor استفاده می‌شود +``` + +--- + +## نمونه واقعی Response — GET /api/v1/clinic-pro/doctor-address/{id} +```json +{ + "id": "39", + "uuid": "7b759d2a-af8a-4730-8eb0-e77dcd3a724e", + "name": "مطب اصلی", + "map": { + "latitude": "53.121212", + "longitude": "57.121212" + }, + "address": "آذربايجان غربي، مياندوآب، خيابان ۱۵ خرداد، برج ماندگار", + "telephone": "09120671756" +} +``` diff --git a/docs/tasks/task-06-clinic/architecture.md b/docs/tasks/task-06-clinic/architecture.md new file mode 100644 index 00000000..05ed326a --- /dev/null +++ b/docs/tasks/task-06-clinic/architecture.md @@ -0,0 +1,69 @@ +# معماری — تسک ۰۶: ماژول کلینیک + +## ساختار فایل‌ها +``` +src/Module/Clinic/ +├── Controller/ +│ ├── ClinicController.php ← CRUD + لیست +│ └── ClinicImageController.php ← آپلود تصویر و لوگو +├── Service/ +│ └── ClinicService.php +├── Repository/ +│ └── ClinicRepository.php +├── Entity/ +│ ├── Clinic.php +│ └── ClinicDoctor.php ← رابطه کلینیک-دکتر +├── DTO/ +│ ├── Request/ +│ │ ├── CreateClinicRequest.php +│ │ └── UpdateClinicRequest.php +│ └── Response/ +│ ├── ClinicResponse.php +│ └── ClinicDoctorListResponse.php +└── Voter/ + └── ClinicVoter.php +``` + +## Entity: Clinic +```php +#[ORM\Entity] +#[ORM\Table(name: 'clinics')] +class Clinic +{ + #[ORM\Id, ORM\GeneratedValue, ORM\Column] + private int $id; + + #[ORM\Column(type: UuidType::NAME, unique: true)] + private Uuid $uuid; + + #[ORM\ManyToOne(targetEntity: User::class)] + private User $owner; + + #[ORM\Column(length: 200)] + private string $name; + + #[ORM\Column(type: 'text', nullable: true)] + private ?string $description; + + #[ORM\Column(length: 20, nullable: true)] + private ?string $phone; + + #[ORM\Column(type: 'text', nullable: true)] + private ?string $address; + + #[ORM\Column(length: 100, nullable: true)] + private ?string $city; + + #[ORM\Column(length: 255, nullable: true)] + private ?string $imagePath; // تصویر اصلی + + #[ORM\Column(length: 255, nullable: true)] + private ?string $logoPath; // لوگو + + #[ORM\ManyToMany(targetEntity: Doctor::class)] + #[ORM\JoinTable(name: 'clinic_doctors')] + private Collection $doctors; + + // TimestampableTrait +} +``` diff --git a/docs/tasks/task-06-clinic/database.md b/docs/tasks/task-06-clinic/database.md new file mode 100644 index 00000000..6bb85951 --- /dev/null +++ b/docs/tasks/task-06-clinic/database.md @@ -0,0 +1,69 @@ +# پایگاه داده — تسک ۰۶: ماژول کلینیک + +## جدول: clinics +_(entity_type=clinic_pro, bundle=clinic — از config تأیید شده)_ + +| ستون | نوع | نام Drupal | توضیح | +|------|-----|-----------|-------| +| id | INT UNSIGNED AUTO_INCREMENT PK | id | | +| uuid | CHAR(36) UNIQUE NOT NULL | uuid | | +| user_id | INT FK → users.id NOT NULL | uid | مالک کلینیک | +| name | VARCHAR(255) NULL | field_name | نام کلینیک | +| info | LONGTEXT NULL | field_info | توضیحات (نه description!) | +| address | LONGTEXT NULL | field_address | آدرس | +| telephone | VARCHAR(50) NULL | field_telephone | تلفن (نه phone!) | +| is_24_7 | TINYINT(1) DEFAULT 0 | field_24_7 | باز ۲۴/۷ | +| working_days | VARCHAR(255) NULL | field_working_days | روزهای کاری (متن آزاد، مثال: 'شنبه تا سه شنبه ساعت ۱۲:۲۰') | +| latitude | FLOAT NULL | field_latitude | | +| longitude | FLOAT NULL | field_longitude | | +| logo_id | INT FK → files.id NULL | field_clinic_logo | لوگو کلینیک (image, cardinality=1) | +| city_id | INT FK → categories.id NULL | field_city | شهر (entity ref → category/city) | +| state_id | INT FK → categories.id NULL | field_state | استان (entity ref → category/state) | +| representation_id | INT FK → representations.id NULL | field_agent | نماینده کلینیک (entity ref → user — در Drupal به user اشاره دارد، در Symfony به representation) | +| created_at | INT NOT NULL | created | Unix timestamp | +| updated_at | INT NOT NULL | changed | Unix timestamp | + +## جدول: clinic_doctors (ManyToMany) +| ستون | نوع | نام Drupal | توضیح | +|------|-----|-----------|-------| +| clinic_id | INT FK → clinics.id CASCADE | | | +| doctor_id | INT FK → doctors.id CASCADE | field_doctors | cardinality نامحدود | + +## جدول: clinic_specialties (ManyToMany) +| ستون | نوع | توضیح | +|------|-----|-------| +| clinic_id | INT FK → clinics.id CASCADE | | +| category_id | INT FK → categories.id | field_clinic_specialty | + +## جدول: clinic_services (ManyToMany) +| ستون | نوع | توضیح | +|------|-----|-------| +| clinic_id | INT FK → clinics.id CASCADE | | +| category_id | INT FK → categories.id | field_doctor_services | + +## جدول: clinic_insurances (ManyToMany) +| ستون | نوع | توضیح | +|------|-----|-------| +| clinic_id | INT FK → clinics.id CASCADE | | +| category_id | INT FK → categories.id | field_insurance (insurance_type یا supplementary_insurance bundle) | + +## جدول: clinic_images (تصاویر اضافی) +| ستون | نوع | نام Drupal | توضیح | +|------|-----|-----------|-------| +| id | INT AUTO_INCREMENT PK | | | +| clinic_id | INT FK → clinics.id CASCADE | | | +| file_id | INT FK → files.id | field_image_clinic | حداکثر ۵ تصویر (cardinality=5) | +| sort_order | INT DEFAULT 0 | delta | ترتیب | + +## ایندکس‌ها +```sql +CREATE INDEX idx_clinics_owner ON clinics(user_id); +CREATE INDEX idx_clinics_city ON clinics(city_id); +CREATE INDEX idx_clinics_state ON clinics(state_id); +CREATE INDEX idx_clinics_representation ON clinics(representation_id); +``` + +## نکات مهم +- `field_agent` در Drupal به user اشاره دارد (cardinality=-1 نامحدود) — در Symfony ساده‌تر شده و فقط یک representation دارد +- `field_working_days` متن آزاد است، مثل 'شنبه تا سه‌شنبه ۸ تا ۱۲'، نه فرمت ساختاریافته +- `field_telephone` نه `phone` — اسم فیلد از Drupal config گرفته شده diff --git a/docs/tasks/task-06-clinic/implementation_notes.md b/docs/tasks/task-06-clinic/implementation_notes.md new file mode 100644 index 00000000..8852ad1d --- /dev/null +++ b/docs/tasks/task-06-clinic/implementation_notes.md @@ -0,0 +1,24 @@ +# نکات پیاده‌سازی — تسک ۰۶: ماژول کلینیک + +## لیست دکترهای کلینیک +endpoint `GET /api/v1/clinic/doctor-list/{uuid}` لیست دکترهایی که +به این کلینیک تعلق دارند را برمی‌گرداند. اطلاعات دکتر شامل: +- نام، تخصص، تصویر، میانگین امتیاز + +## آپلود دو نوع تصویر +کلینیک دو تصویر دارد: +- `image`: تصویر اصلی/배너 کلینیک (حداکثر ۵MB) +- `logo`: لوگوی کلینیک (حداکثر ۲MB، ترجیحاً مربعی) + +هر دو در مسیر `public/uploads/clinics/` ذخیره می‌شوند. + +## مجوزها +``` +POST /api/v1/clinic → ROLE_ADMIN +GET /api/v1/clinic/{uuid} → عمومی +PATCH /api/v1/clinic/{uuid} → owner یا ROLE_ADMIN +GET /api/v1/clinics → عمومی +GET clinic/doctor-list → عمومی +POST clinic/image → owner یا ROLE_ADMIN +POST clinic/logo → owner یا ROLE_ADMIN +``` diff --git a/docs/tasks/task-06-clinic/task.md b/docs/tasks/task-06-clinic/task.md new file mode 100644 index 00000000..3cb69e0f --- /dev/null +++ b/docs/tasks/task-06-clinic/task.md @@ -0,0 +1,165 @@ +# تسک ۰۶: ماژول کلینیک + +## توضیح +پیاده‌سازی مدیریت کلینیک‌ها، لیست کلینیک‌ها، لیست دکترهای هر کلینیک +و آپلود تصویر و لوگوی کلینیک. + +## Endpoint ها (واقعی از Drupal) + +| متد | مسیر | توضیح | نیاز به Auth | +|-----|------|-------|-------------| +| POST | `/api/v1/clinic` | ایجاد کلینیک | بله | +| GET | `/api/v1/clinic/{uuid}` | دریافت اطلاعات کامل کلینیک | خیر | +| PATCH | `/api/v1/clinic/{uuid}` | ویرایش کلینیک | بله (Owner/Admin) | +| GET | `/api/v1/clinics` | لیست کلینیک‌ها با فیلتر | خیر | +| GET | `/api/v1/clinic/doctor-list/{clinic_uuid}` | لیست دکترهای کلینیک | خیر | +| POST | `/file/upload/clinic_pro/clinic/field_image_clinic` | آپلود تصویر گالری کلینیک | بله | +| POST | `/file/upload/clinic_pro/clinic/field_clinic_logo` | آپلود لوگوی کلینیک | بله | + +> **⚠ مسیر آپلود واقعی:** `/file/upload/clinic_pro/clinic/field_image_clinic` (نه `/api/v1/clinic/image`) + +## پیش‌نیازها +- تسک ۰۱، ۰۲، ۰۵ (Doctor)، ۰۸ (Categories) + +## زمان تخمینی +۶ تا ۸ ساعت + +--- + +## فیلترهای GET /api/v1/clinics + +| پارامتر | نوع | الزامی | مثال | +|---------|-----|--------|------| +| `state` | string | خیر | `13` | +| `city` | string | خیر | `32` | +| `specialty` | string | خیر | `511` | +| `page` | string | بله | `1` | +| `limit` | string | بله | `10` | +| `sort` | string | خیر | `DESC` | + +--- + +## نمونه واقعی Response — GET /api/v1/clinic/{uuid} + +```json +{ + "id": "23", + "uuid": "e4550163-5a88-4f67-b07e-cd6063738598", + "title": "clinic 1", + "images_clinic": [ + { + "url": "https://domain.com/sites/default/files/2025-11/image.png", + "fid": "91", + "filename": "image.png", + "filemime": "image/png", + "filesize": 34314 + } + ], + "clinic_logo": [ + { + "url": "https://domain.com/sites/default/files/clinics/logo/2025-11/logo.png", + "fid": "96", + "filename": "logo.png", + "filemime": "image/png", + "filesize": 39281 + } + ], + "phone_number": "۰۶۱-۳۳۹۱۶۵۸۹", + "caption": "توضیحات درباره کلینیک...", + "list_bime": [ + { + "uuid": "...", + "id": "1559", + "name": "بیمه آتیه سازان حافظ", + "logo": [{ "url": "...", "fid": "5", "filename": "hafez-insurance.png", "filemime": "image/png", "filesize": 16100 }] + } + ], + "specialties": [ + { "uuid": "...", "id": "610", "name": "روماتولوژی", "parent": "602" } + ], + "services": [ + { "uuid": "...", "id": "1602", "name": "ویزیت تخصصی" } + ], + "clinic_specialty": [ + { "uuid": "...", "id": "610", "name": "روماتولوژی", "parent": "602" } + ], + "doctors": 3, + "doctor_list": null, + "city": [{ "uuid": "...", "id": "130", "name": "بندرعباس", "parent": "29" }], + "state": [{ "uuid": "...", "id": "29", "name": "هرمزگان" }], + "location": "بندرعباس: رسالت شمالی- میدان صادقیه", + "map": { "latitude": "27.200632975404", "longitude": "56.356043815613" }, + "24_7": true, + "field_working_days": "شنبه تا سه شنبه ساعت ۱۲:۲۰" +} +``` + +> **⚠ نکات فیلدهای واقعی Response:** +> - **`phone_number`** (نه `telephone`!) — شماره تماس +> - **`caption`** (نه `info`!) — توضیحات کلینیک +> - **`images_clinic`** (نه `images`!) — تصاویر گالری با url/fid/filename/filemime/filesize +> - **`clinic_logo`** (نه `logo`!) — لوگو با url/fid/filename/filemime/filesize +> - **`list_bime`** (نه `insurances`!) — بیمه‌های کلینیک، هر آیتم دارای `logo` نیز هست +> - **`field_working_days`** — روزهای کاری (string آزاد) +> - **`24_7`** — boolean +> - **`doctors`** — تعداد دکترها (integer) +> - **`services`** و **`clinic_specialty`** هر دو در response هستند + +--- + +## فیلدهای PATCH /api/v1/clinic/{uuid} + +```json +{ + "name": "نام کلینیک", + "address": "آدرس", + "telephone": "شماره تلفن", + "latitude": "27.2", + "longitude": "56.3", + "insurance": [300, 301], + "info": "توضیحات", + "doctor_services": [575, 576], + "working_days": "شنبه تا سه‌شنبه", + "24_7": 1, + "image_clinic": [91, 92], + "clinic_logo": [96] +} +``` + +## فیلدهای POST /api/v1/clinic (ایجاد) + +```json +{ + "name": "نام کلینیک", + "state": [1], + "city": [32], + "address": "آدرس", + "telephone": "شماره تلفن", + "latitude": "50.21", + "longitude": "57.212", + "insurance": [300, 301], + "info": "توضیحات", + "doctor_services": [575, 576], + "working_days": "شنبه تا سه‌شنبه", + "24_7": 1, + "image_clinic": [19], + "clinic_logo": [20] +} +``` + +--- + +## آپلود تصویر/لوگوی کلینیک + +``` +POST /file/upload/clinic_pro/clinic/field_image_clinic +POST /file/upload/clinic_pro/clinic/field_clinic_logo + +Headers: + Content-Type: application/octet-stream + Content-Disposition: file; filename="clinic.png" + X-CSRF-Token: {token} + Authorization: Bearer {token} +Body: binary file content +Response → { fid, ... } که در image_clinic یا clinic_logo استفاده می‌شود +``` diff --git a/docs/tasks/task-08-categories/architecture.md b/docs/tasks/task-08-categories/architecture.md new file mode 100644 index 00000000..d84d71da --- /dev/null +++ b/docs/tasks/task-08-categories/architecture.md @@ -0,0 +1,68 @@ +# معماری — تسک ۰۸: ماژول دسته‌بندی‌ها + +## ساختار فایل‌ها +``` +src/Module/Category/ +├── Controller/ +│ └── CategoryController.php ← همه endpoint ها +├── Service/ +│ └── CategoryService.php +├── Repository/ +│ └── CategoryRepository.php +├── Entity/ +│ └── Category.php +├── DTO/ +│ ├── Request/ +│ │ ├── CreateCategoryRequest.php +│ │ └── UpdateCategoryRequest.php +│ └── Response/ +│ └── CategoryResponse.php +└── DataFixtures/ + └── CategoryFixtures.php ← داده‌های اولیه (استان، شهر، تخصص، ...) +``` + +## Entity: Category +```php +#[ORM\Entity] +#[ORM\Table(name: 'categories')] +class Category +{ + #[ORM\Id, ORM\GeneratedValue, ORM\Column] + private int $id; + + #[ORM\Column(length: 200)] + private string $name; + + #[ORM\Column(length: 100, nullable: true)] + private ?string $code; // کد انگلیسی برای فیلتر + + // نوع دسته: tag, supplementary_insurance, insurance_type, + // state, city, specially_doctor, doctor_services + #[ORM\Column(length: 50)] + private string $type; + + #[ORM\ManyToOne(targetEntity: self::class)] + #[ORM\JoinColumn(nullable: true)] + private ?Category $parent; // برای رابطه استان-شهر + + #[ORM\Column(type: 'integer', default: 0)] + private int $sortOrder = 0; + + // TimestampableTrait +} +``` + +## Routing نمونه +```php +// GET /api/v1/categorys/{type} +#[Route('/api/v1/categorys/{type}', methods: ['GET'])] +public function listByType(string $type): Response +{ + $allowed = ['tag', 'supplementary_insurance', 'insurance_type', + 'state', 'city', 'specially_doctor', 'doctor_services']; + if (!in_array($type, $allowed)) { + return $this->notFound(); + } + return $this->json($this->categoryService->findByType($type)); +} +``` diff --git a/docs/tasks/task-08-categories/database.md b/docs/tasks/task-08-categories/database.md new file mode 100644 index 00000000..9a209218 --- /dev/null +++ b/docs/tasks/task-08-categories/database.md @@ -0,0 +1,69 @@ +# پایگاه داده — تسک ۰۸: ماژول دسته‌بندی‌ها + +## ساختار واقعی از DB backup + +جدول `category` یک entity با چند bundle است. +بیشتر داده‌ها (استان‌ها، شهرها، تخصص‌ها) در INSERT‌های DB backup موجودند. + +## جدول: categories +_(entity_type=category — از DB backup تأیید شده)_ + +| ستون | نوع | نام Drupal | توضیح | +|------|-----|-----------|-------| +| id | INT UNSIGNED AUTO_INCREMENT PK | id | | +| uuid | CHAR(36) UNIQUE NOT NULL | uuid | | +| bundle | VARCHAR(32) NOT NULL | bundle | نوع دسته‌بندی | +| label | VARCHAR(255) NULL | label | نام (base field) | +| status | TINYINT(1) DEFAULT 1 | status | فعال/غیرفعال | +| parent_id | INT FK → categories.id NULL | field_parent | والد (استان→شهر / تخصص والد) | +| weight | INT DEFAULT 0 | field_weight | ترتیب نمایش | +| logo_id | INT FK → files.id NULL | field_logo | تصویر/آیکون (برای بیمه‌ها) | +| title | VARCHAR(255) NULL | field_title | عنوان جایگزین | +| representation_id | INT FK → representations.id NULL | field_representation | نماینده مرتبط (برای city bundle) | + +## فیلدهای اختصاصی bundle=city +_(فقط برای city bundle — از config تأیید شده)_ + +| ستون | نوع | نام Drupal | توضیح | +|------|-----|-----------|-------| +| contact_phone | VARCHAR(255) NULL | field_contactphone | تلفن | +| email | VARCHAR(255) NULL | field_email | ایمیل | +| description | TEXT NULL | field_description | توضیحات | +| slogan | VARCHAR(255) NULL | field_slogan | شعار | +| domain | VARCHAR(255) NULL | field_domain | دامنه اختصاصی شهر | +| keywords | VARCHAR(255) NULL | field_keywords | کلیدواژه SEO | +| footer_description | TEXT NULL | field_footerdescription | توضیحات footer | +| footer_disclaimer | TEXT NULL | field_footerdisclaimer | سلب مسئولیت | +| social_media | LONGTEXT NULL | field_socialmedia | شبکه‌های اجتماعی (JSON) | + +## مقادیر مجاز bundle +``` +state → استان (31 استان ایران — داده در DB موجود) +city → شهر (parent_id = state_id) +specially_doctor → تخصص پزشکی +doctor_services → سرویس‌های پزشکی +insurance_type → نوع بیمه پایه +supplementary_insurance → بیمه تکمیلی +tag → تگ بلاگ +``` + +## ایندکس‌ها +```sql +CREATE INDEX idx_categories_bundle ON categories(bundle); +CREATE INDEX idx_categories_parent ON categories(parent_id); +CREATE INDEX idx_categories_status ON categories(status, bundle); +``` + +## داده‌های موجود در DB backup (نمونه) +``` +استان‌ها: id=1..100 (bundle='state') — تمام ۳۱ استان ایران +شهرها: id=101..2503 (bundle='city') — شهرهای ایران +``` +داده‌ها باید از DB backup به Symfony fixtures مهاجرت داده شوند. + +## نکته مهم: city bundle +City bundle فیلدهای زیادی دارد که برای نمایش اطلاعات سایت نماینده استفاده می‌شوند: +- `field_representation` → لینک به نماینده (multi-tenant) +- `field_domain` → دامنه اختصاصی شهر +- `field_slogan`, `field_keywords` → SEO +این فیلدها در Symfony در جدول جداگانه `city_settings` یا در همان categories با nullable columns نگه‌داشته می‌شوند. diff --git a/docs/tasks/task-08-categories/implementation_notes.md b/docs/tasks/task-08-categories/implementation_notes.md new file mode 100644 index 00000000..4e406437 --- /dev/null +++ b/docs/tasks/task-08-categories/implementation_notes.md @@ -0,0 +1,33 @@ +# نکات پیاده‌سازی — تسک ۰۸: ماژول دسته‌بندی‌ها + +## کشینگ +لیست‌های Lookup (استان، شهر، تخصص) به‌ندرت تغییر می‌کنند. +→ نتایج را با Symfony Cache (Redis) کش کن: +```php +public function findByType(string $type): array +{ + return $this->cache->get("categories_{$type}", function (ItemInterface $item) use ($type) { + $item->expiresAfter(3600); // 1 ساعت + return $this->repository->findBy(['type' => $type], ['sortOrder' => 'ASC']); + }); +} +``` +→ هنگام ایجاد/ویرایش/حذف category، کش مربوط را invalidate کن. + +## فیلتر شهر براساس استان +برای لیست شهرها، پارامتر `state_id` اختیاری است: +``` +GET /api/v1/categorys/city?state_id=5 +``` + +## مجوزها +``` +GET /api/v1/categorys/* → عمومی (بدون auth) +POST /api/v1/category → ROLE_ADMIN +PATCH /api/v1/category/{id} → ROLE_ADMIN +DELETE /api/v1/category/{id} → ROLE_ADMIN +``` + +## نکته نام endpoint +در Drupal از `categorys` (اشتباه گرامری) استفاده شده. +در Symfony همان مسیر را حفظ کن تا کلاینت تغییر نکند. diff --git a/docs/tasks/task-08-categories/task.md b/docs/tasks/task-08-categories/task.md new file mode 100644 index 00000000..6e24e658 --- /dev/null +++ b/docs/tasks/task-08-categories/task.md @@ -0,0 +1,52 @@ +# تسک ۰۸: ماژول دسته‌بندی‌ها و Lookup ها + +## توضیح +پیاده‌سازی لیست‌های ثابت (lookup) مثل استان‌ها، شهرها، تخصص‌های پزشکی، +بیمه‌ها، تگ‌ها و مدیریت دسته‌بندی‌ها. + +## Endpoint ها + +| متد | مسیر | توضیح | نیاز به Auth | +|-----|------|-------|-------------| +| GET | `/api/v1/categorys/tag` | لیست تگ‌ها | خیر | +| GET | `/api/v1/categorys/supplementary_insurance` | بیمه‌های تکمیلی | خیر | +| GET | `/api/v1/categorys/insurance_type` | نوع بیمه پایه | خیر | +| GET | `/api/v1/categorys/state` | لیست استان‌ها | خیر | +| GET | `/api/v1/categorys/city` | لیست شهرها | خیر | +| GET | `/api/v1/categorys/specially_doctor` | تخصص‌های پزشکی | خیر | +| GET | `/api/v1/categorys/doctor_services` | سرویس‌های پزشکی | خیر | +| POST | `/api/v1/category` | ایجاد دسته‌بندی | بله (Admin) | +| PATCH | `/api/v1/category/{id}` | ویرایش دسته‌بندی | بله (Admin) | +| DELETE | `/api/v1/category/{id}` | حذف دسته‌بندی | بله (Admin) | + +## پیش‌نیازها +- تسک ۰۱ و ۰۲ + +## نکته مهم +این تسک باید **قبل از تسک‌های ۰۵، ۰۶** انجام شود چون +تسک‌های دکتر و کلینیک به categories وابسته‌اند. + +## زمان تخمینی +۴ تا ۵ ساعت + +## نمونه Response + +### GET /api/v1/categorys/state +```json +{ + "data": [ + { "id": 1, "name": "تهران", "code": "tehran" }, + { "id": 2, "name": "اصفهان", "code": "isfahan" } + ] +} +``` + +### GET /api/v1/categorys/specially_doctor +```json +{ + "data": [ + { "id": 1, "name": "قلب و عروق", "code": "cardiology" }, + { "id": 2, "name": "مغز و اعصاب", "code": "neurology" } + ] +} +``` diff --git a/docs/tasks/task-09-appointment-settings/architecture.md b/docs/tasks/task-09-appointment-settings/architecture.md new file mode 100644 index 00000000..2c77129a --- /dev/null +++ b/docs/tasks/task-09-appointment-settings/architecture.md @@ -0,0 +1,130 @@ +# معماری — تسک ۰۹: ماژول تنظیمات نوبت‌دهی + +## ساختار فایل‌ها +``` +src/Module/AppointmentSettings/ +├── Controller/ +│ ├── WeeklyScheduleController.php +│ ├── DateOverrideController.php +│ └── HolidayController.php +├── Service/ +│ ├── WeeklyScheduleService.php +│ ├── DateOverrideService.php +│ └── HolidayService.php +├── Repository/ +│ ├── WeeklyScheduleRepository.php +│ ├── DateOverrideRepository.php +│ └── HolidayRepository.php +├── Entity/ +│ ├── WeeklySchedule.php +│ ├── DateOverride.php +│ └── Holiday.php +└── DTO/ + ├── Request/ + │ ├── CreateWeeklyScheduleRequest.php + │ ├── CreateDateOverrideRequest.php + │ └── CreateHolidayRequest.php + └── Response/ + ├── WeeklyScheduleResponse.php + └── DateOverrideResponse.php +``` + +## Entity: WeeklySchedule +```php +#[ORM\Entity] +#[ORM\Table(name: 'weekly_schedules')] +class WeeklySchedule +{ + #[ORM\Id, ORM\GeneratedValue, ORM\Column] + private int $id; + + #[ORM\Column(type: UuidType::NAME, unique: true)] + private Uuid $uuid; + + #[ORM\OneToOne(targetEntity: Doctor::class)] + private Doctor $doctor; + + // هر روز هفته یک JSON: {active, slots: [{start, end, duration}]} + #[ORM\Column(type: 'json')] + private array $saturday = ['active' => false, 'slots' => []]; + + #[ORM\Column(type: 'json')] + private array $sunday = ['active' => false, 'slots' => []]; + + #[ORM\Column(type: 'json')] + private array $monday = ['active' => false, 'slots' => []]; + + #[ORM\Column(type: 'json')] + private array $tuesday = ['active' => false, 'slots' => []]; + + #[ORM\Column(type: 'json')] + private array $wednesday = ['active' => false, 'slots' => []]; + + #[ORM\Column(type: 'json')] + private array $thursday = ['active' => false, 'slots' => []]; + + #[ORM\Column(type: 'json')] + private array $friday = ['active' => false, 'slots' => []]; + + // TimestampableTrait +} +``` + +## Entity: DateOverride +```php +#[ORM\Entity] +#[ORM\Table(name: 'date_overrides')] +class DateOverride +{ + #[ORM\Id, ORM\GeneratedValue, ORM\Column] + private int $id; + + #[ORM\Column(type: UuidType::NAME, unique: true)] + private Uuid $uuid; + + #[ORM\ManyToOne(targetEntity: Doctor::class)] + private Doctor $doctor; + + #[ORM\Column(type: 'date')] + private \DateTimeInterface $date; + + #[ORM\Column(type: 'boolean', default: false)] + private bool $active; + + #[ORM\Column(length: 200, nullable: true)] + private ?string $reason; + + #[ORM\Column(type: 'json', nullable: true)] + private ?array $customSlots; // [{start, end, duration}] + + // TimestampableTrait +} +``` + +## Entity: Holiday +```php +#[ORM\Entity] +#[ORM\Table(name: 'holidays')] +class Holiday +{ + #[ORM\Id, ORM\GeneratedValue, ORM\Column] + private int $id; + + #[ORM\Column(type: UuidType::NAME, unique: true)] + private Uuid $uuid; + + #[ORM\ManyToOne(targetEntity: Doctor::class)] + private Doctor $doctor; + + #[ORM\Column(type: 'date')] + private \DateTimeInterface $startDate; + + #[ORM\Column(type: 'date')] + private \DateTimeInterface $endDate; + + #[ORM\Column(length: 200, nullable: true)] + private ?string $reason; + + // TimestampableTrait +} +``` diff --git a/docs/tasks/task-09-appointment-settings/database.md b/docs/tasks/task-09-appointment-settings/database.md new file mode 100644 index 00000000..f81c9834 --- /dev/null +++ b/docs/tasks/task-09-appointment-settings/database.md @@ -0,0 +1,121 @@ +# پایگاه داده — تسک ۰۹: ماژول تنظیمات نوبت‌دهی + +## مهم: ساختار واقعی field_setting از DB backup + +**تفاوت اساسی با طراحی اولیه:** +- یک فیلد JSON به نام `field_setting` کل برنامه هفتگی را ذخیره می‌کند +- ساختار: **آرایه ۷ المان** (ایندکس 0=شنبه تا 6=جمعه) +- هر روز دو نوبت **صبح** و **عصر** دارد (نه slot‌های آرایه‌ای) + +## جدول: weekly_schedules +_(entity_type=appointment_settings, bundle=weekly_schedule)_ + +| ستون | نوع | توضیح | +|------|-----|-------| +| id | INT UNSIGNED AUTO_INCREMENT PK | | +| uuid | CHAR(36) UNIQUE NOT NULL | | +| doctor_id | INT FK → doctors.id UNIQUE | یک رکورد به‌ازای هر دکتر | +| setting | LONGTEXT NOT NULL | JSON برنامه کامل هفتگی | +| created_at | INT NOT NULL | Unix timestamp | +| updated_at | INT NOT NULL | Unix timestamp | + +## ساختار واقعی JSON فیلد `setting` (از DB backup) + +```json +[ + { + "morning": { + "active": 1, + "location_id": 48, + "start_time": "08:00", + "end_time": "12:00", + "patient_limit": 10, + "duration_per_patient": 15, + "has_rest": true, + "rest_interval": 60, + "time_to_rest": 10 + }, + "evening": { + "active": 0 + } + }, + { + "morning": { "active": 0 }, + "evening": { + "active": 1, + "location_id": 49, + "start_time": "15:00", + "end_time": "18:00", + "patient_limit": 8, + "duration_per_patient": 20, + "has_rest": false + } + }, + ... +] +``` + +ایندکس روزها: +| ایندکس | روز | +|--------|-----| +| 0 | شنبه | +| 1 | یکشنبه | +| 2 | دوشنبه | +| 3 | سه‌شنبه | +| 4 | چهارشنبه | +| 5 | پنجشنبه | +| 6 | جمعه | + +فیلدهای هر session (morning/evening): +| فیلد | نوع | توضیح | +|------|-----|-------| +| active | 0/1 | آیا این نوبت فعال است | +| location_id | int | ID آدرس مطب (→ doctor_addresses) | +| start_time | "HH:MM" | ساعت شروع | +| end_time | "HH:MM" | ساعت پایان | +| patient_limit | int | حداکثر تعداد بیمار | +| duration_per_patient | int (دقیقه) | مدت هر ویزیت | +| has_rest | boolean | آیا استراحت دارد | +| rest_interval | int (دقیقه) | فاصله استراحت | +| time_to_rest | int (دقیقه) | مدت استراحت | + +## جدول: date_overrides +_(entity_type=appointment_settings, bundle=date_override)_ + +| ستون | نوع | توضیح | +|------|-----|-------| +| id | INT UNSIGNED AUTO_INCREMENT PK | | +| uuid | CHAR(36) UNIQUE NOT NULL | | +| doctor_id | INT FK → doctors.id | دکتر | +| date | INT NOT NULL | تاریخ (Unix timestamp) — field_date | +| active | TINYINT(1) DEFAULT 0 | آیا کار می‌کند — field_active | +| setting | LONGTEXT NULL | JSON اسلات‌های سفارشی (همان ساختار field_setting) | +| created_at | INT NOT NULL | | +| updated_at | INT NOT NULL | | + +## جدول: holidays +_(entity_type=appointment_settings, bundle=holidays)_ + +| ستون | نوع | توضیح | +|------|-----|-------| +| id | INT UNSIGNED AUTO_INCREMENT PK | | +| uuid | CHAR(36) UNIQUE NOT NULL | | +| doctor_id | INT FK → doctors.id | دکتر | +| start_date | INT NOT NULL | تاریخ شروع (Unix timestamp) | +| end_date | INT NOT NULL | تاریخ پایان (Unix timestamp) | +| active | TINYINT(1) DEFAULT 1 | field_active | +| created_at | INT NOT NULL | | +| updated_at | INT NOT NULL | | + +## ایندکس‌ها +```sql +CREATE UNIQUE INDEX idx_weekly_schedules_doctor ON weekly_schedules(doctor_id); +CREATE INDEX idx_date_overrides_doctor_date ON date_overrides(doctor_id, date); +CREATE INDEX idx_holidays_doctor_range ON holidays(doctor_id, start_date, end_date); +``` + +## نکات مهم +- **GET /appointment-settings/{uuid}** — uuid دکتر است، نه uuid schedule +- `setting[0]` ایندکس 0=شنبه تا 6=جمعه (هفته ایرانی) +- هر روز دقیقاً ۲ نوبت (morning و evening) دارد +- session غیرفعال فقط `{"active": 0}` است، بقیه فیلدها ندارد diff --git a/docs/tasks/task-09-appointment-settings/implementation_notes.md b/docs/tasks/task-09-appointment-settings/implementation_notes.md new file mode 100644 index 00000000..6ec09207 --- /dev/null +++ b/docs/tasks/task-09-appointment-settings/implementation_notes.md @@ -0,0 +1,72 @@ +# نکات پیاده‌سازی — تسک ۰۹: ماژول تنظیمات نوبت‌دهی + +## اولویت‌بندی تنظیمات (از Manual — بخش ۲.۸.۴) +هنگام محاسبه اسلات‌های خالی (تسک ۱۰): +``` +1. DateOverride (بالاترین) → اگر Override فعال برای این تاریخ وجود دارد، تعطیلی نادیده گرفته می‌شود +2. Holiday → اگر تاریخ تعطیل است AND override ندارد → روز بسته است +3. WeeklySchedule (پایین) → در صورت نبود override و تعطیلی → برنامه هفتگی +``` + +## UUID در URL endpoint لیست override ها +مسیر `GET /api/v1/appointment-settings/date-override/list/{uuid}` +→ این `uuid` برابر است با UUID دکتر (نه DateOverride) + +## ساختار واقعی هر session در weekly schedule (از Manual و API request) +```json +{ + "active": 1, + "number_of_turns": 10, ← تعداد نوبت (نه patient_limit!) + "turn_time": 10, ← مدت هر نوبت به دقیقه (نه duration_per_patient!) + "location": { "id": 48 }, ← آدرس مطب (object، نه فقط ID) + "start_time": "10:00", + "end_time": "13:00" +} +``` + +ساختار کامل weekly schedule (7 روز — از "0"=شنبه تا "6"=جمعه): +```json +{ + "0": { + "morning": { "active": 1, "number_of_turns": 10, "turn_time": 10, "location": {"id": 48}, "start_time": "10:00", "end_time": "13:00" }, + "evening": { "active": 0 } + }, + "1": { "morning": {"active": 0}, "evening": { "active": 1, ... } }, + ... +} +``` + +**مهم:** در DB backup، فیلدهای `patient_limit` و `duration_per_patient` استفاده شده بود. +در API (کلاینت) از `number_of_turns` و `turn_time` استفاده می‌شود. +در Symfony باید هر دو نام را پشتیبانی کنی یا از نام‌های API استفاده کنی. + +## مهم: ذخیره‌سازی field_setting (از کد واقعی) +```php +// در Drupal: +$normalized['field_setting'] = json_encode($data['setting'][0]); +// یعنی اولین المان آرایه‌ای که فرانت می‌فرستد ذخیره می‌شود + +// در Symfony هم همین رویکرد: +$weeklySchedule->setSetting(json_encode($request->getSetting()[0])); +``` + +## GET از UUID دکتر (نه UUID schedule) +```php +// weeklyScheduleService.get($uuid) در Drupal: +// 1. ابتدا دکتر با این uuid را پیدا کن → $doctorEntity +// 2. سپس schedule با field_doctor_id = $doctorId را پیدا کن + +// در Symfony: +$doctor = $this->doctorRepo->findByUuid($uuid); +$schedule = $this->scheduleRepo->findByDoctor($doctor); +``` + +## مجوزها +تمام endpoint های این ماژول نیاز به احراز هویت دارند: +``` +POST/PATCH/DELETE → دکتر مرتبط (owner) یا ROLE_ADMIN +GET → دکتر مرتبط یا ROLE_ADMIN یا منشی دکتر +``` + +## user_flow +جریان کامل در فایل جداگانه user_flow.md توضیح داده شده است. diff --git a/docs/tasks/task-09-appointment-settings/task.md b/docs/tasks/task-09-appointment-settings/task.md new file mode 100644 index 00000000..a14bc851 --- /dev/null +++ b/docs/tasks/task-09-appointment-settings/task.md @@ -0,0 +1,66 @@ +# تسک ۰۹: ماژول تنظیمات نوبت‌دهی + +## توضیح +پیاده‌سازی سیستم تنظیمات نوبت‌دهی دکتر شامل برنامه هفتگی، +override روزهای خاص و تعطیلات. + +## Endpoint ها + +| متد | مسیر | توضیح | نیاز به Auth | +|-----|------|-------|-------------| +| POST | `/api/v1/appointment-settings/weekly-schedule` | ایجاد برنامه هفتگی | بله (Doctor) | +| PATCH | `/api/v1/appointment-settings/weekly-schedule/{uuid}` | ویرایش برنامه | بله | +| GET | `/api/v1/appointment-settings/weekly-schedule/{uuid}` | دریافت برنامه | بله | +| DELETE | `/api/v1/booking-setting/{uuid}` | حذف تنظیمات | بله | +| GET | `/api/v1/appointment-settings/date-override/list/{uuid}` | لیست override ها | بله | +| POST | `/api/v1/appointment-settings/date-override` | ایجاد override | بله (Doctor) | +| PATCH | `/api/v1/appointment-settings/date-override/{uuid}` | ویرایش override | بله | +| DELETE | `/api/v1/appointment-settings/date-override/{uuid}` | حذف override | بله | +| GET | `/api/v1/appointment-settings/date-override/{uuid}` | دریافت override | بله | +| POST | `/api/v1/appointment-settings/holidays` | ثبت تعطیلات | بله (Doctor) | +| PATCH | `/api/v1/appointment-settings/holidays/{uuid}` | ویرایش تعطیلات | بله | + +## پیش‌نیازها +- تسک ۰۱، ۰۲، ۰۵ (Doctor) + +## زمان تخمینی +۱۰ تا ۱۲ ساعت + +## نمونه Request + +### POST /api/v1/appointment-settings/weekly-schedule +```json +{ + "doctor_uuid": "61be915b-...", + "schedule": { + "saturday": { "active": true, "slots": [{"start": "09:00", "end": "13:00", "duration": 30}] }, + "sunday": { "active": true, "slots": [{"start": "09:00", "end": "13:00", "duration": 30}] }, + "monday": { "active": false, "slots": [] }, + "tuesday": { "active": true, "slots": [{"start": "14:00", "end": "18:00", "duration": 20}] }, + "wednesday": { "active": false, "slots": [] }, + "thursday": { "active": true, "slots": [{"start": "09:00", "end": "12:00", "duration": 30}] }, + "friday": { "active": false, "slots": [] } + } +} +``` + +### POST /api/v1/appointment-settings/date-override +```json +{ + "doctor_uuid": "...", + "date": "2024-03-20", + "active": false, + "reason": "مرخصی", + "custom_slots": [] +} +``` + +### POST /api/v1/appointment-settings/holidays +```json +{ + "doctor_uuid": "...", + "start_date": "2024-03-20", + "end_date": "2024-03-27", + "reason": "نوروز" +} +``` diff --git a/docs/tasks/task-09-appointment-settings/user_flow.md b/docs/tasks/task-09-appointment-settings/user_flow.md new file mode 100644 index 00000000..20733bc9 --- /dev/null +++ b/docs/tasks/task-09-appointment-settings/user_flow.md @@ -0,0 +1,67 @@ +# جریان کاربری — تسک ۰۹: تنظیمات نوبت‌دهی + +## جریان تنظیم اولیه نوبت‌دهی توسط دکتر + +``` +دکتر وارد پنل می‌شود + │ + ▼ +POST /api/v1/appointment-settings/weekly-schedule + { doctor_uuid, schedule: { saturday: {...}, sunday: {...}, ... } } + │ + └─► ذخیره برنامه هفتگی پایه +``` + +## جریان ثبت مرخصی یا تعطیلات + +``` +دکتر تعطیلات را ثبت می‌کند + │ + ▼ +POST /api/v1/appointment-settings/holidays + { doctor_uuid, start_date, end_date, reason } + │ + └─► در بازه تعطیلات، هیچ نوبتی نمی‌توان گرفت +``` + +## جریان override یک روز خاص + +``` +دکتر می‌خواهد یک روز خاص را سفارشی کند + │ + ├─► غیرفعال کردن یک روز: + │ POST /date-override { date: "2024-03-15", active: false } + │ + └─► اسلات سفارشی برای یک روز: + POST /date-override { + date: "2024-03-15", + active: true, + custom_slots: [{ start: "10:00", end: "12:00", duration: 20 }] + } +``` + +## الگوریتم محاسبه اسلات‌های خالی (تسک ۱۰ از این استفاده می‌کند) + +``` +برای تاریخ درخواست‌شده: + │ + ▼ +آیا در بازه Holiday است؟ + بله → نوبت موجود نیست + خیر │ + ▼ +آیا DateOverride برای این تاریخ وجود دارد؟ + بله → active=false: نوبت موجود نیست + active=true: از custom_slots استفاده کن + خیر │ + ▼ +از WeeklySchedule روز هفته مربوطه استفاده کن + active=false: نوبت موجود نیست + active=true: اسلات‌های slots را محاسبه کن + │ + ▼ +اسلات‌های رزروشده را حذف کن (از جدول appointments) + │ + ▼ +لیست اسلات‌های خالی را برگردان +``` diff --git a/docs/tasks/task-10-appointment/architecture.md b/docs/tasks/task-10-appointment/architecture.md new file mode 100644 index 00000000..76865d16 --- /dev/null +++ b/docs/tasks/task-10-appointment/architecture.md @@ -0,0 +1,71 @@ +# معماری — تسک ۱۰: ماژول نوبت‌دهی + +## ساختار فایل‌ها +``` +src/Module/Appointment/ +├── Controller/ +│ └── AppointmentController.php +├── Service/ +│ ├── AppointmentService.php +│ └── SlotCalculatorService.php ← محاسبه اسلات‌های خالی +├── Repository/ +│ └── AppointmentRepository.php +├── Entity/ +│ └── Appointment.php +├── DTO/ +│ ├── Request/ +│ │ └── CreateAppointmentRequest.php +│ └── Response/ +│ ├── AppointmentResponse.php +│ └── SlotResponse.php +└── Voter/ + └── AppointmentVoter.php +``` + +## Entity: Appointment +```php +#[ORM\Entity] +#[ORM\Table(name: 'appointments')] +class Appointment +{ + #[ORM\Id, ORM\GeneratedValue, ORM\Column] + private int $id; + + #[ORM\Column(type: UuidType::NAME, unique: true)] + private Uuid $uuid; + + #[ORM\ManyToOne(targetEntity: User::class)] + private User $patient; + + #[ORM\ManyToOne(targetEntity: Doctor::class)] + private Doctor $doctor; + + #[ORM\Column(type: 'date')] + private \DateTimeInterface $appointmentDate; + + #[ORM\Column(length: 10)] + private string $appointmentTime; // HH:MM + + // pending, confirmed, cancelled, completed + #[ORM\Column(length: 20, default: 'pending')] + private string $status; + + #[ORM\Column(length: 30, nullable: true)] + private ?string $insuranceType; + + #[ORM\Column(type: 'text', nullable: true)] + private ?string $notes; + + #[ORM\OneToOne(targetEntity: Payment::class, mappedBy: 'appointment')] + private ?Payment $payment; + + // TimestampableTrait +} +``` + +## SlotCalculatorService +این سرویس با استفاده از WeeklySchedule، DateOverride و Holiday +اسلات‌های خالی را برای یک دکتر در یک تاریخ مشخص محاسبه می‌کند: +``` +calculateAvailableSlots(Doctor $doctor, \DateTimeInterface $date): array +``` diff --git a/docs/tasks/task-10-appointment/database.md b/docs/tasks/task-10-appointment/database.md new file mode 100644 index 00000000..a811fc9b --- /dev/null +++ b/docs/tasks/task-10-appointment/database.md @@ -0,0 +1,82 @@ +# پایگاه داده — تسک ۱۰: ماژول نوبت‌دهی + +## جدول: appointments +_(entity_type=appointment — از DB backup تأیید شده)_ + +| ستون | نوع | نام Drupal | توضیح | +|------|-----|-----------|-------| +| id | INT UNSIGNED AUTO_INCREMENT PK | id | | +| uuid | CHAR(36) UNIQUE NOT NULL | uuid | | +| patient_id | INT FK → users.id NOT NULL | uid | بیمار (owner) | +| doctor_id | INT FK → doctors.id NOT NULL | field_doctor_id | entity ref → clinic_pro | +| address_id | INT FK → doctor_addresses.id NULL | field_address | entity ref → clinic_pro | +| representation_id | INT FK → representations.id NULL | field_representation | entity ref → clinic_pro | +| start_time | INT NOT NULL | field_start_time | Unix timestamp (Asia/Tehran) | +| end_time | INT NOT NULL | field_end_time | Unix timestamp | +| slot | LONGTEXT NULL | field_slot | JSON (ساختار زیر) | +| status | VARCHAR(40) DEFAULT 'waiting_for_payment' | field_status | وضعیت | +| visited_at | INT NULL | field_visited_at | زمان ویزیت (Unix timestamp) | +| info | LONGTEXT NULL | field_info | یادداشت | +| created_at | INT NOT NULL | created | Unix timestamp | +| updated_at | INT NOT NULL | changed | Unix timestamp | + +## ساختار واقعی JSON فیلد `slot` (از DB backup) +```json +{ + "time": "17:00", + "status": "available", + "start_time_timestamp": 1763472600, + "end_time_timestamp": 1763473800, + "duration_per_patient": 20, + "location_id": 38 +} +``` + +## وضعیت‌های کامل (field_status) — از config +``` +waiting_for_payment → پیش‌فرض — منتظر پرداخت +reserved → رزرو‌شده (پرداخت انجام شده) +auto_cancel_unpaid → لغو خودکار (عدم پرداخت) +cancelled_by_patient → لغو توسط بیمار +cancelled_by_doctor → لغو توسط دکتر +checked_in → بیمار آمده +waiting → در صف انتظار +in_progress → در حال ویزیت +visited → ویزیت تمام شده +no_show → غایب +postponed → به تعویق افتاده +completed → تکمیل شده +``` + +⚠️ وضعیت‌هایی که slot را آزاد می‌کنند (قابل رزرو مجدد): +`auto_cancel_unpaid`, `cancelled_by_patient`, `cancelled_by_doctor` + +## ایندکس‌ها +```sql +CREATE INDEX idx_appointments_patient ON appointments(patient_id); +CREATE INDEX idx_appointments_doctor ON appointments(doctor_id); +CREATE INDEX idx_appointments_doctor_time ON appointments(doctor_id, start_time); +CREATE INDEX idx_appointments_status ON appointments(status); +CREATE INDEX idx_appointments_representation ON appointments(representation_id); +``` + +## نمونه داده واقعی از DB backup +``` +id=1, uuid='617f78af-...', uid=32, doctor_id=29 +start_time=1763472600, end_time=1763473800 +slot: {"time":"17:00","status":"available","start_time_timestamp":1763472600, + "end_time_timestamp":1763473800,"duration_per_patient":20,"location_id":38} +``` + +## روابط +- `appointments.patient_id` → `users.id` +- `appointments.doctor_id` → `doctors.id` (clinic_pro entity) +- `appointments.address_id` → `doctor_addresses.id` (clinic_pro entity) +- `appointments.representation_id` → `representations.id` (clinic_pro entity) +- `appointments` ← `payments.field_reference_id` (OneToOne) + +## نکات مهم +- تایم‌زون: `Asia/Tehran` +- `start_time` و `end_time` هر دو Unix timestamp هستند (INT) +- `slot.time` ساعت شروع برای نمایش است (HH:MM) +- نوبت ابتدا `waiting_for_payment` → بعد پرداخت → `reserved` diff --git a/docs/tasks/task-10-appointment/implementation_notes.md b/docs/tasks/task-10-appointment/implementation_notes.md new file mode 100644 index 00000000..9e41cde8 --- /dev/null +++ b/docs/tasks/task-10-appointment/implementation_notes.md @@ -0,0 +1,122 @@ +# نکات پیاده‌سازی — تسک ۱۰: ماژول نوبت‌دهی + +## وضعیت‌های واقعی نوبت (از Drupal) +``` +waiting_for_payment → وضعیت پیش‌فرض هنگام ثبت نوبت +confirmed → بعد از پرداخت موفق +auto_cancel_unpaid → لغو خودکار به دلیل عدم پرداخت +cancelled_by_patient → لغو توسط بیمار +cancelled_by_doctor → لغو توسط دکتر +``` +⚠️ در طراحی اولیه `pending/cancelled/completed` بود — این‌ها **اشتباه** بودند. + +## تشخیص نماینده از HTTP Host (Multi-tenant) +```php +// در AppointmentService.php Drupal: +// نماینده از domain_name=host پیدا می‌شود +private function getRepresentation(string $host): ?int { + return $this->representationRepo->findByDomainName($host)?->getId(); +} +// در Symfony: از $request->getHost() استفاده کن +$host = $request->getSchemeAndHttpHost() . '/'; // e.g. http://yasuj-nobat.localhost:3000/ +$representation = $this->representationRepo->findByDomainName($host); +``` + +## فیلدهای واقعی نوبت (از کد Drupal) +``` +field_doctor_id → entity reference به doctor +field_start_time → Unix timestamp (Asia/Tehran) +field_end_time → Unix timestamp (Asia/Tehran) +field_address → entity reference به doctor_address +field_slot → JSON: {start_time_timestamp, end_time_timestamp, location_id, start, end, duration} +field_representation → entity reference به representation +field_status → string (waiting_for_payment, confirmed, ...) +field_visited_at → Unix timestamp (بعد از ویزیت) +field_info → یادداشت +``` + +## اعتبارسنجی slot (از کد Drupal) +```php +// بررسی start_time معتبر بودن (در آینده، نه گذشته) +$checkStartTime = $this->isTimestampValid($startTime, 10); // 10 دقیقه حداقل +$checkEndTime = $this->isTimestampValid($endTime, 10); + +// بررسی تداخل (conflict check) +$unacceptableStatus = ['auto_cancel_unpaid', 'cancelled_by_patient', 'cancelled_by_doctor']; +// اگر نوبتی برای همین doctor + slot وجود داشت که status آن در لیست بالا نبود → خطا +``` + +## جلوگیری از Race Condition +از database transaction + pessimistic write lock استفاده کن: +```php +$this->entityManager->beginTransaction(); +try { + $existing = $this->repo->findConflictingAppointment( + $doctorId, $startTime, $endTime, + lockMode: LockMode::PESSIMISTIC_WRITE + ); + if ($existing) throw new SlotAlreadyTakenException(); + + $appointment = new Appointment(...); + $this->entityManager->persist($appointment); + $this->entityManager->flush(); + $this->entityManager->commit(); +} catch (\Exception $e) { + $this->entityManager->rollback(); + throw $e; +} +``` + +## Response کامل نوبت (از finalizedData Drupal) +```json +{ + "id": 1, + "uuid": "...", + "status": "waiting_for_payment", + "start_time": 1716000000, + "end_time": 1716001800, + "visited_at": null, + "info": null, + "slot": { + "start_time_timestamp": 1716000000, + "end_time_timestamp": 1716001800, + "location_id": 42, + "start": "09:00", + "end": "09:30", + "duration": 30 + }, + "doctor": { + "id": 5, + "uuid": "...", + "name": "دکتر محمدی", + "specialty": {"id": 3, "uuid": "...", "name": "متخصص قلب"} + }, + "address": { + "id": 42, "uuid": "...", "name": "مطب شیراز", + "address": "...", "phone": "071...", + "map": {"latitude": 29.6, "longitude": 52.5} + }, + "patient": { + "id": 10, "uuid": "...", + "name": "علی رضایی", + "mobile": "09120000000", + "profile": {"id": 8, "uuid": "..."} + } +} +``` + +## روزهای غیر قابل رزرو +endpoint `GET /appointment/not-available/{doctorId}` تاریخ‌هایی را برمی‌گرداند که در آن‌ها نوبت خالی نیست: +- روزهایی در Holiday جای گرفته‌اند +- روزهایی که DateOverride با active=false دارند +- روزهایی که WeeklySchedule آن‌ها active=false است +- روزهایی که همه slot‌هایشان رزرو فعال دارند + +## مجوزها +``` +POST /appointment → احراز هویت‌شده +GET /appointment-slots/{doctorId} → عمومی +GET /appointment/not-available/{id} → عمومی +GET /appointment/my-appointments/{id} → owner یا ROLE_ADMIN +PATCH /appointment/{uuid}/status → ROLE_ADMIN یا دکتر مرتبط +``` diff --git a/docs/tasks/task-10-appointment/task.md b/docs/tasks/task-10-appointment/task.md new file mode 100644 index 00000000..375c3325 --- /dev/null +++ b/docs/tasks/task-10-appointment/task.md @@ -0,0 +1,284 @@ +# تسک ۱۰: ماژول نوبت‌دهی + +## توضیح +سیستم رزرو نوبت شامل نمایش اسلات‌های خالی، رزرو نوبت، لغو نوبت، +روزهای غیرقابل رزرو و لیست نوبت‌های کاربر. + +## Endpoint ها + +| متد | مسیر | توضیح | نیاز به Auth | +|-----|------|-------|-------------| +| GET | `/api/v1/appointment-slots` | اسلات‌های خالی دکتر در تاریخ | خیر | +| POST | `/api/v1/appointment` | رزرو نوبت | بله | +| GET | `/api/v1/appointment/not-available/{doctorId}` | روزهای غیرقابل رزرو | خیر | +| GET | `/api/v1/appointment/my-appointments/{userId}` | نوبت‌های من | بله | +| PATCH | `/api/v1/appointment/{uuid}/cancel` | لغو نوبت توسط کاربر | بله (Owner) | +| PATCH | `/api/v1/appointment/{uuid}/status` | تغییر وضعیت نوبت | بله (Doctor/Secretary/Admin) | + +## پیش‌نیازها +- تسک ۰۱، ۰۲، ۰۵ (Doctor)، ۰۹ (تنظیمات)، ۱۵ (Payment) + +## زمان تخمینی +۱۲ تا ۱۵ ساعت + +--- + +## Status Machine نوبت + +``` +[ایجاد نوبت] + ↓ +waiting_for_payment ──→ (پرداخت موفق) ──→ reserved + ↓ ↓ + (لغو) ┌────────────┤ + ↓ │ │ + cancelled_by_patient checked_in (لغو دکتر) + ↓ ↓ + waiting cancelled_by_doctor + ↓ + in_progress + ↓ + ┌─────────────┴─────────────┐ + ↓ ↓ + visited no_show + ↓ + completed +``` + +**وضعیت‌ها:** +| وضعیت | توضیح | چه کسی تغییر می‌دهد | +|--------|-------|---------------------| +| `waiting_for_payment` | منتظر پرداخت | سیستم — بعد از رزرو | +| `reserved` | رزرو شده — پرداخت موفق | سیستم — بعد از تأیید پرداخت | +| `checked_in` | بیمار به مطب رسیده | منشی/دکتر | +| `waiting` | در صف انتظار مطب | منشی/دکتر | +| `in_progress` | ویزیت در حال انجام | منشی/دکتر | +| `visited` | ویزیت انجام شد | منشی/دکتر | +| `no_show` | بیمار نیامد | منشی/دکتر | +| `completed` | کامل شد | سیستم | +| `cancelled_by_patient` | لغو توسط بیمار | بیمار (Owner) | +| `cancelled_by_doctor` | لغو توسط دکتر | دکتر/Admin | +| `postponed` | به تعویق افتاده | دکتر/Admin | + +--- + +## فلوی کامل رزرو + پرداخت + +``` +POST /api/v1/appointment + ↓ +1. بررسی اسلات: آیا time در آن date خالی است؟ +2. بررسی holiday/date_override +3. ایجاد appointment با status=waiting_for_payment +4. بازگشت uuid نوبت به کلاینت + ↓ +POST /api/v1/payment (در task-15) + { appointment_uuid: "...", payment_method: "mellat" } + ↓ +5. ایجاد payment با status=pending +6. دریافت payment_url از درگاه +7. redirect کاربر به درگاه + ↓ +[Callback از درگاه بانک] + ↓ +8. تأیید پرداخت → payments.status = 'received' +9. appointments.status = 'reserved' +10. واریز کمیسیون به کیف پول نماینده (اگر از دامنه نماینده) +``` + +**⚠ نکته:** اگر در ۳۰ دقیقه پرداخت نشود → `waiting_for_payment` به `cancelled_by_system` تغییر کند (job) + +--- + +## GET /api/v1/appointment-slots + +``` +Query params: + doctor_uuid (الزامی) + date (الزامی) — فرمت: YYYY-MM-DD +``` + +```json +{ + "success": true, + "data": { + "date": "2024-03-20", + "doctor": { "uuid": "...", "name": "دکتر احمدی" }, + "slots": [ + { "time": "09:00", "available": true, "duration": 30 }, + { "time": "09:30", "available": false, "duration": 30 }, + { "time": "10:00", "available": true, "duration": 30 } + ] + } +} +``` + +**منطق محاسبه اسلات‌های خالی:** +``` +1. بارگذاری weekly_schedule دکتر برای روز هفته مربوطه +2. بررسی date_override برای تاریخ مشخص +3. بررسی holiday (اگر تاریخ در بازه تعطیلی است → همه اسلات‌ها unavailable) +4. خواندن نوبت‌های موجود با status ≠ cancelled → آن اسلات‌ها unavailable +5. بازگشت لیست اسلات‌ها با وضعیت available/unavailable +``` + +--- + +## POST /api/v1/appointment + +```json +// Request +{ + "doctor_uuid": "61be915b-...", + "date": "2024-03-20", + "time": "09:00", + "address_id": 39, + "insurance_type_id": null, + "notes": "درد معده دارم" +} + +// Response 201 +{ + "success": true, + "data": { + "uuid": "...", + "doctor": { "uuid": "...", "name": "دکتر احمدی" }, + "date": "2024-03-20", + "time": "09:00", + "status": "waiting_for_payment", + "created_at": 1748000000 + } +} + +// Response 409 — اسلات گرفته شده +{ + "success": false, + "errors": [{ "code": "ERR_APPOINTMENT_001", "message": "اسلات انتخاب‌شده در دسترس نیست" }] +} +``` + +--- + +## PATCH /api/v1/appointment/{uuid}/cancel — لغو نوبت + +```json +// Request +{ "reason": "به دلیل بیماری نمی‌توانم بیایم" } + +// Response 200 +{ + "success": true, + "data": { + "uuid": "...", + "status": "cancelled_by_patient", + "refund_status": "pending" + } +} + +// Response 400 — نوبت قابل لغو نیست +{ + "success": false, + "errors": [{ "code": "ERR_APPOINTMENT_002", "message": "نوبت در وضعیت فعلی قابل لغو نیست" }] +} +``` + +**قوانین لغو:** +- فقط نوبت‌های با status `waiting_for_payment` یا `reserved` قابل لغو هستند +- اگر پرداخت شده (`reserved`) → `payments.status = 'refund'` و refund شروع می‌شود +- لغو بعد از `checked_in` فقط توسط Admin/Doctor مجاز است + +--- + +## PATCH /api/v1/appointment/{uuid}/status + +```json +// Request (Doctor/Secretary/Admin) +{ "status": "checked_in" } + +// Response 200 +{ + "success": true, + "data": { + "uuid": "...", + "status": "checked_in", + "updated_at": 1748000000 + } +} +``` + +**Transition های مجاز:** +``` +reserved → checked_in (Doctor/Secretary) +checked_in → waiting (Doctor/Secretary) +waiting → in_progress (Doctor/Secretary) +in_progress → visited (Doctor/Secretary) +in_progress → no_show (Doctor/Secretary) +visited → completed (System/Doctor) +reserved → cancelled_by_doctor (Doctor/Admin) +reserved → postponed (Doctor/Admin) +``` + +--- + +## GET /api/v1/appointment/not-available/{doctorId} + +```json +{ + "success": true, + "data": { + "not_available_dates": [ + "2024-03-20", + "2024-03-21", + "2024-04-01" + ] + } +} +``` + +**منطق:** +- روزهایی که holiday هستند +- روزهایی که date_override با `active=false` تعریف شده +- روزهایی که همه اسلات‌ها پر هستند + +--- + +## GET /api/v1/appointment/my-appointments/{userId} + +``` +Query params: + status (اختیاری) — فیلتر بر اساس وضعیت + page (اختیاری، پیش‌فرض 1) + limit (اختیاری، پیش‌فرض 10) +``` + +```json +{ + "success": true, + "data": [ + { + "uuid": "...", + "doctor": { + "uuid": "...", + "name": "دکتر احمدی", + "specialty": "قلب و عروق", + "img": [{ "url": "..." }] + }, + "date": "2024-03-20", + "time": "09:00", + "status": "reserved", + "payment_status": "received", + "created_at": 1748000000 + } + ], + "meta": { "totalRecords": 12, "totalPages": 2, "currentPage": 1 } +} +``` + +--- + +## نکات مهم + +- **Optimistic Locking:** هنگام رزرو اسلات، از Transaction + Lock استفاده شود تا race condition نباشد +- **Expiry Job:** نوبت‌های `waiting_for_payment` بعد از ۳۰ دقیقه باید auto-cancel شوند (Symfony Scheduler) +- **N+1 Prevention:** در لیست نوبت‌ها، دکتر و وضعیت پرداخت با eager loading بارگذاری شوند +- **Timestamps:** همه تاریخ/زمان‌ها Unix timestamp (INT) ذخیره می‌شوند diff --git a/docs/tasks/task-10-appointment/user_flow.md b/docs/tasks/task-10-appointment/user_flow.md new file mode 100644 index 00000000..636d80fe --- /dev/null +++ b/docs/tasks/task-10-appointment/user_flow.md @@ -0,0 +1,48 @@ +# جریان کاربری — تسک ۱۰: نوبت‌دهی + +## جریان کامل رزرو نوبت + +``` +کاربر دکتر را انتخاب می‌کند + │ + ▼ +GET /api/v1/appointment/not-available/{doctorId} + → دریافت تاریخ‌های غیر قابل رزرو (برای کالندار) + │ + ▼ +کاربر تاریخ مورد نظر را انتخاب می‌کند + │ + ▼ +GET /api/v1/appointment-slots?doctor_uuid=...&date=... + → دریافت اسلات‌های خالی آن روز + │ + ▼ +کاربر ساعت مورد نظر را انتخاب می‌کند + │ + ▼ +POST /api/v1/appointment + { doctor_uuid, date, time, insurance_type, notes } + │ + ├─► بررسی موجود بودن اسلات + ├─► ایجاد appointment با status=pending + └─► ایجاد payment با status=pending (تسک ۱۵) + │ + ▼ +کاربر به درگاه پرداخت هدایت می‌شود (تسک ۱۵) + │ + ▼ +بعد از پرداخت موفق: + appointment.status = confirmed + payment.status = paid + │ + ▼ +ارسال پیامک تأیید به کاربر و دکتر +``` + +## جریان مشاهده نوبت‌های من + +``` +GET /api/v1/appointment/my-appointments/{userId} + → لیست همه نوبت‌ها (گذشته و آینده) + → به صورت صعودی بر اساس تاریخ مرتب‌شده +``` diff --git a/docs/tasks/task-11-insurance/architecture.md b/docs/tasks/task-11-insurance/architecture.md new file mode 100644 index 00000000..5abcd400 --- /dev/null +++ b/docs/tasks/task-11-insurance/architecture.md @@ -0,0 +1,44 @@ +# معماری — تسک ۱۱: ماژول بیمه + +## ساختار فایل‌ها +``` +src/Module/Insurance/ +├── Controller/ +│ └── InsuranceController.php +├── Service/ +│ └── InsuranceService.php +├── Repository/ +│ └── InsuranceRepository.php +├── Entity/ +│ └── Insurance.php +└── DTO/ + ├── Request/ + │ ├── CreateInsuranceRequest.php + │ └── UpdateInsuranceRequest.php + └── Response/ + └── InsuranceResponse.php +``` + +## Entity: Insurance +```php +#[ORM\Entity] +#[ORM\Table(name: 'doctor_insurances')] +class Insurance +{ + #[ORM\Id, ORM\GeneratedValue, ORM\Column] + private int $id; + + #[ORM\ManyToOne(targetEntity: Doctor::class)] + #[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')] + private Doctor $doctor; + + #[ORM\ManyToOne(targetEntity: Category::class)] + #[ORM\JoinColumn(nullable: false)] + private Category $insuranceCategory; // از جدول categories + + #[ORM\Column(type: 'boolean', default: true)] + private bool $isActive = true; + + // TimestampableTrait +} +``` diff --git a/docs/tasks/task-11-insurance/database.md b/docs/tasks/task-11-insurance/database.md new file mode 100644 index 00000000..3d235266 --- /dev/null +++ b/docs/tasks/task-11-insurance/database.md @@ -0,0 +1,31 @@ +# پایگاه داده — تسک ۱۱: ماژول بیمه + +## توضیح +در Drupal، بیمه‌ها به عنوان دسته‌بندی (`category` entity) ذخیره می‌شوند: +- bundle=`insurance_type` → بیمه‌های پایه +- bundle=`supplementary_insurance` → بیمه‌های تکمیلی + +رابطه doctor-insurance از طریق `field_insurance` روی bundle=clinic در Drupal موجود است (نه مستقیم روی doctor). +در profile کاربر: `field_basic_insurance` و `field_supplementary_insurance` (entity ref → category). + +## جدول: doctor_insurances (رابطه doctor ↔ insurance) +_(از endpoint واقعی: PATCH /api/v1/insurance/1 دارای field_price است)_ + +| ستون | نوع | نام Drupal | توضیح | +|------|-----|-----------|-------| +| id | INT AUTO_INCREMENT PK | id | | +| doctor_id | INT FK → doctors.id CASCADE | field_doctor | دکتر | +| category_id | INT FK → categories.id | field_insurance_type | نوع بیمه (bundle=insurance_type یا supplementary_insurance) | +| price | INT NULL | field_price | مبلغ ویزیت با این بیمه (تومان — اختیاری) | + +## ایندکس‌ها +```sql +CREATE UNIQUE INDEX idx_doctor_insurance ON doctor_insurances(doctor_id, category_id); +CREATE INDEX idx_doctor_insurance_cat ON doctor_insurances(category_id); +``` + +## نکات مهم +- داده‌های بیمه در جدول `categories` ذخیره می‌شوند — نه جدول جداگانه +- `clinic_insurances` هم وجود دارد: رابطه clinic ↔ insurance (تسک ۰۶) +- بیمه کاربر در `profiles` ذخیره می‌شود: field_basic_insurance, field_supplementary_insurance (تسک ۰۳) +- هیچ timestamp در این pivot table لازم نیست diff --git a/docs/tasks/task-11-insurance/implementation_notes.md b/docs/tasks/task-11-insurance/implementation_notes.md new file mode 100644 index 00000000..56f2faa5 --- /dev/null +++ b/docs/tasks/task-11-insurance/implementation_notes.md @@ -0,0 +1,17 @@ +# نکات پیاده‌سازی — تسک ۱۱: ماژول بیمه + +## رابطه با Categories +این ماژول از جدول `categories` برای نام و نوع بیمه استفاده می‌کند. +هنگام create، فقط `insurance_category_id` کافی است. + +## مجوزها +``` +POST → دکتر (برای خودش) یا ROLE_ADMIN +GET → دکتر مرتبط یا ROLE_ADMIN +PATCH → دکتر مرتبط یا ROLE_ADMIN +DELETE → دکتر مرتبط یا ROLE_ADMIN +``` + +## یکپارچگی با لیست دکتر +در endpoint `GET /api/v1/doctors`، بیمه‌های هر دکتر باید +به عنوان فیلد در response ظاهر شوند. diff --git a/docs/tasks/task-11-insurance/task.md b/docs/tasks/task-11-insurance/task.md new file mode 100644 index 00000000..3e2da51a --- /dev/null +++ b/docs/tasks/task-11-insurance/task.md @@ -0,0 +1,24 @@ +# تسک ۱۱: ماژول بیمه + +## توضیح +مدیریت بیمه‌های مرتبط با دکتر یا کلینیک (بیمه‌هایی که دکتر می‌پذیرد). + +## Endpoint ها + +| متد | مسیر | توضیح | نیاز به Auth | +|-----|------|-------|-------------| +| POST | `/api/v1/insurance/` | ایجاد رابطه بیمه | بله (Doctor/Admin) | +| GET | `/api/v1/insurance/{id}` | دریافت اطلاعات بیمه | بله | +| PATCH | `/api/v1/insurance/{id}` | ویرایش بیمه | بله | +| DELETE | `/api/v1/insurance/{id}` | حذف بیمه | بله | + +## پیش‌نیازها +- تسک ۰۱، ۰۲، ۰۵ (Doctor)، ۰۸ (Categories) + +## زمان تخمینی +۳ تا ۴ ساعت + +## توضیح +این ماژول مشخص می‌کند که یک دکتر کدام بیمه‌ها را می‌پذیرد. +داده‌های اصلی بیمه در ماژول Categories هستند (تسک ۰۸). +این جدول رابطه دکتر ↔ بیمه را ذخیره می‌کند. diff --git a/docs/tasks/task-12-rating-comment/architecture.md b/docs/tasks/task-12-rating-comment/architecture.md new file mode 100644 index 00000000..b19ce1d5 --- /dev/null +++ b/docs/tasks/task-12-rating-comment/architecture.md @@ -0,0 +1,89 @@ +# معماری — تسک ۱۲: ماژول امتیاز و نظرات + +## ساختار فایل‌ها +``` +src/Module/Rating/ +├── Controller/ +│ ├── RatingController.php +│ └── CommentController.php +├── Service/ +│ ├── RatingService.php ← آپدیت average_rating دکتر +│ └── CommentService.php +├── Repository/ +│ ├── RatingRepository.php +│ └── CommentRepository.php +├── Entity/ +│ ├── Rating.php +│ └── Comment.php +├── DTO/ +│ ├── Request/ +│ │ ├── CreateRatingRequest.php +│ │ ├── CreateCommentRequest.php +│ │ └── ConfirmCommentRequest.php +│ └── Response/ +│ ├── RatingResponse.php +│ └── CommentResponse.php +└── Voter/ + ├── RatingVoter.php + └── CommentVoter.php +``` + +## Entity: Rating +```php +#[ORM\Entity] +#[ORM\Table(name: 'ratings')] +class Rating +{ + #[ORM\Id, ORM\GeneratedValue, ORM\Column] + private int $id; + + #[ORM\Column(type: UuidType::NAME, unique: true)] + private Uuid $uuid; + + #[ORM\ManyToOne(targetEntity: User::class)] + private User $patient; + + #[ORM\ManyToOne(targetEntity: Doctor::class)] + private Doctor $doctor; + + #[ORM\Column(type: 'integer')] + private int $score; // 1 تا 5 + + #[ORM\OneToOne(targetEntity: Appointment::class, nullable: true)] + private ?Appointment $appointment; + + // TimestampableTrait +} +``` + +## Entity: Comment +```php +#[ORM\Entity] +#[ORM\Table(name: 'comments')] +class Comment +{ + #[ORM\Id, ORM\GeneratedValue, ORM\Column] + private int $id; + + #[ORM\Column(type: UuidType::NAME, unique: true)] + private Uuid $uuid; + + #[ORM\ManyToOne(targetEntity: User::class)] + private User $author; + + #[ORM\ManyToOne(targetEntity: Doctor::class)] + private Doctor $doctor; + + #[ORM\Column(type: 'text')] + private string $text; + + // pending, approved, rejected + #[ORM\Column(length: 20, default: 'pending')] + private string $status; + + #[ORM\ManyToOne(targetEntity: Rating::class, nullable: true)] + private ?Rating $rating; + + // TimestampableTrait +} +``` diff --git a/docs/tasks/task-12-rating-comment/database.md b/docs/tasks/task-12-rating-comment/database.md new file mode 100644 index 00000000..0061ba20 --- /dev/null +++ b/docs/tasks/task-12-rating-comment/database.md @@ -0,0 +1,71 @@ +# پایگاه داده — تسک ۱۲: ماژول امتیاز و نظرات + +## مهم: نام فیلد از DB +فیلد ستاره در Drupal **`field_starts`** است (نه `field_stars`!) — از config تأیید شد. + +## جدول: ratings +_(entity_type=clinic_pro_comment, bundle=rate)_ + +| ستون | نوع | نام Drupal | توضیح | +|------|-----|-----------|-------| +| id | INT UNSIGNED AUTO_INCREMENT PK | id | | +| uuid | CHAR(36) UNIQUE NOT NULL | uuid | | +| user_id | INT FK → users.id NOT NULL | uid | امتیاز‌دهنده | +| doctor_id | INT FK → doctors.id NOT NULL | field_doctor_id | entity ref → clinic_pro | +| doctor_behavior | INT NOT NULL | field_doctor_behavior | برخورد مناسب (0-100) | +| accuracy_of_diagnosis | INT NOT NULL | field_accuracy_of_diagnosis | تشخیص درست (0-100) | +| waiting_time_at_clinic | INT NOT NULL | field_waiting_time_at_clinic | زمان انتظار (0-100) | +| doctor_expertise | INT NOT NULL | field_doctor_expertise | مهارت (0-100) | +| clinic_cleanliness | INT NOT NULL | field_clinic_cleanliness | نظافت (0-100) | +| starts | DECIMAL(10,2) NOT NULL | field_starts | ستاره محاسبه‌شده (0-5) — ⚠️ `starts` نه `stars`! | +| percent | FLOAT NOT NULL | field_percent | درصد محاسبه‌شده (0-100) | +| created_at | INT NOT NULL | created | Unix timestamp | +| updated_at | INT NOT NULL | changed | Unix timestamp | + +## جدول: comments +_(entity_type=clinic_pro_comment, bundle=comments)_ + +| ستون | نوع | نام Drupal | توضیح | +|------|-----|-----------|-------| +| id | INT UNSIGNED AUTO_INCREMENT PK | id | | +| uuid | CHAR(36) UNIQUE NOT NULL | uuid | | +| user_id | INT FK → users.id NOT NULL | uid | نویسنده | +| doctor_id | INT FK → doctors.id NOT NULL | field_doctor_id | دکتر | +| comment | LONGTEXT NOT NULL | field_comment | متن نظر | +| approved | TINYINT(1) DEFAULT 0 | field_approved | تأیید شده (نه ENUM بلکه boolean) | +| parent_id | INT FK → comments.id NULL | field_parent | نظر پدر (پاسخ به نظر) | +| created_at | INT NOT NULL | created | Unix timestamp | +| updated_at | INT NOT NULL | changed | Unix timestamp | + +## جدول: likes +_(entity_type=clinic_pro_comment, bundle=like)_ + +| ستون | نوع | نام Drupal | توضیح | +|------|-----|-----------|-------| +| id | INT UNSIGNED AUTO_INCREMENT PK | id | | +| uuid | CHAR(36) UNIQUE NOT NULL | uuid | | +| user_id | INT FK → users.id NOT NULL | uid | کاربر | +| comment_id | INT FK → comments.id NOT NULL | field_comment_id | نظر مورد لایک | +| is_like | TINYINT(1) NOT NULL | field_like | لایک (1) یا دیس‌لایک (0) | +| created_at | INT NOT NULL | created | Unix timestamp | +| updated_at | INT NOT NULL | changed | Unix timestamp | + +## ایندکس‌ها +```sql +-- هر کاربر فقط یک امتیاز برای هر دکتر +CREATE UNIQUE INDEX idx_ratings_user_doctor ON ratings(user_id, doctor_id); + +-- هر کاربر فقط یک لایک/دیس‌لایک برای هر نظر +CREATE UNIQUE INDEX idx_likes_user_comment ON likes(user_id, comment_id); + +CREATE INDEX idx_ratings_doctor ON ratings(doctor_id); +CREATE INDEX idx_comments_doctor_approved ON comments(doctor_id, approved); +CREATE INDEX idx_comments_parent ON comments(parent_id); +``` + +## نکته‌های مهم +- فیلد ستاره `starts` است (نه `stars`) — همان‌طور که در config تأیید شد +- `approved` boolean است (0/1)، نه ENUM +- `percent` نوع FLOAT است (نه DECIMAL) — از config تأیید شد +- `starts` نوع DECIMAL(10,2) است — از config تأیید شد +- `comment.status` در جدول پایه Drupal وجود دارد (tinyint) اما از `field_approved` استفاده می‌شود diff --git a/docs/tasks/task-12-rating-comment/implementation_notes.md b/docs/tasks/task-12-rating-comment/implementation_notes.md new file mode 100644 index 00000000..9faa3979 --- /dev/null +++ b/docs/tasks/task-12-rating-comment/implementation_notes.md @@ -0,0 +1,164 @@ +# نکات پیاده‌سازی — تسک ۱۲: ماژول امتیاز و نظرات + +## ⚠ تناقض نام فیلدها: API vs DB (بسیار مهم!) + +فیلدهایی که **کلاینت ارسال می‌کند** با نام فیلدهای **پایگاه داده** متفاوت هستند: + +| نام در Request (API) | نام در DB (Drupal field) | توضیح | +|---------------------|------------------------|-------| +| `correct_diagnosis` | `accuracy_of_diagnosis` | دقت تشخیص | +| `doctor_skill` | `doctor_expertise` | مهارت پزشک | +| `behavior_doctor` | `doctor_behavior` | برخورد پزشک | +| `office_cleaning` | `clinic_cleanliness` | نظافت مطب | +| `time_in_office` | `waiting_time_at_clinic` | زمان انتظار | +| `doctor` | `doctor_id` | شناسه دکتر (integer) | +| `rate` | `starts` | امتیاز ستاره‌ای (DECIMAL 10,2) | + +**در Symfony باید:** +- ورودی را با نام‌های API دریافت کن (`correct_diagnosis`, ...) +- در Entity و DB با نام‌های Drupal ذخیره کن (`accuracy_of_diagnosis`, ...) + +## نمونه واقعی Request — POST /api/v1/clinicpro/rate +```json +{ + "correct_diagnosis": 100, + "doctor_skill": 100, + "behavior_doctor": 100, + "office_cleaning": 100, + "time_in_office": 100, + "doctor": 1, + "rate": 2 +} +``` + +## نمونه واقعی Request — PATCH /api/v1/clinicpro/rate/{uuid} +```json +{ + "correct_diagnosis": 50, + "doctor_skill": 60, + "behavior_doctor": 70, + "office_cleaning": 80, + "time_in_office": 90, + "doctor": 1, + "rate": 2 +} +``` + +--- + +## سیستم امتیازدهی وزنی + +Rating در Drupal **5 معیار جداگانه** دارد که هر کدام مقدار 0-100 می‌گیرند +و با وزن‌های متفاوت محاسبه می‌شوند: + +```php +$weights = [ + "doctor_behavior" => 1.5, // behavior_doctor در API + "accuracy_of_diagnosis" => 3.0, // correct_diagnosis در API + "waiting_time_at_clinic" => 1.0, // time_in_office در API + "doctor_expertise" => 2.0, // doctor_skill در API + "clinic_cleanliness" => 1.0, // office_cleaning در API +]; + +// فرمول محاسبه: +$weightedAverage = SUM(value * weight) / SUM(weights); // از 100 +$stars = ($weightedAverage / 100) * 5; // از 5 +``` + +### پیاده‌سازی calculateDoctorRating در Symfony +```php +public function calculateRating(array $apiScores): array +{ + // نگاشت نام‌های API به نام‌های DB + $mapped = [ + 'accuracy_of_diagnosis' => $apiScores['correct_diagnosis'] ?? 0, + 'doctor_expertise' => $apiScores['doctor_skill'] ?? 0, + 'doctor_behavior' => $apiScores['behavior_doctor'] ?? 0, + 'clinic_cleanliness' => $apiScores['office_cleaning'] ?? 0, + 'waiting_time_at_clinic' => $apiScores['time_in_office'] ?? 0, + ]; + + $weights = [ + 'doctor_behavior' => 1.5, + 'accuracy_of_diagnosis' => 3.0, + 'waiting_time_at_clinic' => 1.0, + 'doctor_expertise' => 2.0, + 'clinic_cleanliness' => 1.0, + ]; + + $totalScore = 0.0; + $totalWeight = 0.0; + + foreach ($weights as $key => $weight) { + $totalScore += $mapped[$key] * $weight; + $totalWeight += $weight; + } + + $weightedAverage = $totalWeight > 0 ? $totalScore / $totalWeight : 0; + $stars = ($weightedAverage / 100) * 5; + + return [ + 'percent' => round($weightedAverage, 1), + 'starts' => round(min(5.0, max(0.0, $stars)), 2), + // ⚠️ نام فیلد DB: "starts" است نه "stars"! + ]; +} +``` + +--- + +## آمار دکتر — GET /api/v1/clinicpro-comment/doctor-rate/{doctorUuid} + +``` +URL: /api/v1/clinicpro-comment/doctor-rate/{uuid_دکتر} +Auth: عمومی (بدون احراز هویت) +``` + +```json +{ + "average_stars": 4.3, + "total_rates": 87, + "averages": { + "average_doctor_behavior": 82.1, + "average_accuracy_of_diagnosis": 88.5, + "average_waiting_time_at_clinic": 65.3, + "average_doctor_expertise": 90.2, + "average_clinic_cleanliness": 78.4 + } +} +``` + +--- + +## تأیید نظرات +نظرات با `approved=0` ذخیره می‌شوند. +ادمین آن‌ها را از `GET /api/v1/clinicpro/unverified-comments/{doctorId}` می‌بیند. +سپس با `PATCH /api/v1/clinicpro/unverified-comments/{commentId}` تأیید می‌کند. + +--- + +## اعتبارسنجی مقادیر Rating +هر معیار باید بین 0 تا 100 باشد: +```php +#[Assert\Range(min: 0, max: 100)] +``` + +--- + +## مجوزها +``` +POST /api/v1/clinicpro/rate → احراز هویت‌شده +PATCH /api/v1/clinicpro/rate/{uuid} → owner (هر کاربر فقط یک امتیاز برای هر دکتر) +DELETE /api/v1/rate/doctor/{uuid} → ROLE_ADMIN +GET /api/v1/clinicpro/rate/{uuid} → owner (امتیاز کاربر برای دکتر مشخص) +GET /api/v1/clinicpro-comment/doctor-rate/{uuid} → عمومی (آمار کلی دکتر) +POST /api/v1/clinicpro/comment → احراز هویت‌شده +PATCH /api/v1/clinicpro/comment/{uuid} → owner یا ROLE_ADMIN +DELETE /api/v1/clinicpro/comment/{uuid} → owner یا ROLE_ADMIN +GET /api/v1/clinicpro/comment/{uuid} → احراز هویت‌شده +GET /api/v1/clinicpro/comments/{doctorId} → احراز هویت‌شده (فقط approved) +GET /api/v1/clinicpro/unverified-comments/{doctorId} → ROLE_ADMIN +PATCH /api/v1/clinicpro/unverified-comments/{id} → ROLE_ADMIN (تأیید/رد نظر) +POST /api/v1/clinicpro/like → احراز هویت‌شده +PATCH /api/v1/clinicpro/like/{uuid} → owner +``` diff --git a/docs/tasks/task-12-rating-comment/task.md b/docs/tasks/task-12-rating-comment/task.md new file mode 100644 index 00000000..625b77c6 --- /dev/null +++ b/docs/tasks/task-12-rating-comment/task.md @@ -0,0 +1,99 @@ +# تسک ۱۲: ماژول امتیاز و نظرات + +## توضیح +سیستم امتیازدهی (rate) و نظرات (comment) و لایک کاربران برای دکترها، +شامل تأیید نظرات توسط ادمین. + +## Endpoint ها (واقعی از Drupal) + +### امتیازدهی (Rate) + +| متد | مسیر | توضیح | نیاز به Auth | +|-----|------|-------|-------------| +| POST | `/api/v1/clinicpro/rate` | ثبت امتیاز جدید | بله | +| PATCH | `/api/v1/clinicpro/rate/{uuid}` | ویرایش امتیاز | بله (Owner) | +| DELETE | `/api/v1/rate/doctor/{uuid}` | حذف امتیاز | بله (Admin) | +| GET | `/api/v1/clinicpro/rate/{uuid}` | امتیاز من برای دکتر | بله | +| GET | `/api/v1/clinicpro-comment/doctor-rate/{doctor_uuid}` | آمار کلی امتیازهای دکتر | خیر | + +### نظرات (Comment) + +| متد | مسیر | توضیح | نیاز به Auth | +|-----|------|-------|-------------| +| POST | `/api/v1/clinicpro/comment` | ثبت نظر جدید | بله | +| PATCH | `/api/v1/clinicpro/comment/{uuid}` | ویرایش نظر | بله (Owner/Admin) | +| DELETE | `/api/v1/clinicpro/comment/{uuid}` | حذف نظر | بله (Owner/Admin) | +| GET | `/api/v1/clinicpro/comment/{uuid}` | دریافت یک نظر | بله | +| GET | `/api/v1/clinicpro/comments/{doctorId}` | لیست نظرات دکتر (با page/limit) | بله | +| GET | `/api/v1/clinicpro/unverified-comments/{doctorId}` | نظرات تأییدنشده (با page/limit) | بله (Admin) | +| PATCH | `/api/v1/clinicpro/unverified-comments/{commentId}` | تأیید/رد نظر | بله (Admin) | + +### لایک (Like) + +| متد | مسیر | توضیح | نیاز به Auth | +|-----|------|-------|-------------| +| POST | `/api/v1/clinicpro/like` | ثبت لایک/دیس‌لایک | بله | +| PATCH | `/api/v1/clinicpro/like/{uuid}` | ویرایش لایک | بله (Owner) | + +## پیش‌نیازها +- تسک ۰۱، ۰۲، ۰۵ (Doctor) + +## زمان تخمینی +۸ تا ۱۰ ساعت + +--- + +## ⚠ نام فیلدهای Request (متفاوت از DB!) + +| فیلد در Request | معادل در DB | توضیح | +|----------------|------------|-------| +| `correct_diagnosis` | `accuracy_of_diagnosis` | دقت تشخیص (0-100) | +| `doctor_skill` | `doctor_expertise` | مهارت پزشک (0-100) | +| `behavior_doctor` | `doctor_behavior` | برخورد پزشک (0-100) | +| `office_cleaning` | `clinic_cleanliness` | نظافت مطب (0-100) | +| `time_in_office` | `waiting_time_at_clinic` | زمان انتظار (0-100) | +| `doctor` | `doctor_id` | شناسه دکتر | +| `rate` | `starts` | امتیاز ستاره (ذخیره محاسبه‌شده) | + +--- + +## نمونه واقعی Request — POST /api/v1/clinicpro/rate +```json +{ + "correct_diagnosis": 100, + "doctor_skill": 100, + "behavior_doctor": 100, + "office_cleaning": 100, + "time_in_office": 100, + "doctor": 1, + "rate": 2 +} +``` + +## نمونه واقعی Request — PATCH /api/v1/clinicpro/rate/{uuid} +```json +{ + "correct_diagnosis": 50, + "doctor_skill": 60, + "behavior_doctor": 70, + "office_cleaning": 80, + "time_in_office": 90, + "doctor": 1, + "rate": 2 +} +``` + +## نمونه واقعی Request — PATCH /api/v1/clinicpro/unverified-comments/{uuid} +_(تأیید نظر — بدنه خالی یا فقط `approved`)_ +```json +{} +``` + +--- + +## نکات مهم +- هر کاربر فقط **یک امتیاز** برای هر دکتر می‌تواند ثبت کند (UNIQUE user_id + doctor_id) +- نظرات با `approved=0` ذخیره می‌شوند و باید توسط ادمین تأیید شوند +- لایک فقط برای **نظرات** است (نه بلاگ یا دکتر) +- هر کاربر فقط **یک لایک** برای هر نظر می‌تواند ثبت کند +- URL کامنت‌های تأییدنشده: `{doctorId}` در URL است اما فقط ROLE_ADMIN بررسی می‌شود diff --git a/docs/tasks/task-13-like/architecture.md b/docs/tasks/task-13-like/architecture.md new file mode 100644 index 00000000..3eb63074 --- /dev/null +++ b/docs/tasks/task-13-like/architecture.md @@ -0,0 +1,46 @@ +# معماری — تسک ۱۳: ماژول لایک + +## ساختار فایل‌ها +``` +src/Module/Like/ +├── Controller/ +│ └── LikeController.php +├── Service/ +│ └── LikeService.php +├── Repository/ +│ └── LikeRepository.php +├── Entity/ +│ └── Like.php +└── DTO/ + └── Request/ + └── CreateLikeRequest.php +``` + +## Entity: Like +```php +#[ORM\Entity] +#[ORM\Table(name: 'likes')] +class Like +{ + #[ORM\Id, ORM\GeneratedValue, ORM\Column] + private int $id; + + #[ORM\Column(type: UuidType::NAME, unique: true)] + private Uuid $uuid; + + #[ORM\ManyToOne(targetEntity: User::class)] + private User $user; + + // نوع موجودیت: blog, doctor + #[ORM\Column(length: 30)] + private string $entityType; + + #[ORM\Column(type: 'integer')] + private int $entityId; + + #[ORM\Column(type: 'boolean', default: true)] + private bool $isLiked; + + // TimestampableTrait +} +``` diff --git a/docs/tasks/task-13-like/database.md b/docs/tasks/task-13-like/database.md new file mode 100644 index 00000000..d6dc5a97 --- /dev/null +++ b/docs/tasks/task-13-like/database.md @@ -0,0 +1,33 @@ +# پایگاه داده — تسک ۱۳: ماژول لایک + +## ساختار واقعی از Drupal (از config تأیید شده) + +در Drupal، لایک به عنوان bundle=`like` در entity `clinic_pro_comment` ذخیره می‌شود: +- `field_like` (boolean) → آیا لایک است یا آنلایک +- `field_comment_id` (entity_reference → comment) → لایک مربوط به کدام کامنت + +لایک‌ها **فقط** روی نظرات (comment) هستند، نه blog یا doctor. + +## جدول: likes +_(entity_type=clinic_pro_comment, bundle=like)_ + +| ستون | نوع | نام Drupal | توضیح | +|------|-----|-----------|-------| +| id | INT UNSIGNED AUTO_INCREMENT PK | id | | +| uuid | CHAR(36) UNIQUE NOT NULL | uuid | | +| user_id | INT FK → users.id NOT NULL | uid | کاربری که لایک زده | +| comment_id | INT FK → comments.id NOT NULL | field_comment_id | کامنت مورد نظر | +| is_liked | TINYINT(1) DEFAULT 1 | field_like | 1=لایک، 0=آنلایک | +| created_at | INT NOT NULL | created | Unix timestamp | +| updated_at | INT NOT NULL | changed | Unix timestamp | + +## ایندکس‌ها +```sql +CREATE UNIQUE INDEX idx_likes_user_comment ON likes(user_id, comment_id); +CREATE INDEX idx_likes_comment ON likes(comment_id); +``` + +## نکات مهم +- در Drupal، لایک فقط برای **comment** است (نه blog یا doctor) +- `field_like` boolean است — کاربر می‌تواند لایک (1) یا آنلایک (0) ثبت کند +- UNIQUE(user_id, comment_id) تضمین می‌کند هر کاربر فقط یک بار لایک/آنلایک بزند diff --git a/docs/tasks/task-13-like/implementation_notes.md b/docs/tasks/task-13-like/implementation_notes.md new file mode 100644 index 00000000..d103a7ed --- /dev/null +++ b/docs/tasks/task-13-like/implementation_notes.md @@ -0,0 +1,21 @@ +# نکات پیاده‌سازی — تسک ۱۳: ماژول لایک + +## Toggle Like +PATCH endpoint باید is_liked را toggle کند: +```php +public function toggle(Like $like): void +{ + $like->setIsLiked(!$like->isLiked()); + $this->em->flush(); +} +``` + +## جلوگیری از لایک دوگانه +unique index روی (user_id, entity_type, entity_id) جلوگیری می‌کند. +اگر قبلاً لایک وجود داشت، PATCH برای toggle استفاده می‌شود. + +## مجوزها +``` +POST /like → احراز هویت‌شده +PATCH /like/{uuid} → owner +``` diff --git a/docs/tasks/task-13-like/task.md b/docs/tasks/task-13-like/task.md new file mode 100644 index 00000000..ae0ae2d8 --- /dev/null +++ b/docs/tasks/task-13-like/task.md @@ -0,0 +1,17 @@ +# تسک ۱۳: ماژول لایک + +## توضیح +سیستم لایک برای بلاگ‌ها یا دکترها. + +## Endpoint ها + +| متد | مسیر | توضیح | نیاز به Auth | +|-----|------|-------|-------------| +| POST | `/api/v1/clinicpro/like` | ثبت لایک | بله | +| PATCH | `/api/v1/clinicpro/like/{uuid}` | ویرایش/حذف لایک (toggle) | بله | + +## پیش‌نیازها +- تسک ۰۱، ۰۲ + +## زمان تخمینی +۲ تا ۳ ساعت diff --git a/docs/tasks/task-14-secretary/architecture.md b/docs/tasks/task-14-secretary/architecture.md new file mode 100644 index 00000000..00e5213f --- /dev/null +++ b/docs/tasks/task-14-secretary/architecture.md @@ -0,0 +1,50 @@ +# معماری — تسک ۱۴: ماژول منشی + +## ساختار فایل‌ها +``` +src/Module/Secretary/ +├── Controller/ +│ └── SecretaryController.php +├── Service/ +│ └── SecretaryService.php +├── Repository/ +│ └── SecretaryRepository.php +├── Entity/ +│ └── Secretary.php +├── DTO/ +│ ├── Request/ +│ │ ├── CreateSecretaryRequest.php +│ │ └── UpdateSecretaryRequest.php +│ └── Response/ +│ └── SecretaryResponse.php +└── Voter/ + └── SecretaryVoter.php +``` + +## Entity: Secretary +```php +#[ORM\Entity] +#[ORM\Table(name: 'secretaries')] +class Secretary +{ + #[ORM\Id, ORM\GeneratedValue, ORM\Column] + private int $id; + + #[ORM\Column(type: UuidType::NAME, unique: true)] + private Uuid $uuid; + + #[ORM\ManyToOne(targetEntity: User::class)] + private User $user; // حساب کاربری منشی + + #[ORM\ManyToOne(targetEntity: Doctor::class)] + private Doctor $doctor; // دکتر مربوطه + + #[ORM\Column(length: 20, default: 'active')] + private string $status; + + #[ORM\Column(type: 'json', nullable: true)] + private ?array $permissions; // ['manage_appointments', 'view_payments', ...] + + // TimestampableTrait +} +``` diff --git a/docs/tasks/task-14-secretary/database.md b/docs/tasks/task-14-secretary/database.md new file mode 100644 index 00000000..66200fb5 --- /dev/null +++ b/docs/tasks/task-14-secretary/database.md @@ -0,0 +1,31 @@ +# پایگاه داده — تسک ۱۴: ماژول منشی + +## جدول: doctor_secretaries +_(entity_type=clinic_pro, bundle=doctor_secretary — از config تأیید شده)_ + +| ستون | نوع | نام Drupal | توضیح | +|------|-----|-----------|-------| +| id | INT UNSIGNED AUTO_INCREMENT PK | id | | +| uuid | CHAR(36) UNIQUE NOT NULL | uuid | | +| user_id | INT FK → users.id NOT NULL | uid | مالک رکورد | +| doctor_id | INT FK → doctors.id NOT NULL | field_doctor | دکتر (entity ref → clinic_pro/doctor) | +| secretary_id | INT FK → users.id NOT NULL | field_secretary | منشی (entity ref → user) | +| telephone | VARCHAR(50) NULL | field_telephone | تلفن تماس منشی | +| permission | LONGTEXT NULL | field_permission | مجوزها (JSON یا متن) | +| active | TINYINT(1) DEFAULT 1 | field_active | فعال/غیرفعال (نه status VARCHAR!) | +| created_at | INT NOT NULL | created | Unix timestamp | +| updated_at | INT NOT NULL | changed | Unix timestamp | + +## ایندکس‌ها +```sql +CREATE UNIQUE INDEX idx_secretary_doctor_user ON doctor_secretaries(doctor_id, secretary_id); +CREATE INDEX idx_secretary_doctor ON doctor_secretaries(doctor_id); +CREATE INDEX idx_secretary_user ON doctor_secretaries(secretary_id); +``` + +## نکات مهم +- فیلد وضعیت: `active` (TINYINT boolean) — نه `status` با مقادیر string +- `field_permission` نوع string_long است (LONGTEXT)، می‌تواند JSON یا متن ساده باشد +- `doctor_id` → FK به doctors.id (نه clinic_pro.id) — در Symfony به entity doctor اشاره می‌کند +- `secretary_id` → FK به users.id — کاربری که نقش منشی دارد +- UNIQUE(doctor_id, secretary_id): یک منشی نمی‌تواند دو بار برای یک دکتر ثبت شود diff --git a/docs/tasks/task-14-secretary/implementation_notes.md b/docs/tasks/task-14-secretary/implementation_notes.md new file mode 100644 index 00000000..082821e5 --- /dev/null +++ b/docs/tasks/task-14-secretary/implementation_notes.md @@ -0,0 +1,25 @@ +# نکات پیاده‌سازی — تسک ۱۴: ماژول منشی + +## نقش کاربری +هنگام ایجاد secretary، نقش `ROLE_SECRETARY` به user مرتبط اضافه می‌شود. +هنگام حذف، نقش را remove کن (اگر منشی دکتر دیگری نیست). + +## مجوزهای منشی +```json +{ + "permissions": [ + "manage_appointments", // مدیریت نوبت‌ها + "view_payments", // مشاهده پرداخت‌ها + "manage_schedule" // مدیریت برنامه + ] +} +``` + +## مجوزها در سیستم +``` +POST → دکتر (برای خودش) یا ROLE_ADMIN +PATCH → دکتر مرتبط یا ROLE_ADMIN +DELETE → دکتر مرتبط یا ROLE_ADMIN +GET → دکتر مرتبط، خود منشی، یا ROLE_ADMIN +GET list → دکتر مرتبط یا ROLE_ADMIN +``` diff --git a/docs/tasks/task-14-secretary/task.md b/docs/tasks/task-14-secretary/task.md new file mode 100644 index 00000000..f7abca64 --- /dev/null +++ b/docs/tasks/task-14-secretary/task.md @@ -0,0 +1,313 @@ +# تسک ۱۴: ماژول منشی + +## توضیح +مدیریت منشی‌های دکترها که می‌توانند نوبت‌ها و پرداخت‌ها را مدیریت کنند. +هر دکتر بسته به پلن اشتراک می‌تواند ۱ یا ۳ منشی فعال داشته باشد. + +## Endpoint ها + +| متد | مسیر | توضیح | نیاز به Auth | +|-----|------|-------|-------------| +| POST | `/api/v1/secretary` | ایجاد منشی | بله (Doctor/Admin) | +| PATCH | `/api/v1/secretary/{uuid}` | ویرایش منشی | بله (Doctor/Admin) | +| GET | `/api/v1/secretary/{uuid}` | دریافت اطلاعات منشی | بله | +| DELETE | `/api/v1/secretary/{uuid}` | حذف منشی | بله (Doctor/Admin) | +| GET | `/api/v1/secretaries/{doctorUuid}` | لیست منشی‌های دکتر | بله | + +## پیش‌نیازها +- تسک ۰۱، ۰۲، ۰۵ (Doctor) + +## زمان تخمینی +۵ تا ۶ ساعت + +--- + +## سیستم مجوزها — Resource-Based Permissions (مقیاس‌پذیر) + +فیلد `permissions` در جدول `doctor_secretaries` یک JSON ساختاریافته با نسخه‌بندی است. +طراحی به گونه‌ای است که در آینده بتوان منابع (`resources`) و عملیات (`actions`) جدید اضافه کرد بدون تغییر در ساختار جدول. + +### ساختار JSON + +```json +{ + "version": 1, + "resources": { + "appointments": { + "view": true, + "create": true, + "cancel": false, + "update_status": true + }, + "addresses": { + "view": true, + "create": true, + "update": true, + "delete": false + }, + "clinic_info": { + "view": true, + "update": false + }, + "insurances": { + "view": true, + "create": true, + "update": true, + "delete": false + } + } +} +``` + +### منابع و عملیات فعلی + +| Resource | Actions | توضیح | +|----------|---------|-------| +| `appointments` | `view`, `create`, `cancel`, `update_status` | نوبت‌ها | +| `addresses` | `view`, `create`, `update`, `delete` | آدرس‌های مطب/کلینیک | +| `clinic_info` | `view`, `update` | اطلاعات مطب یا کلینیک | +| `insurances` | `view`, `create`, `update`, `delete` | بیمه‌ها | + +### مقیاس‌پذیری — اضافه کردن Resource جدید در آینده + +برای اضافه کردن Resource جدید (مثلاً `patients` یا `reports`) فقط کافی است: +1. کلید جدید به JSON اضافه شود — بدون migration جدید +2. کد Permission Checker به صورت خودکار آن را پشتیبانی می‌کند +3. منشی‌های موجود که کلید جدید را ندارند، به صورت پیش‌فرض `false` دارند + +### پیاده‌سازی PHP — SecretaryPermissionChecker + +```php +// src/Secretary/Security/SecretaryPermissionChecker.php +class SecretaryPermissionChecker +{ + /** + * بررسی مجوز منشی برای یک عملیات روی یک منبع + * مثال: $checker->can($secretary, 'appointments', 'create') + */ + public function can(Secretary $secretary, string $resource, string $action): bool + { + if (!$secretary->isActive()) { + return false; + } + + $permissions = $secretary->getPermissions(); + return (bool) ($permissions['resources'][$resource][$action] ?? false); + } + + /** + * بررسی دسترسی کامل به یک منبع (همه actions باید true باشند) + */ + public function canAll(Secretary $secretary, string $resource, array $actions): bool + { + return array_reduce( + $actions, + fn($carry, $action) => $carry && $this->can($secretary, $resource, $action), + true + ); + } +} +``` + +**مثال استفاده در Controller:** +```php +// در AppointmentController +if (!$this->permissionChecker->can($secretary, 'appointments', 'create')) { + throw new AccessDeniedHttpException('منشی مجاز به ثبت نوبت نیست'); +} + +// در InsuranceController +if (!$this->permissionChecker->can($secretary, 'insurances', 'delete')) { + throw new AccessDeniedHttpException('منشی مجاز به حذف بیمه نیست'); +} +``` + +### پیش‌فرض هنگام ایجاد منشی + +```json +{ + "version": 1, + "resources": { + "appointments": { + "view": true, + "create": true, + "cancel": false, + "update_status": true + }, + "addresses": { + "view": true, + "create": false, + "update": false, + "delete": false + }, + "clinic_info": { + "view": true, + "update": false + }, + "insurances": { + "view": true, + "create": false, + "update": false, + "delete": false + } + } +} +``` + +--- + +## POST /api/v1/secretary + +```json +// Request +{ + "mobile_number": "09120671756", + "doctor_uuid": "61be915b-...", + "permissions": { + "version": 1, + "resources": { + "appointments": { + "view": true, + "create": true, + "cancel": false, + "update_status": true + }, + "addresses": { + "view": true, + "create": false, + "update": false, + "delete": false + }, + "clinic_info": { + "view": true, + "update": false + }, + "insurances": { + "view": true, + "create": false, + "update": false, + "delete": false + } + } + } +} + +// Response 201 +{ + "success": true, + "data": { + "uuid": "...", + "user": { "uuid": "...", "realname": "فاطمه رضایی", "mobile": "09120671756" }, + "doctor": { "uuid": "...", "name": "دکتر احمدی" }, + "active": true, + "permissions": { ... }, + "created_at": 1748000000 + } +} + +// Response 422 — حد مجاز منشی +{ + "success": false, + "errors": [{ "code": "ERR_SECRETARY_001", "message": "پلن فعلی اجازه منشی بیشتر را نمی‌دهد" }] +} +``` + +**قانون بررسی پلن (سمت سرور):** +``` +پلن بیسیک → max 1 منشی فعال +پلن پیشرفته → max 3 منشی فعال + +هنگام POST /secretary: + activeCount = COUNT(*) WHERE doctor_id=X AND active=true + if activeCount >= maxAllowed → 422 +``` + +--- + +## PATCH /api/v1/secretary/{uuid} + +```json +// Request (فقط resources موردنظر — deep merge با پیش‌فرض‌ها) +{ + "active": false, + "permissions": { + "resources": { + "insurances": { + "create": true, + "update": true + } + } + } +} +// نکته: فقط resources/actions ارسال‌شده تغییر می‌کنند — بقیه دست‌نخورده می‌مانند +``` + +// Response 200 +{ + "success": true, + "data": { ... } +} +``` + +--- + +## GET /api/v1/secretary/{uuid} + +```json +{ + "success": true, + "data": { + "uuid": "...", + "user": { + "uuid": "...", + "realname": "فاطمه رضایی", + "mobile": "09120671756", + "picture": null + }, + "doctor": { "uuid": "...", "name": "دکتر احمدی" }, + "active": true, + "permissions": { + "version": 1, + "resources": { + "appointments": { "view": true, "create": true, "cancel": false, "update_status": true }, + "addresses": { "view": true, "create": false, "update": false, "delete": false }, + "clinic_info": { "view": true, "update": false }, + "insurances": { "view": true, "create": false, "update": false, "delete": false } + } + }, + "created_at": 1748000000 + } +} +``` + +--- + +## GET /api/v1/secretaries/{doctorUuid} + +```json +{ + "success": true, + "data": [ + { + "uuid": "...", + "user": { "uuid": "...", "realname": "فاطمه رضایی", "mobile": "09120671756" }, + "active": true, + "permissions": { ... }, + "created_at": 1748000000 + } + ] +} +``` + +--- + +## نکات مهم + +- **کاربر منشی:** هنگام ایجاد منشی با mobile_number، ابتدا بررسی می‌شود آیا کاربر با این شماره وجود دارد — اگر نه، کاربر جدید ایجاد می‌شود +- **ROLE:** کاربر منشی باید role `doctor_s_secretary` داشته باشد +- **لاگین منشی:** منشی می‌تواند با username/password لاگین کند (تسک ۰۲) +- **بررسی پلن:** کاملاً سمت سرور انجام می‌شود، قابل دور زدن نیست +- **نوبت آفلاین:** نوبتی که منشی ثبت می‌کند (`appointments.create`) کمیسیون نماینده ندارد +- **PATCH permissions:** فقط resources/actions ارسال‌شده تغییر می‌کنند (deep merge) — بقیه دست‌نخورده +- **Resource ناشناخته:** اگر resource جدیدی در JSON باشد که سرور نمی‌شناسد، نادیده گرفته می‌شود (forward compat) +- **پیش‌فرض `false`:** اگر resource یا action در JSON وجود نداشته باشد → `false` (deny by default) diff --git a/docs/tasks/task-15-payment/architecture.md b/docs/tasks/task-15-payment/architecture.md new file mode 100644 index 00000000..1c8a379c --- /dev/null +++ b/docs/tasks/task-15-payment/architecture.md @@ -0,0 +1,67 @@ +# معماری — تسک ۱۵: ماژول پرداخت + +## ساختار فایل‌ها +``` +src/Module/Payment/ +├── Controller/ +│ ├── PaymentController.php ← ایجاد و دریافت پرداخت +│ └── PaymentCallbackController.php ← callback درگاه پرداخت +├── Service/ +│ ├── PaymentService.php +│ └── Gateway/ +│ ├── PaymentGatewayInterface.php +│ ├── ZarinpalGateway.php ← درگاه زرین‌پال +│ └── NullGateway.php ← برای محیط dev +├── Repository/ +│ └── PaymentRepository.php +├── Entity/ +│ └── Payment.php +├── DTO/ +│ ├── Request/ +│ │ └── CreatePaymentRequest.php +│ └── Response/ +│ └── PaymentResponse.php +└── Voter/ + └── PaymentVoter.php +``` + +## Entity: Payment +```php +#[ORM\Entity] +#[ORM\Table(name: 'payments')] +class Payment +{ + #[ORM\Id, ORM\GeneratedValue, ORM\Column] + private int $id; + + #[ORM\Column(type: UuidType::NAME, unique: true)] + private Uuid $uuid; + + #[ORM\OneToOne(targetEntity: Appointment::class)] + private Appointment $appointment; + + #[ORM\ManyToOne(targetEntity: User::class)] + private User $user; + + #[ORM\Column(type: 'integer')] + private int $amount; // ریال + + // pending, paid, failed, refunded + #[ORM\Column(length: 20, default: 'pending')] + private string $status; + + #[ORM\Column(length: 30, nullable: true)] + private ?string $paymentMethod; // online, cash, insurance + + #[ORM\Column(length: 100, nullable: true)] + private ?string $gatewayToken; // توکن درگاه + + #[ORM\Column(length: 50, nullable: true)] + private ?string $referenceCode; // کد پیگیری + + #[ORM\Column(type: 'datetime_immutable', nullable: true)] + private ?\DateTimeImmutable $paidAt; + + // TimestampableTrait +} +``` diff --git a/docs/tasks/task-15-payment/database.md b/docs/tasks/task-15-payment/database.md new file mode 100644 index 00000000..b2e6793c --- /dev/null +++ b/docs/tasks/task-15-payment/database.md @@ -0,0 +1,107 @@ +# پایگاه داده — تسک ۱۵: ماژول پرداخت + +## جدول: payments +_(entity_type=payment, bundle=appointment — از DB backup تأیید شده)_ + +| ستون | نوع | نام Drupal | توضیح | +|------|-----|-----------|-------| +| id | INT UNSIGNED AUTO_INCREMENT PK | id | | +| uuid | CHAR(36) UNIQUE NOT NULL | uuid | | +| user_id | INT FK → users.id NOT NULL | uid | پرداخت‌کننده | +| appointment_id | INT FK → appointments.id UNIQUE NULL | field_reference_id | entity ref → appointment | +| representation_id | INT FK → representations.id NULL | field_representation | entity ref → clinic_pro | +| amount | INT NOT NULL | field_amount | مبلغ به **ریال** (نه تومان) — تایپ INT | +| status | VARCHAR(20) DEFAULT 'pending' | field_status | وضعیت | +| payment_method | VARCHAR(20) NULL | field_payment_method | روش پرداخت | +| ref_id | VARCHAR(100) NULL | field_ref_id | SaleReferenceId بانک | +| frontend_address | VARCHAR(150) NULL | field_frontend_address | URL فرانت برای redirect | +| payment_time | INT NULL | field_payment_time | زمان پرداخت (Unix timestamp) | +| card_info | LONGTEXT NULL | field_card_info | اطلاعات کارت (JSON/text) | +| created_at | INT NOT NULL | created | Unix timestamp | +| updated_at | INT NOT NULL | changed | Unix timestamp | + +## وضعیت‌های پرداخت (field_status) — از config +``` +pending → ایجاد شده، منتظر پرداخت +received → پرداخت موفق تأیید شده ⚠️ نه 'paid'! +refund → مبلغ برگشت خورده +canceled → لغو شده (نه 'failed') +``` + +## روش‌های پرداخت (field_payment_method) — از config +``` +mellat → بانک ملت (SOAP) +sep → بانک سامان (SEP) +``` + +## نمونه داده واقعی از DB backup +``` +id=2, uid=33, amount=100000 (ریال), status=pending, method=mellat +frontend_address='http://yasuj-nobat.localhost:3000/' +``` + +## ایندکس‌ها +```sql +CREATE UNIQUE INDEX idx_payments_appointment ON payments(appointment_id); +CREATE INDEX idx_payments_user ON payments(user_id); +CREATE INDEX idx_payments_status ON payments(status); +CREATE INDEX idx_payments_representation ON payments(representation_id); +CREATE INDEX idx_payments_ref_id ON payments(ref_id); +``` + +## وابستگی وضعیت appointment +``` +appointments.status = 'waiting_for_payment' → پیش‌نیاز ایجاد payment +بعد از پرداخت موفق: + payments.status = 'received' + appointments.status = 'reserved' + payment_time = Unix timestamp الان +``` + +## نکات مهم +- `amount` نوع **INT** است (نه DECIMAL) — از DB backup تأیید شد (مثال: 100000) +- مبلغ در **ریال** ذخیره می‌شود +- `field_reference_id` → entity reference به appointment (نه foreign key مستقیم در جدول payment) +- `frontend_address` برای redirect بعد از پرداخت به سایت نماینده است +- `card_info` برای ذخیره اطلاعات کارت بانکی (مثلاً شماره کارت ماسک‌شده) + +--- + +## جدول: subscription_payments (پرداخت اشتراک) +_(از بخش ۲.۱۰.۲ مستند — نوع پرداخت مجزا از پرداخت نوبت)_ + +| ستون | نوع | توضیح | +|------|-----|-------| +| id | INT UNSIGNED AUTO_INCREMENT PK | | +| uuid | CHAR(36) UNIQUE NOT NULL | | +| user_id | INT FK → users.id NOT NULL | کاربر سابسکرایب‌کننده | +| reference_type | VARCHAR(10) NOT NULL | `doctor` یا `clinic` | +| reference_id | INT NOT NULL | FK به doctors.id یا clinics.id | +| representation_id | INT FK → representations.id NULL | نماینده (در صورت وجود) | +| amount | INT NOT NULL | مبلغ اشتراک (ریال) | +| payment_method | VARCHAR(20) NULL | روش پرداخت (mellat, sep, ...) | +| payment_time | INT NULL | زمان پرداخت (Unix timestamp) | +| start_date | INT NOT NULL | تاریخ شروع اشتراک (Unix timestamp) | +| expiration_date | INT NOT NULL | تاریخ انقضای اشتراک (Unix timestamp) | +| ref_id | VARCHAR(100) NULL | شماره مرجع درگاه بانکی | +| card_info | LONGTEXT NULL | اطلاعات کارت بانکی (JSON) | +| frontend_address | VARCHAR(150) NULL | آدرس بازگشت پس از پرداخت | +| status | VARCHAR(20) DEFAULT 'pending' | pending \| received \| refund \| canceled | +| created_at | INT NOT NULL | Unix timestamp | +| updated_at | INT NOT NULL | Unix timestamp | + +## ایندکس‌های subscription_payments +```sql +CREATE INDEX idx_sub_payments_user ON subscription_payments(user_id); +CREATE INDEX idx_sub_payments_ref ON subscription_payments(reference_type, reference_id); +CREATE INDEX idx_sub_payments_status ON subscription_payments(status); +CREATE INDEX idx_sub_payments_expiry ON subscription_payments(expiration_date); +``` + +## تفاوت payments و subscription_payments + +| ویژگی | payments | subscription_payments | +|-------|----------|----------------------| +| مرجع | `appointment_id` | `reference_id` → doctor/clinic | +| فیلدهای اضافه | — | `start_date`, `expiration_date` | +| هدف | پرداخت نوبت | خرید اشتراک پلن | diff --git a/docs/tasks/task-15-payment/implementation_notes.md b/docs/tasks/task-15-payment/implementation_notes.md new file mode 100644 index 00000000..11fe205b --- /dev/null +++ b/docs/tasks/task-15-payment/implementation_notes.md @@ -0,0 +1,99 @@ +# نکات پیاده‌سازی — تسک ۱۵: ماژول پرداخت + +## درگاه‌های واقعی پروژه (از کد Drupal) + +### ۱. بانک ملت (Mellat) — پروتکل SOAP +```php +// وب‌سرویس SOAP با متدهای: +// bpPayRequest → شروع تراکنش +// bpVerifyRequest → تأیید پرداخت +// bpInquiryRequest → استعلام وضعیت +// bpSettleRequest → تسویه +// bpReversalRequest → برگشت تراکنش + +// پارامترهای پیکربندی: +terminal_id, username, password +wsdl_endpoint, gate_url +test_mode (boolean) +callback_url, callback_url_test +``` + +### ۲. SEP (سامان) — درگاه دوم +در `sep_payment/src/Plugin/MyPayment/SepPayment.php` پیاده‌سازی شده. + +### پیاده‌سازی در Symfony +```php +interface PaymentGatewayInterface { + public function pay(array $data): array; // { success, ref_id, gateway_url } + public function verify(array $callbackData, int $orderId): array; + public function refund(array $data): array; +} +``` + +پیکربندی در `.env`: +``` +PAYMENT_GATEWAY=mellat # mellat | sep +MELLAT_TERMINAL_ID=... +MELLAT_USERNAME=... +MELLAT_PASSWORD=... +MELLAT_TEST_MODE=true +``` + +## وضعیت پرداخت (از کد واقعی) +``` +pending → بعد از ایجاد پرداخت +received → بعد از تأیید موفق (نه "paid"!) +failed → پرداخت ناموفق +``` +⚠️ در Drupal status موفق `received` است نه `paid`. + +## شرط ایجاد پرداخت +**appointment باید status=`waiting_for_payment` داشته باشد.** +اگر status متفاوت باشد → 400 error. + +## قیمت از Config (نه Request) +```php +// مبلغ از پیکربندی خوانده می‌شود، نه از request body! +$amount = $config->get('payment.settings')['price']; +``` +→ در `.env` یا config: +``` +APPOINTMENT_PRICE=500000 +``` + +## frontend_address +پرداخت دارای `frontend_address` است — URL فرانت برای redirect بعد از پرداخت. +این به representation مرتبط است (سیستم multi-tenant). + +## Callback Mellat +``` +POST /payment/callback/mellat + RefId=... + ResCode=0 + SaleOrderId=... + SaleReferenceId=... + +جریان: +1. ResCode === '0' باشد +2. bpVerifyRequest → اگر موفق نبود → bpInquiryRequest → اگر موفق نبود → bpReversalRequest +3. bpSettleRequest (resCode='0' یا '45' = قبلاً تسویه شده) +4. appointment.status = confirmed +5. payment.status = received +6. payment.ref_id = SaleReferenceId +``` + +## Idempotency +اگر callback دوبار بیاید، دوبار process نشود: +```php +if ($payment->getStatus() === 'received') { + return; // قبلاً پردازش شده +} +``` + +## مجوزها +``` +POST /payment → احراز هویت‌شده +GET /payment/{uuid} → owner یا ROLE_ADMIN یا دکتر مرتبط +GET /my-payments → owner +GET/POST callback → عمومی (درگاه پرداخت) +``` diff --git a/docs/tasks/task-15-payment/task.md b/docs/tasks/task-15-payment/task.md new file mode 100644 index 00000000..a2a45a86 --- /dev/null +++ b/docs/tasks/task-15-payment/task.md @@ -0,0 +1,285 @@ +# تسک ۱۵: ماژول پرداخت + +## توضیح +مدیریت پرداخت نوبت‌ها از طریق درگاه‌های Mellat و SEP، +callback پرداخت، refund و مشاهده تاریخچه. + +## Endpoint ها + +| متد | مسیر | توضیح | نیاز به Auth | +|-----|------|-------|-------------| +| POST | `/api/v1/payment` | شروع فرآیند پرداخت | بله | +| GET | `/api/v1/payment/{uuid}` | دریافت اطلاعات پرداخت | بله | +| GET | `/api/v1/payment/my-payments/{userId}` | تاریخچه پرداخت‌های من | بله | +| POST | `/api/v1/payment/callback/mellat` | Callback از درگاه ملت | خیر (IP whitelist) | +| POST | `/api/v1/payment/callback/sep` | Callback از درگاه سامان | خیر (IP whitelist) | +| POST | `/api/v1/subscription-payment` | شروع پرداخت اشتراک | بله | +| GET | `/api/v1/subscription-payment/{uuid}` | اطلاعات پرداخت اشتراک | بله | +| POST | `/api/v1/subscription-payment/callback/mellat` | Callback اشتراک ملت | خیر | +| POST | `/api/v1/subscription-payment/callback/sep` | Callback اشتراک سامان | خیر | + +## پیش‌نیازها +- تسک ۰۱، ۰۲، ۱۰ (Appointment) + +## زمان تخمینی +۱۰ تا ۱۲ ساعت + +--- + +## فلوی کامل پرداخت نوبت + +``` +۱. POST /api/v1/payment + ↓ +۲. بررسی: appointment.status == 'waiting_for_payment' ؟ + ↓ (بله) +۳. ایجاد رکورد payment با status=pending + ↓ +۴. فراخوانی PaymentGatewayInterface::initiate(amount, callback_url) + ↓ + ┌──────────────────┬──────────────────┐ + Mellat (SOAP) SEP (REST) + → bpPayRequest → MerchantSendTransaction + → دریافت RefId → دریافت token + ↓ +۵. بازگشت payment_url به کلاینت + ↓ +۶. Redirect کاربر به درگاه بانک + ↓ +۷. [Callback از بانک] + ↓ +۸. POST /api/v1/payment/callback/{gateway} + ↓ +۹. تأیید تراکنش با درگاه (VerifyRequest) + ↓ + ┌─────────────────────────────────────┐ + پرداخت موفق پرداخت ناموفق + ↓ ↓ +payments.status=received payments.status=canceled +appointments.status=reserved appointments.status=waiting_for_payment +واریز کمیسیون نماینده (کاربر می‌تواند مجدداً تلاش کند) + ↓ +Redirect به frontend_address +``` + +--- + +## Strategy Pattern برای درگاه‌ها + +```php +interface PaymentGatewayInterface +{ + public function initiate(int $amount, string $callbackUrl, string $description): GatewayInitResult; + public function verify(string $refId, int $amount): GatewayVerifyResult; + public function getName(): string; // 'mellat' | 'sep' +} + +class MellatGateway implements PaymentGatewayInterface { ... } +class SepGateway implements PaymentGatewayInterface { ... } +``` + +--- + +## POST /api/v1/payment + +```json +// Request +{ + "appointment_uuid": "7b759d2a-...", + "payment_method": "mellat", + "frontend_address": "https://yasuj-nobat.localhost:3000/" +} + +// Response 200 +{ + "success": true, + "data": { + "uuid": "...", + "payment_url": "https://bpm.shaparak.ir/pgwchannel/startpay.mellat?RefId=xxx", + "amount": 500000, + "status": "pending", + "expires_at": 1748001800 + } +} + +// Response 400 — نوبت در وضعیت نامناسب +{ + "success": false, + "errors": [{ "code": "ERR_PAYMENT_003", "message": "وضعیت نوبت برای پرداخت مناسب نیست" }] +} + +// Response 503 — درگاه در دسترس نیست +{ + "success": false, + "errors": [{ "code": "ERR_PAYMENT_001", "message": "درگاه پرداخت در حال حاضر در دسترس نیست" }] +} +``` + +--- + +## POST /api/v1/payment/callback/mellat + +``` +// form-data از بانک +ResCode=0 +SaleOrderId=... +SaleReferenceId=12345678 +``` + +**منطق:** +``` +1. پیدا کردن payment با ref_id مربوطه +2. فراخوانی MellatGateway::verify(SaleReferenceId, amount) +3. اگر موفق: + - payments.status = 'received' + - payments.ref_id = SaleReferenceId + - payments.payment_time = now() + - appointments.status = 'reserved' + - محاسبه و واریز کمیسیون نماینده (async) +4. Redirect به frontend_address + ?status=success +5. اگر ناموفق: + - payments.status = 'canceled' + - Redirect به frontend_address + ?status=failed +``` + +--- + +## GET /api/v1/payment/{uuid} + +```json +{ + "success": true, + "data": { + "uuid": "...", + "appointment": { + "uuid": "...", + "date": "2024-03-20", + "time": "09:00", + "doctor": { "name": "دکتر احمدی" } + }, + "amount": 500000, + "status": "received", + "payment_method": "mellat", + "ref_id": "12345678", + "payment_time": 1748000000, + "created_at": 1748000000 + } +} +``` + +--- + +## فلوی Refund (لغو نوبت بعد از پرداخت) + +``` +PATCH /api/v1/appointment/{uuid}/cancel + ↓ +appointment.status = 'cancelled_by_patient' + ↓ +payment.status = 'refund' + ↓ +ثبت در سیستم — refund واقعی دستی توسط ادمین انجام می‌شود + ↓ +log در سیستم برای پیگیری ادمین +``` + +> **نکته:** Refund خودکار از درگاه در این پروژه پیاده‌سازی نمی‌شود — ادمین به صورت دستی مبلغ را برمی‌گرداند. + +--- + +## Subscription Payment — POST /api/v1/subscription-payment + +```json +// Request +{ + "reference_type": "doctor", + "reference_id": 29, + "plan": "advanced", + "payment_method": "mellat", + "frontend_address": "https://yasuj-nobat.localhost:3000/" +} + +// Response 200 +{ + "success": true, + "data": { + "uuid": "...", + "payment_url": "https://bpm.shaparak.ir/...", + "amount": 5000000, + "plan": "advanced", + "status": "pending" + } +} +``` + +**بعد از تأیید پرداخت اشتراک:** +``` +subscription_payments.status = 'received' +subscription_payments.start_date = now() +subscription_payments.expiration_date = now() + 30 روز (یا 365 روز) +واریز کمیسیون به کیف پول نماینده (اگر از طریق نماینده) +``` + +--- + +## نکات مهم + +- **مبلغ در ریال ذخیره می‌شود** (نه تومان) — مثال: ۵۰,۰۰۰ تومان = ۵۰۰,۰۰۰ ریال +- **وضعیت 'received'** — نه 'paid' (مستقیم از Drupal) +- **Circuit Breaker:** اگر درگاه ۳ بار پشت سر هم fail داشت → به مدت ۵ دقیقه blocked شود +- **Idempotency:** Callback ممکن است چند بار فراخوانی شود — بررسی کنید payment قبلاً verified نشده باشد +- **IP Whitelist:** Callback endpoint ها باید فقط از IP های بانک قابل دسترس باشند + +--- + +## ⚠ امنیت: جلوگیری از Open Redirect + +فیلد `frontend_address` در request می‌تواند توسط مهاجم دستکاری شود تا Callback به یک سایت مخرب redirect کند. + +**راه‌حل — Whitelist دامنه‌های مجاز:** + +```php +// config/packages/payment.yaml (یا .env) +ALLOWED_FRONTEND_HOSTS=yasuj-nobat.localhost,clinicpro.ir,app.clinicpro.ir + +// در PaymentService قبل از ذخیره frontend_address: +private function validateFrontendAddress(string $url): void +{ + $parsed = parse_url($url); + $host = $parsed['host'] ?? ''; + $allowed = explode(',', $this->params->get('allowed_frontend_hosts')); + + if (!in_array($host, $allowed, true)) { + throw new \InvalidArgumentException('آدرس بازگشت مجاز نیست'); + } +} +``` + +**یا روش ساده‌تر:** `frontend_address` را از JWT کاربر یا از `representations.domain_name` بخوان — نه از request body. + +--- + +## ⚠ امنیت: IP Whitelist برای Callback + +```php +// src/Payment/EventSubscriber/PaymentCallbackGuard.php +class PaymentCallbackGuard implements EventSubscriberInterface +{ + private const MELLAT_IPS = ['185.143.233.0/24', '79.175.148.0/24']; + private const SEP_IPS = ['195.146.48.0/24']; + + public function onKernelRequest(RequestEvent $event): void + { + $path = $event->getRequest()->getPathInfo(); + if (!str_contains($path, '/payment/callback/')) return; + + $clientIp = $event->getRequest()->getClientIp(); + $gateway = str_contains($path, 'mellat') ? 'mellat' : 'sep'; + $allowed = $gateway === 'mellat' ? self::MELLAT_IPS : self::SEP_IPS; + + if (!$this->ipInRanges($clientIp, $allowed)) { + throw new AccessDeniedHttpException('IP not allowed for payment callback'); + } + } +} +``` diff --git a/docs/tasks/task-16-representation/architecture.md b/docs/tasks/task-16-representation/architecture.md new file mode 100644 index 00000000..3e4ab311 --- /dev/null +++ b/docs/tasks/task-16-representation/architecture.md @@ -0,0 +1,54 @@ +# معماری — تسک ۱۶: ماژول داشبورد دکتر + +## ساختار فایل‌ها +``` +src/Module/Representation/ +├── Controller/ +│ └── RepresentationController.php +├── Service/ +│ ├── DashboardService.php ← آمار کلی +│ ├── IncomeService.php ← محاسبه درآمد +│ └── AppointmentReportService.php ← گزارش نوبت‌ها +├── Repository/ +│ └── RepresentationRepository.php ← کوئری‌های پیچیده آماری +└── DTO/ + └── Response/ + ├── DashboardResponse.php + ├── YearlyIncomeResponse.php + └── AppointmentListResponse.php +``` + +## نمودار جریان +``` +GET /representation/{uuid} + │ + ▼ +DashboardService + ├─► شمارش appointments (ماه جاری، کل، pending) + ├─► جمع payments (ماه جاری، کل) + └─► average_rating از doctors.average_rating +``` + +## کوئری درآمد سالانه (بر اساس ماه‌های شمسی) +```php +// RepresentationRepository.php +// توجه: محاسبه بر اساس ماه شمسی است، نه میلادی +// JalaliDateService بازه timestamp هر ماه را می‌دهد +public function getMonthIncome(int $representationId, int $startTs, int $endTs): float +{ + return (float) $this->createQueryBuilder('p') + ->select('SUM(p.amount)') + ->where('p.representationId = :repr') + ->andWhere('p.status = :status') + ->andWhere('p.createdAt >= :start') + ->andWhere('p.createdAt < :end') + ->setParameters([ + 'repr' => $representationId, + 'status' => 'received', // نه 'paid'! + 'start' => (new \DateTime())->setTimestamp($startTs), + 'end' => (new \DateTime())->setTimestamp($endTs), + ]) + ->getQuery() + ->getSingleScalarResult() ?? 0.0; +} +``` diff --git a/docs/tasks/task-16-representation/database.md b/docs/tasks/task-16-representation/database.md new file mode 100644 index 00000000..ea647c8a --- /dev/null +++ b/docs/tasks/task-16-representation/database.md @@ -0,0 +1,70 @@ +# پایگاه داده — تسک ۱۶: ماژول Representation + +## جدول: representations +_(entity_type=clinic_pro, bundle=representation — از DB backup تأیید شده)_ + +| ستون | نوع | نام Drupal | توضیح | +|------|-----|-----------|-------| +| id | INT UNSIGNED AUTO_INCREMENT PK | id | | +| uuid | CHAR(36) UNIQUE NOT NULL | uuid | | +| domain_name | VARCHAR(255) UNIQUE NULL | field_domain_name | دامنه سایت نماینده | +| city_id | INT FK → categories.id NULL | field_city | entity ref → category (city bundle) | +| state_id | INT FK → categories.id NULL | field_state | entity ref → category (state bundle) | +| active | TINYINT(1) DEFAULT 1 | field_active | فعال/غیرفعال | +| commission_percent | INT NULL | field_commission_percent | درصد کمیسیون (نوع INT نه decimal) | +| address | LONGTEXT NULL | field_address | آدرس نماینده | +| bank_account | LONGTEXT NULL | field_bank_account | اطلاعات حساب بانکی (JSON/text) | +| created_at | INT NOT NULL | created | Unix timestamp | +| updated_at | INT NOT NULL | changed | Unix timestamp | + +## نمونه داده واقعی (از DB backup) +``` +id=41, bundle='representation', uid=33 +uuid='bc2a0518-f20d-4542-9376-c2b4fd264706' +``` + +## ایندکس‌ها +```sql +CREATE UNIQUE INDEX idx_representations_domain ON representations(domain_name); +CREATE INDEX idx_representations_city ON representations(city_id); +``` + +## روابط با جداول دیگر +- `doctors.representation_id` → `representations.id` (تسک ۰۵) +- `appointments.representation_id` → `representations.id` (تسک ۱۰) +- `payments.representation_id` → `representations.id` (تسک ۱۵) + +## کوئری‌های آماری (بر اساس ماه شمسی) + +### پرداخت‌های یک ماه شمسی (از RepresentationService.php) +```sql +-- startTimestamp و endTimestamp از JalaliDateService می‌آیند +SELECT SUM(p.amount) as total_price, COUNT(p.id) as count +FROM payments p +WHERE p.representation_id = :id + AND p.status = 'received' -- نه 'paid'! + AND p.created_at >= :startTimestamp + AND p.created_at < :endTimestamp +``` + +### بیماران یک نماینده (total_patients) +```sql +SELECT COUNT(DISTINCT a.patient_id) +FROM appointments a +WHERE a.representation_id = :id +``` + +### نوبت‌های امروز +```sql +SELECT COUNT(*) +FROM appointments a +WHERE a.representation_id = :id + AND a.start_time >= :todayStartTimestamp + AND a.start_time < :tomorrowStartTimestamp +``` + +## نکات مهم +- **domain_name** شامل scheme و slash انتها است: `http://yasuj-nobat.localhost:3000/` +- آمار مالی از `payments` با `status='received'` است، نه `paid` +- محاسبه ماه/سال با تقویم **شمسی** انجام می‌شود (نه میلادی) +- `JalaliDateService` باید قبل از این تسک پیاده‌سازی شده باشد diff --git a/docs/tasks/task-16-representation/implementation_notes.md b/docs/tasks/task-16-representation/implementation_notes.md new file mode 100644 index 00000000..85aba774 --- /dev/null +++ b/docs/tasks/task-16-representation/implementation_notes.md @@ -0,0 +1,133 @@ +# نکات پیاده‌سازی — تسک ۱۶: ماژول Representation (داشبورد دکتر) + +## مهم: این ماژول دو نقش دارد +۱. **مدیریت نمایندگی (Multi-tenant)** — هر نماینده یک دامنه دارد +۲. **داشبورد دکتر** — آمار نوبت‌ها، درآمد، بیماران + +## ساختار Representation در Drupal +```php +// فیلدهای entity (clinic_pro, bundle=representation): +field_domain_name // دامنه سایت نماینده (مثل: http://yasuj-nobat.localhost:3000/) +field_city // entity reference به category (شهر) +field_active // boolean +field_commission_percent // درصد کمیسیون + +// دکتر به نماینده از طریق field_representation لینک می‌شود +// نوبت هم با field_representation لینک می‌شود (از HTTP Host) +``` + +## شناسایی نماینده از Host +```php +// در زمان ثبت نوبت: +$host = 'http://yasuj-nobat.localhost:3000/'; +$representation = $this->representationRepo->findByDomainName($host); +// در Symfony: $request->getSchemeAndHttpHost() . '/' +``` + +## endpoint: my-doctor (برای بیمار) +``` +GET /api/v1/representation/my-doctor/{userId} +``` +→ لیست دکترهایی که این کاربر نوبت گرفته را برمی‌گرداند. +فیلد search: `field_representation = $representationId` + +## endpoint: my-appointments (برای نماینده) +``` +GET /api/v1/representation/my-appointments/{representationId} +``` +→ تمام نوبت‌های مرتبط با این نماینده +کوئری روی `appointment.field_representation = $id` + +## endpoint: filter (داشبورد ماهانه) +``` +GET /api/v1/representation/filter/{representationId}?timestamp=... +``` +→ از `timestamp` برای تعیین ماه جاری شمسی استفاده می‌کند + +```php +// محاسبه بازه ماه جاری شمسی: +$persianMonthRange = $jalaliService->getCurrentPersianMonthRange($timestamp); +$startTimestamp = $persianMonthRange['start']; + +// آمار پرداخت‌ها در این ماه: +// field_status = 'received' AND field_representation = $id AND created >= $startTimestamp +``` + +Response: +```json +{ + "payments_total": { "total_price": 12500000, "count": 25 }, + "total_patients": 142, + "today_appointments": 8 +} +``` + +## endpoint: yearly-income (درآمد سالانه) +``` +GET /api/v1/representation/yearly-income/{id}?timestamp=... +``` + +این endpoint از تقویم **شمسی (جلالی)** استفاده می‌کند: +```php +// محاسبه بازه سال شمسی: +$persianYearRange = $jalaliService->getPersianYearRange($timestamp); +$jalaliYear = $persianYearRange['year']; + +// Loop از ماه ۱ تا ۱۲ شمسی: +for ($month = 1; $month <= 12; $month++) { + $monthRange = $this->getMonthRangeByYearAndMonth($jalaliYear, $month); + $income = $this->getMonthIncome($representationId, $monthRange['start'], $monthRange['end']); + // income = SUM(payment.amount) WHERE status='received' AND representation=$id AND created BETWEEN start AND end +} +``` + +Response: +```json +{ + "year": 1403, + "monthly_income": [ + { "month": 1, "income": 8500000 }, + { "month": 2, "income": 9200000 }, + ... + { "month": 12, "income": 0 } + ] +} +``` + +## JalaliDateService پیاده‌سازی در Symfony +از کد `custom_service/src/jalali/JalaliDateService.php` برای پیاده‌سازی استفاده کن. +توابع مورد نیاز: +```php +class JalaliDateService { + public function getCurrentPersianMonthRange(?int $timestamp): array; + // return: ['start' => timestamp, 'end' => timestamp] + + public function getPersianYearRange(?int $timestamp): array; + // return: ['year' => int, 'start' => timestamp, 'end' => timestamp] + + public function getMonthRangeByYearAndMonth(int $year, int $month): array; + // return: ['start' => timestamp, 'end' => timestamp] + + public function gregorianToPersian(int $gy, int $gm, int $gd): array; + // return: [$jy, $jm, $jd] + + public function persianToGregorian(int $jy, int $jm, int $jd): array; + // return: [$gy, $gm, $gd] +} +``` + +## کشینگ آمار داشبورد +آمار داشبورد را با Redis کش کن (TTL = 5 دقیقه): +```php +$cacheKey = "dashboard_representation_{$representation->getId()}"; +// بعد از هر payment جدید → cache invalidate +``` + +## مجوزها +``` +GET /representation/{uuid} → دکتر مرتبط یا ROLE_ADMIN +GET /representation/my-appointments/{id} → دکتر/نماینده یا ROLE_ADMIN +GET /representation/my-doctor/{userId} → کاربر خودش +GET /representation/filter/{id} → دکتر/نماینده یا ROLE_ADMIN +GET /representation/yearly-income/{id} → دکتر/نماینده یا ROLE_ADMIN +``` diff --git a/docs/tasks/task-16-representation/task.md b/docs/tasks/task-16-representation/task.md new file mode 100644 index 00000000..b6fd0bf0 --- /dev/null +++ b/docs/tasks/task-16-representation/task.md @@ -0,0 +1,153 @@ +# تسک ۱۶: ماژول Representation (نمایندگی + داشبورد) + +## توضیح +این ماژول دو کارکرد دارد: +۱. **مدیریت نمایندگی‌ها (Multi-tenant)** — هر نماینده دامنه‌ای دارد؛ نوبت‌ها و دکترها به نماینده مرتبط می‌شوند +۲. **داشبورد دکتر/نماینده** — آمار نوبت‌ها، درآمد ماهانه/سالانه، بیماران + +## Endpoint ها + +| متد | مسیر | توضیح | نیاز به Auth | +|-----|------|-------|-------------| +| GET | `/api/v1/representation/{uuid}` | اطلاعات نمایندگی | بله (Admin) | +| POST | `/api/v1/representations/{id}/bank-accounts` | اضافه کردن کارت بانکی | بله (Admin) | +| GET | `/api/v1/representation/my-appointments/{representationId}` | نوبت‌های نماینده | بله | +| GET | `/api/v1/representation/my-doctor/{userId}` | دکترهای یک بیمار | بله | +| GET | `/api/v1/representation/filter/{representationId}` | آمار ماه جاری شمسی | بله | +| GET | `/api/v1/representation/yearly-income/{representationId}` | درآمد سالانه شمسی | بله | + +## پیش‌نیازها +- تسک ۰۱، ۰۲، ۰۵ (Doctor)، ۱۰ (Appointment)، ۱۵ (Payment) +- پیاده‌سازی `JalaliDateService` (برای تبدیل تاریخ شمسی) + +> **تسویه نماینده** در تسک ۱۸ پوشش داده می‌شود (کیف پول، درخواست برداشت، تأیید ادمین) + +## زمان تخمینی +۸ تا ۱۰ ساعت + +## Query Params + +### GET /api/v1/representation/filter/{representationId} +``` +?timestamp=1704067200 (اختیاری — Unix timestamp — برای تعیین ماه شمسی) +``` +اگر timestamp نداده شود، ماه جاری شمسی استفاده می‌شود. + +### GET /api/v1/representation/yearly-income/{representationId} +``` +?timestamp=1704067200 (اختیاری — Unix timestamp — برای تعیین سال شمسی) +``` + +## نمونه Response‌ها + +### GET /api/v1/representation/{uuid} +```json +{ + "id": 1, + "uuid": "...", + "domain_name": "http://yasuj-nobat.localhost:3000/", + "city": { "id": 5, "uuid": "...", "label": "یاسوج" }, + "active": true, + "commission_percent": 10.0, + "created": 1704067200, + "changed": 1716000000 +} +``` + +### GET /api/v1/representation/filter/{id} +```json +{ + "payments_total": { "total_price": 12500000, "count": 25 }, + "total_patients": 142, + "today_appointments": 8 +} +``` + +### GET /api/v1/representation/yearly-income/{id} +```json +{ + "year": 1403, + "monthly_income": [ + { "month": 1, "income": 8500000 }, + { "month": 2, "income": 9200000 }, + { "month": 3, "income": 0 }, + ... + { "month": 12, "income": 0 } + ] +} +``` + +### GET /api/v1/representation/my-appointments/{id} +```json +{ + "data": [ + { + "id": 10, "uuid": "...", + "start_time": 1716000000, "end_time": 1716001800, + "status": "confirmed", + "slot": { "start": "09:00", "end": "09:30", "duration": 30, "location_id": 42 }, + "doctor": { "id": 5, "uuid": "...", "label": "دکتر محمدی" }, + "address": { "id": 42, "uuid": "...", "label": "مطب شیراز" }, + "representation": { "id": 1, "uuid": "...", "label": "نمایندگی یاسوج" }, + "owner": { "id": 20, "uuid": "...", "name": "علی رضایی" } + } + ] +} +``` + +--- + +## POST /api/v1/representations/{id}/bank-accounts + +``` +ورودی: + Authorization: Bearer + Content-Type: application/json + +Body: +{ + "card_number": "6037-9999-1234-5678", + "bank_name": "ملت", + "is_default": true +} +``` + +```json +// خروجی HTTP 201: +{ + "success": true, + "bank_account": { + "card_number": "6037-9999-1234-5678", + "bank_name": "ملت", + "is_default": true + } +} +``` + +> **⚠ نکات مهم:** +> - فیلد `bank_account` در جدول Representation به‌صورت JSON Array ذخیره می‌شود +> - هر آیتم شامل: `card_number`, `bank_name`, `is_default` +> - اگر `is_default=true` باشد، `is_default` سایر کارت‌ها باید `false` شود +> - حداقل یک کارت باید `is_default=true` داشته باشد + +--- + +## نمونه `bank_account` در GET /api/v1/representation/{uuid} +```json +{ + "bank_account": [ + { "card_number": "6037-9999-1234-5678", "bank_name": "ملت", "is_default": true }, + { "card_number": "5859-3312-4455-6677", "bank_name": "صادرات", "is_default": false } + ] +} +``` + +--- + +## فیلدهای Representation Entity +``` +field_domain_name → دامنه (مثل: http://yasuj-nobat.localhost:3000/) +field_city → entity reference → category (شهر) +field_active → boolean +field_commission_percent → درصد کمیسیون +``` diff --git a/docs/tasks/task-17-sms/database.md b/docs/tasks/task-17-sms/database.md new file mode 100644 index 00000000..cba2b8b0 --- /dev/null +++ b/docs/tasks/task-17-sms/database.md @@ -0,0 +1,44 @@ +# پایگاه داده — تسک ۱۷: ماژول پیامک + +## جدول: sms_accounts (حساب پیامک) +_(از Manual بخش ۴.۱ — entity جدید در Symfony)_ + +| ستون | نوع | توضیح | +|------|-----|-------| +| id | INT AUTO_INCREMENT PK | | +| uuid | CHAR(36) UNIQUE NOT NULL | | +| user_id | INT FK → users.id NOT NULL | کاربر مالک | +| owner_type | VARCHAR(10) NOT NULL | `doctor` یا `clinic` | +| owner_id | INT NOT NULL | FK به doctors.id یا clinics.id | +| balance | INT DEFAULT 0 | تعداد پیامک باقی‌مانده | +| created_at | INT NOT NULL | Unix timestamp | +| updated_at | INT NOT NULL | Unix timestamp | + +## جدول: sms_queue (صف پیامک) +_(از Manual بخش ۴.۲)_ + +| ستون | نوع | توضیح | +|------|-----|-------| +| id | INT AUTO_INCREMENT PK | | +| account_id | INT FK → sms_accounts.id CASCADE | حساب پیامک مالک | +| recipient_mobile | VARCHAR(20) NOT NULL | شماره موبایل گیرنده | +| message | TEXT NOT NULL | متن پیامک | +| scheduled_at | INT NOT NULL | زمان برنامه‌ریزی‌شده (Unix timestamp) — معمولاً یک روز قبل از نوبت | +| sent_at | INT NULL | زمان واقعی ارسال | +| status | VARCHAR(20) DEFAULT 'queued' | `queued`, `sent`, `failed`, `cancelled` | +| created_at | INT NOT NULL | | +| updated_at | INT NOT NULL | | + +## ایندکس‌ها +```sql +CREATE UNIQUE INDEX idx_sms_accounts_owner ON sms_accounts(owner_type, owner_id); +CREATE INDEX idx_sms_queue_account ON sms_queue(account_id); +CREATE INDEX idx_sms_queue_status ON sms_queue(status, scheduled_at); +CREATE INDEX idx_sms_queue_scheduled ON sms_queue(scheduled_at); +``` + +## نکات مهم +- هر دکتر/کلینیک فقط یک حساب پیامک دارد (UNIQUE owner_type, owner_id) +- `scheduled_at` معمولاً یک روز قبل از `appointment.start_time` تنظیم می‌شود +- Tauri (نرم‌افزار لوکال) پیامک‌ها را در صف می‌گذارد، بک‌اند آن‌ها را ارسال می‌کند +- کسر موجودی باید atomic باشد (transaction) diff --git a/docs/tasks/task-17-sms/implementation_notes.md b/docs/tasks/task-17-sms/implementation_notes.md new file mode 100644 index 00000000..c9ffe18a --- /dev/null +++ b/docs/tasks/task-17-sms/implementation_notes.md @@ -0,0 +1,70 @@ +# نکات پیاده‌سازی — تسک ۱۷: ماژول پیامک + +## API Endpoints + +### GET /api/v1/sms/balance +```json +// Response 200: +{ + "owner_id": 29, + "owner_type": "doctor", + "balance": 850 +} + +// Error 404: حساب پیامک وجود ندارد +// Error 401: احراز هویت لازم است +``` + +### POST /api/v1/sms/queue +```json +// Request: +{ + "recipient_mobile": "09121234567", + "message": "یادآوری: نوبت شما فردا ساعت ۱۰:۰۰ است", + "scheduled_at": "2025-06-15T09:00:00+03:30" +} + +// Response 201: +{ + "id": 1, + "status": "queued", + "scheduled_at": 1749970200, + "remaining_balance": 849 +} + +// Error 402: موجودی ناکافی +// Error 400: شماره موبایل یا پیام نامعتبر +``` + +## منطق کسر موجودی (atomic) +```php +// در SmsService.php: +$this->entityManager->beginTransaction(); +$account = $this->smsAccountRepo->findByOwner($ownerType, $ownerId); +if ($account->getBalance() <= 0) { + throw new InsufficientBalanceException(); +} +$account->decrementBalance(); +// add to queue +$this->entityManager->commit(); +``` + +## تشخیص owner از JWT token +```php +// کاربر لاگین‌شده → چک کن دکتر است یا کلینیک +$user = $this->getUser(); +if ($user->hasRole('ROLE_DOCTOR')) { + $doctor = $this->doctorRepo->findByUser($user); + $ownerType = 'doctor'; $ownerId = $doctor->getId(); +} elseif ($user->hasRole('ROLE_CLINIC')) { + $clinic = $this->clinicRepo->findByUser($user); + $ownerType = 'clinic'; $ownerId = $clinic->getId(); +} +``` + +## نکته: ارسال واقعی پیامک +پیامک‌ها توسط یک Job/Command ارسال می‌شوند (نه در همان request): +``` +php bin/console sms:send-queued +``` +این command پیامک‌هایی با `status=queued` و `scheduled_at <= now` را ارسال می‌کند. diff --git a/docs/tasks/task-17-sms/task.md b/docs/tasks/task-17-sms/task.md new file mode 100644 index 00000000..4c02dc9b --- /dev/null +++ b/docs/tasks/task-17-sms/task.md @@ -0,0 +1,471 @@ +# تسک ۱۷: ماژول پیامک (SMS) + +## توضیح +سیستم پیامک یادآوری نوبت برای بیماران. +هر دکتر/کلینیک یک حساب پیامک مستقل دارد که با خرید پیامک شارژ می‌شود. +ارسال پیامک **async** از طریق Symfony Messenger انجام می‌شود. + +دکتر یا کلینیک می‌تواند **تمپلیت پیامک سفارشی** برای هر دسته‌بندی بسازد. +ادمین باید تمپلیت را تأیید کند — بعد از تأیید، در ارسال پیامک از آن استفاده می‌شود. + +## Endpoint ها + +| متد | مسیر | توضیح | نیاز به Auth | +|-----|------|-------|-------------| +| GET | `/api/v1/sms/balance` | موجودی حساب پیامک | بله | +| POST | `/api/v1/sms/queue` | افزودن پیامک به صف | بله | +| GET | `/api/v1/sms/sample-templates` | مشاهده نمونه تمپلیت‌های ادمین | بله (Doctor/Clinic) | +| GET | `/api/v1/sms/templates` | لیست تمپلیت‌های خودم | بله (Doctor/Clinic/Admin) | +| POST | `/api/v1/sms/templates` | ساختن تمپلیت جدید | بله (Doctor/Clinic) | +| GET | `/api/v1/sms/templates/{uuid}` | جزئیات تمپلیت | بله | +| PATCH | `/api/v1/sms/templates/{uuid}` | ویرایش تمپلیت (قبل از ارسال به ادمین) | بله (Owner) | +| DELETE | `/api/v1/sms/templates/{uuid}` | حذف تمپلیت | بله (Owner/Admin) | +| PATCH | `/api/v1/sms/templates/{uuid}/submit` | ارسال به ادمین برای تأیید | بله (Owner) | +| PATCH | `/api/v1/sms/templates/{uuid}/approve` | تأیید تمپلیت | بله (Admin) | +| PATCH | `/api/v1/sms/templates/{uuid}/reject` | رد تمپلیت با دلیل | بله (Admin) | + +## پیش‌نیازها +- تسک ۰۵ (Doctor)، تسک ۰۶ (Clinic) + +## زمان تخمینی +۸ تا ۱۰ ساعت + +--- + +## GET /api/v1/sms/balance + +```json +{ + "success": true, + "data": { + "owner_id": 5, + "owner_type": "doctor", + "balance": 847 + } +} +``` + +--- + +## POST /api/v1/sms/queue + +```json +// Request +{ + "owner_type": "doctor", + "owner_id": 5, + "recipients": [ + { "mobile": "09120671713", "appointment_uuid": "..." } + ], + "scheduled_at": 1748000000 +} + +// Response 201 +{ + "success": true, + "data": { + "id": 42, + "status": "queued", + "scheduled_at": 1748000000, + "remaining_balance": 846 + } +} + +// Response 402 — موجودی ناکافی +{ + "success": false, + "errors": [{ "code": "ERR_SMS_001", "message": "موجودی پیامک کافی نیست" }] +} +``` + +--- + +## SMS Providers — Strategy Pattern + +```php +interface SmsProviderInterface +{ + public function send(string $mobile, string $message): bool; + public function getName(): string; +} + +class KavehNegarProvider implements SmsProviderInterface { ... } +class RanginehProvider implements SmsProviderInterface { ... } +``` + +### Fallback Logic + +``` +تلاش با Provider اول (KavehNegar): + موفق → ثبت log و کسر موجودی + ناموفق → تلاش با Provider دوم (Rangineh): + موفق → ثبت log و کسر موجودی + ناموفق → log خطا، پیامک در صف می‌ماند برای retry +``` + +**محیط Dev:** +```php +if ($this->appEnv === 'dev') { + // OTP ثابت 12345 — بدون ارسال واقعی + return true; +} +``` + +--- + +## Symfony Messenger — پیاده‌سازی Async + +```php +// Message +class SendSmsMessage +{ + public function __construct( + public readonly string $mobile, + public readonly string $message, + public readonly int $smsLogId, + ) {} +} + +// Handler +class SendSmsHandler implements MessageHandlerInterface +{ + public function __invoke(SendSmsMessage $message): void + { + try { + $sent = $this->primaryProvider->send($message->mobile, $message->message); + + if (!$sent) { + $sent = $this->fallbackProvider->send($message->mobile, $message->message); + } + + $this->smsLogRepo->markSent($message->smsLogId, $sent); + } catch (\Exception $e) { + $this->logger->error('sms.send_failed', [ + 'mobile' => $message->mobile, + 'error' => $e->getMessage(), + ]); + throw $e; // Messenger retry می‌کند + } + } +} +``` + +**Retry Config در messenger.yaml:** +```yaml +framework: + messenger: + transports: + async: + dsn: '%env(MESSENGER_TRANSPORT_DSN)%' + retry_strategy: + max_retries: 3 + delay: 5000 # 5 ثانیه + multiplier: 2 # 5s, 10s, 20s +``` + +--- + +## قانون کسر موجودی + +``` +قبل از ارسال: + sms_accounts.balance >= count(recipients) ؟ + خیر → 402 + بله → ادامه + +بعد از ارسال موفق: + UPDATE sms_accounts SET balance = balance - 1 WHERE owner_id = X +``` + +--- + +## ساختار جدول sms_logs + +| ستون | نوع | توضیح | +|------|-----|-------| +| id | INT PK | | +| owner_type | VARCHAR(10) | `doctor` یا `clinic` | +| owner_id | INT | | +| mobile | VARCHAR(20) | شماره گیرنده | +| message | TEXT | متن پیامک | +| provider | VARCHAR(20) | `kavenegar` یا `rangineh` | +| status | VARCHAR(10) | `queued` / `sent` / `failed` | +| scheduled_at | INT | Unix timestamp | +| sent_at | INT NULL | زمان ارسال واقعی | +| error | VARCHAR(255) NULL | پیام خطا در صورت شکست | +| created_at | INT | | + +--- + +## نکات مهم + +- ارسال OTP نیز از همین سرویس استفاده می‌کند (با `scheduled_at=now()`) +- برای OTP، Fallback فوری است — کاربر نمی‌تواند منتظر retry بماند +- موجودی پیامک مستقل از موجودی کیف پول نماینده است + +--- + +## سیستم تمپلیت پیامک سفارشی + +### جریان کلی + +``` +۱. ادمین → چند تمپلیت نمونه آموزشی می‌سازد (is_sample=true) + مثلاً: "یادآوری نوبت — نمونه" + +۲. دکتر/کلینیک → GET /api/v1/sms/sample-templates + نمونه‌ها را مشاهده می‌کند + +۳. دکتر/کلینیک → POST /api/v1/sms/templates + تمپلیت خودش را می‌سازد (status=draft) + می‌تواند از نمونه الهام بگیرد یا از صفر بنویسد + +۴. دکتر/کلینیک → PATCH /api/v1/sms/templates/{uuid}/submit + برای تأیید ادمین ارسال می‌کند (status=pending_approval) + +۵. ادمین → GET /api/v1/sms/templates?status=pending_approval + لیست تمپلیت‌های در انتظار را می‌بیند + +۶. ادمین → PATCH /api/v1/sms/templates/{uuid}/approve (status=approved) + یا PATCH /api/v1/sms/templates/{uuid}/reject (status=rejected) + +۷. بعد از approve → سیستم SMS این تمپلیت را برای آن دکتر/کلینیک استفاده می‌کند + اگر تمپلیت approved نداشت → از تمپلیت پیش‌فرض سیستم استفاده می‌شود +``` + +### دسته‌بندی تمپلیت‌ها (category) + +| category | توضیح | متغیرهای مجاز | +|----------|-------|--------------| +| `appointment_reminder` | یادآوری نوبت | `{patient_name}`, `{doctor_name}`, `{date}`, `{time}`, `{clinic_name}` | +| `appointment_confirmed` | تأیید رزرو | `{patient_name}`, `{doctor_name}`, `{date}`, `{time}` | +| `appointment_cancelled` | لغو نوبت | `{patient_name}`, `{doctor_name}`, `{date}` | +| `appointment_reminder_1h` | یادآوری ۱ ساعت قبل | `{patient_name}`, `{doctor_name}`, `{time}` | +| `custom` | پیامک آزاد (دستی) | `{patient_name}`, `{doctor_name}` | + +### ساختار تمپلیت و متغیرها + +``` +متن نمونه ادمین: +"بیمار گرامی {patient_name}، نوبت شما با {doctor_name} +در تاریخ {date} ساعت {time} در {clinic_name} تأیید شد." + +دکتر می‌تواند تغییر دهد: +"سلام {patient_name} عزیز! یادآوری نوبت ویزیت با دکتر {doctor_name} +تاریخ {date} - ساعت {time} +مطب دکتر احمدی، خیابان ولیعصر" +``` + +متغیرها با `{variable_name}` نشان داده می‌شوند و هنگام ارسال با مقادیر واقعی جایگزین می‌شوند. + +--- + +### GET /api/v1/sms/sample-templates + +```json +{ + "success": true, + "data": [ + { + "uuid": "...", + "category": "appointment_reminder", + "name": "یادآوری نوبت — نمونه پیش‌فرض", + "content": "بیمار گرامی {patient_name}، نوبت شما با {doctor_name} در تاریخ {date} ساعت {time} در {clinic_name} تأیید شد.", + "available_variables": ["{patient_name}", "{doctor_name}", "{date}", "{time}", "{clinic_name}"] + } + ] +} +``` + +--- + +### POST /api/v1/sms/templates + +```json +// Request +{ + "category": "appointment_reminder", + "name": "یادآوری نوبت — مطب دکتر احمدی", + "content": "سلام {patient_name} عزیز! نوبت ویزیت شما با {doctor_name} در تاریخ {date} ساعت {time}. آدرس: خیابان آزادی، مطب طبقه ۲" +} + +// Response 201 +{ + "success": true, + "data": { + "uuid": "...", + "category": "appointment_reminder", + "name": "یادآوری نوبت — مطب دکتر احمدی", + "content": "سلام {patient_name} عزیز!...", + "status": "draft", + "created_at": 1748000000 + } +} + +// Response 422 — متغیر نامعتبر در متن +{ + "success": false, + "errors": [{ "code": "ERR_SMS_002", "message": "متغیر {invalid_var} در این دسته‌بندی مجاز نیست" }] +} +``` + +**اعتبارسنجی هنگام ساختن تمپلیت:** +``` +1. category باید از لیست مجاز باشد +2. متغیرهای داخل {} فقط از لیست available_variables مجاز category باشند +3. طول محتوا: حداکثر 500 کاراکتر +4. هر دکتر/کلینیک حداکثر 3 تمپلیت فعال approved برای هر category +``` + +--- + +### PATCH /api/v1/sms/templates/{uuid}/submit + +```json +// Request — بدون body +// Response 200 +{ + "success": true, + "data": { + "uuid": "...", + "status": "pending_approval", + "submitted_at": 1748000000 + } +} + +// Response 422 — تمپلیت قبلاً submitted یا approved شده +{ + "success": false, + "errors": [{ "code": "ERR_SMS_003", "message": "تمپلیت قبلاً برای بررسی ارسال شده است" }] +} +``` + +--- + +### PATCH /api/v1/sms/templates/{uuid}/approve (Admin) + +```json +// Request — بدون body +// Response 200 +{ + "success": true, + "data": { + "uuid": "...", + "status": "approved", + "approved_at": 1748000000, + "approved_by": { "uuid": "...", "name": "ادمین سیستم" } + } +} +``` + +--- + +### PATCH /api/v1/sms/templates/{uuid}/reject (Admin) + +```json +// Request +{ + "reason": "محتوای تمپلیت با قوانین پیامک مغایرت دارد. لطفاً نام کامل بیمار را حذف کنید." +} + +// Response 200 +{ + "success": true, + "data": { + "uuid": "...", + "status": "rejected", + "rejection_reason": "محتوای تمپلیت با قوانین پیامک مغایرت دارد...", + "rejected_at": 1748000000 + } +} +``` + +بعد از reject، صاحب تمپلیت می‌تواند تمپلیت را ویرایش کند (status → draft) و مجدداً submit کند. + +--- + +### GET /api/v1/sms/templates + +```json +// برای دکتر/کلینیک — فقط تمپلیت‌های خودش +// برای ادمین — همه تمپلیت‌ها با فیلتر + +// Query params: +// ?status=pending_approval ← ادمین برای بررسی +// ?category=appointment_reminder +// ?owner_type=doctor&owner_id=5 + +{ + "success": true, + "data": [ + { + "uuid": "...", + "category": "appointment_reminder", + "name": "یادآوری نوبت — مطب دکتر احمدی", + "content": "سلام {patient_name} عزیز!...", + "status": "approved", + "owner": { "type": "doctor", "name": "دکتر احمدی" }, + "approved_at": 1748000000, + "created_at": 1748000000 + } + ], + "meta": { "totalRecords": 5, "totalPages": 1, "currentPage": 1 } +} +``` + +--- + +### منطق انتخاب تمپلیت هنگام ارسال پیامک + +```php +// در SmsService::getTemplateFor(ownerId, ownerType, category) +public function getTemplateFor(int $ownerId, string $ownerType, string $category): SmsTemplate +{ + // ابتدا تمپلیت approved خاص آن دکتر/کلینیک + $custom = $this->templateRepo->findApproved($ownerId, $ownerType, $category); + + if ($custom) { + return $custom; + } + + // اگر نداشت → تمپلیت نمونه پیش‌فرض ادمین + return $this->templateRepo->findDefaultSample($category); +} + +// رندر محتوای نهایی با جایگزینی متغیرها +public function render(SmsTemplate $template, array $vars): string +{ + return strtr($template->getContent(), array_combine( + array_map(fn($k) => '{' . $k . '}', array_keys($vars)), + array_values($vars) + )); +} +``` + +--- + +### جدول sms_templates + +| ستون | نوع | توضیح | +|------|-----|-------| +| id | INT PK | | +| uuid | VARCHAR(36) | | +| owner_type | VARCHAR(10) | `doctor` / `clinic` / `admin` | +| owner_id | INT NULL | NULL برای نمونه‌های ادمین | +| category | VARCHAR(30) | appointment_reminder / ... | +| name | VARCHAR(100) | نام قابل خواندن | +| content | TEXT | متن با متغیرها | +| is_sample | TINYINT(1) | 1 برای نمونه‌های ادمین | +| status | VARCHAR(20) | `draft` / `pending_approval` / `approved` / `rejected` | +| rejection_reason | TEXT NULL | دلیل رد ادمین | +| approved_by | INT NULL | user_id ادمین تأییدکننده | +| approved_at | INT NULL | | +| submitted_at | INT NULL | | +| created_at | INT | | +| updated_at | INT | | + +```sql +CREATE INDEX idx_sms_tmpl_owner ON sms_templates(owner_type, owner_id); +CREATE INDEX idx_sms_tmpl_status ON sms_templates(status); +CREATE INDEX idx_sms_tmpl_cat ON sms_templates(category); +``` diff --git a/docs/tasks/task-18-settlement/database.md b/docs/tasks/task-18-settlement/database.md new file mode 100644 index 00000000..2b9495ba --- /dev/null +++ b/docs/tasks/task-18-settlement/database.md @@ -0,0 +1,90 @@ +# پایگاه داده — تسک ۱۸: تسویه نماینده + +## جدول: wallet_transactions (تراکنش‌های کیف پول) + +| ستون | نوع | توضیح | +|------|-----|-------| +| id | INT UNSIGNED AUTO_INCREMENT PK | | +| uuid | CHAR(36) UNIQUE NOT NULL | | +| representation_id | INT FK → representations.id NOT NULL | نماینده | +| type | VARCHAR(10) NOT NULL | `credit` (واریز) یا `debit` (برداشت) | +| amount | INT NOT NULL | مبلغ به ریال | +| source | VARCHAR(20) NOT NULL | `appointment` یا `subscription` یا `settlement` | +| source_id | INT NULL | FK به payments.id یا subscription_payments.id یا settlements.id | +| description | VARCHAR(255) NULL | توضیح تراکنش | +| created_at | INT NOT NULL | Unix timestamp | + +```sql +CREATE INDEX idx_wallet_representation ON wallet_transactions(representation_id); +CREATE INDEX idx_wallet_type ON wallet_transactions(representation_id, type); +CREATE INDEX idx_wallet_source ON wallet_transactions(source, source_id); +``` + +--- + +## جدول: settlements (درخواست‌های تسویه) + +| ستون | نوع | توضیح | +|------|-----|-------| +| id | INT UNSIGNED AUTO_INCREMENT PK | | +| uuid | CHAR(36) UNIQUE NOT NULL | | +| representation_id | INT FK → representations.id NOT NULL | نماینده درخواست‌دهنده | +| amount | INT NOT NULL | مبلغ درخواستی (ریال) | +| card_number | VARCHAR(25) NOT NULL | شماره کارت بانکی برداشت | +| bank_name | VARCHAR(50) NOT NULL | نام بانک | +| status | VARCHAR(10) DEFAULT 'pending' | `pending` / `approved` / `rejected` | +| description | VARCHAR(255) NULL | توضیح نماینده | +| admin_note | VARCHAR(255) NULL | یادداشت ادمین هنگام تأیید/رد | +| requested_at | INT NOT NULL | Unix timestamp — زمان درخواست | +| resolved_at | INT NULL | Unix timestamp — زمان تأیید/رد | +| created_at | INT NOT NULL | Unix timestamp | +| updated_at | INT NOT NULL | Unix timestamp | + +```sql +CREATE INDEX idx_settlements_representation ON settlements(representation_id); +CREATE INDEX idx_settlements_status ON settlements(status); +CREATE UNIQUE INDEX idx_settlements_pending ON settlements(representation_id, status) + WHERE status = 'pending'; +-- این index یکتایی یک pending در هر زمان را enforce می‌کند +``` + +--- + +## فیلد balance در representations + +جدول `representations` باید یک فیلد `balance` داشته باشد: + +| ستون | نوع | توضیح | +|------|-----|-------| +| balance | INT DEFAULT 0 | موجودی کیف پول (ریال) — همیشه sync با wallet_transactions | + +> **نکته:** `balance` باید همزمان با هر تراکنش در wallet_transactions آپدیت شود +> تا query موجودی سریع باشد (بدون SUM روی wallet_transactions). + +--- + +## فلوی واریز کمیسیون (هنگام پرداخت موفق) + +``` +payments.status → 'received' + ↓ +commission = payments.amount × (representation.commission_percent / 100) + ↓ +INSERT INTO wallet_transactions (representation_id, type='credit', amount=commission, source='appointment', source_id=payment.id) + ↓ +UPDATE representations SET balance = balance + commission WHERE id = representation_id +``` + +## فلوی تسویه (هنگام تأیید ادمین) + +``` +PATCH /api/v1/settlement/{uuid}/approve + ↓ +بررسی: representations.balance >= settlements.amount + ↓ +INSERT INTO wallet_transactions (type='debit', amount=settlement.amount, source='settlement', source_id=settlement.id) + ↓ +UPDATE representations SET balance = balance - settlement.amount + ↓ +UPDATE settlements SET status='approved', resolved_at=NOW(), admin_note=... +``` diff --git a/docs/tasks/task-18-settlement/task.md b/docs/tasks/task-18-settlement/task.md new file mode 100644 index 00000000..ff37efc9 --- /dev/null +++ b/docs/tasks/task-18-settlement/task.md @@ -0,0 +1,117 @@ +# تسک ۱۸: ماژول تسویه نماینده (Settlement) + +## توضیح +سیستم تسویه‌حساب نمایندگان — هر بار که از طریق دامنه یک نماینده نوبت پرداخت می‌شود، +کمیسیون مشخصی (طبق `commission_percent`) به کیف پول نماینده واریز می‌شود. +نماینده می‌تواند درخواست تسویه (برداشت) بدهد و ادمین آن را تأیید/رد می‌کند. + +## فلوی کمیسیون (از مستند) + +``` +بیمار → پرداخت نوبت از دامنه نماینده + → commission = amount × (commission_percent / 100) + → واریز به کیف پول نماینده (wallet_transactions) + +دکتر → خرید اشتراک از طریق نماینده + → کمیسیون اشتراک → واریز به کیف پول نماینده +``` + +## Endpoint ها + +| متد | مسیر | توضیح | نیاز به Auth | +|-----|------|-------|-------------| +| GET | `/api/v1/representation/{id}/wallet` | موجودی کیف پول نماینده | بله (Admin / Owner) | +| GET | `/api/v1/representation/{id}/wallet/transactions` | تاریخچه تراکنش‌های کیف پول | بله (Admin / Owner) | +| POST | `/api/v1/representation/{id}/settlement` | درخواست تسویه توسط نماینده | بله (Owner) | +| GET | `/api/v1/settlements` | لیست همه درخواست‌های تسویه | بله (Admin) | +| GET | `/api/v1/settlement/{uuid}` | جزئیات یک درخواست تسویه | بله (Admin / Owner) | +| PATCH | `/api/v1/settlement/{uuid}/approve` | تأیید تسویه توسط ادمین | بله (Admin) | +| PATCH | `/api/v1/settlement/{uuid}/reject` | رد تسویه توسط ادمین | بله (Admin) | + +## پیش‌نیازها +- تسک ۰۱، ۰۲، ۱۵ (Payment)، ۱۶ (Representation) + +## زمان تخمینی +۸ تا ۱۰ ساعت + +--- + +## نمونه Request — POST /api/v1/representation/{id}/settlement + +```json +{ + "amount": 5000000, + "card_number": "6037-9999-1234-5678", + "bank_name": "ملت", + "description": "تسویه اسفندماه ۱۴۰۳" +} +``` + +## نمونه Response — GET /api/v1/representation/{id}/wallet + +```json +{ + "representation_id": 3, + "balance": 12500000, + "total_earned": 35000000, + "total_settled": 22500000, + "pending_settlement": 0 +} +``` + +## نمونه Response — GET /api/v1/representation/{id}/wallet/transactions + +```json +{ + "data": [ + { + "uuid": "...", + "type": "credit", + "amount": 50000, + "source": "appointment", + "source_id": 142, + "description": "کمیسیون نوبت #142", + "created_at": 1748000000 + }, + { + "uuid": "...", + "type": "debit", + "amount": 5000000, + "source": "settlement", + "source_id": 7, + "description": "تسویه #7", + "created_at": 1747000000 + } + ], + "page": { + "totalRecords": 48, + "totalPages": 5, + "currentPage": 1 + } +} +``` + +## نمونه Response — GET /api/v1/settlement/{uuid} + +```json +{ + "uuid": "...", + "representation": { "id": 3, "uuid": "...", "label": "نمایندگی یاسوج" }, + "amount": 5000000, + "card_number": "6037-9999-1234-5678", + "bank_name": "ملت", + "status": "pending", + "description": "تسویه اسفندماه ۱۴۰۳", + "admin_note": null, + "requested_at": 1748000000, + "resolved_at": null +} +``` + +## نکات مهم + +- **موجودی کافی:** قبل از ثبت درخواست تسویه، موجودی کیف پول نماینده بررسی شود +- **یک درخواست pending:** نماینده نمی‌تواند همزمان دو درخواست `pending` داشته باشد +- **کارت بانکی:** شماره کارت از لیست `bank_account` نماینده باشد (نه کارت دلخواه) +- **مبلغ حداقل:** حداقل مبلغ تسویه باید تعریف شود (مثلاً ۱۰۰,۰۰۰ ریال) +- **واریز کمیسیون:** هنگام `payments.status = 'received'` → کمیسیون محاسبه و به wallet واریز شود diff --git a/migrations/.gitignore b/migrations/.gitignore new file mode 100644 index 00000000..e69de29b diff --git a/migrations/Version20260609130407.php b/migrations/Version20260609130407.php new file mode 100644 index 00000000..9b8a2639 --- /dev/null +++ b/migrations/Version20260609130407.php @@ -0,0 +1,31 @@ +addSql('CREATE TABLE users (id INT AUTO_INCREMENT NOT NULL, uuid VARCHAR(36) NOT NULL, mobile_number VARCHAR(20) NOT NULL, password_hash VARCHAR(255) DEFAULT NULL, email VARCHAR(100) DEFAULT NULL, real_name VARCHAR(100) DEFAULT NULL, roles JSON NOT NULL, status SMALLINT NOT NULL, created_at INT NOT NULL, updated_at INT NOT NULL, UNIQUE INDEX UNIQ_1483A5E9D17F50A6 (uuid), UNIQUE INDEX uniq_mobile (mobile_number), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('DROP TABLE users'); + } +} diff --git a/migrations/Version20260609131304.php b/migrations/Version20260609131304.php new file mode 100644 index 00000000..3ba15af7 --- /dev/null +++ b/migrations/Version20260609131304.php @@ -0,0 +1,171 @@ +addSql('CREATE TABLE categories ( + id INT AUTO_INCREMENT NOT NULL, + uuid VARCHAR(36) NOT NULL, + bundle VARCHAR(32) NOT NULL, + label VARCHAR(255) DEFAULT NULL, + status SMALLINT NOT NULL DEFAULT 1, + parent_id INT DEFAULT NULL, + weight INT NOT NULL DEFAULT 0, + logo_id INT DEFAULT NULL, + title VARCHAR(255) DEFAULT NULL, + representation_id INT DEFAULT NULL, + contact_phone VARCHAR(255) DEFAULT NULL, + email VARCHAR(255) DEFAULT NULL, + description TEXT DEFAULT NULL, + slogan VARCHAR(255) DEFAULT NULL, + domain VARCHAR(255) DEFAULT NULL, + keywords VARCHAR(255) DEFAULT NULL, + footer_description TEXT DEFAULT NULL, + footer_disclaimer TEXT DEFAULT NULL, + social_media JSON DEFAULT NULL, + UNIQUE INDEX UNIQ_3AF346686B548B0F (uuid), + INDEX idx_categories_bundle (bundle), + INDEX idx_categories_parent (parent_id), + INDEX idx_categories_status (status, bundle), + PRIMARY KEY(id) + ) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci`'); + + // States (31 provinces) + $states = [ + 'آذربایجان شرقی', 'آذربایجان غربی', 'اردبیل', 'اصفهان', 'البرز', + 'ایلام', 'بوشهر', 'تهران', 'چهارمحال و بختیاری', 'خراسان جنوبی', + 'خراسان رضوی', 'خراسان شمالی', 'خوزستان', 'زنجان', 'سمنان', + 'سیستان و بلوچستان', 'فارس', 'قزوین', 'قم', 'کردستان', + 'کرمان', 'کرمانشاه', 'کهگیلویه و بویراحمد', 'گلستان', 'گیلان', + 'لرستان', 'مازندران', 'مرکزی', 'هرمزگان', 'همدان', 'یزد', + ]; + foreach ($states as $i => $label) { + $uuid = $this->genUuid(); + $this->addSql("INSERT INTO categories (uuid,bundle,label,status,weight) VALUES ('{$uuid}','state','{$label}',1,".($i+1).")"); + } + + // Cities (major cities per province; parent_id = state row id 1..31) + $cities = [ + [1,'تبریز'],[1,'مراغه'],[1,'مرند'], + [2,'ارومیه'],[2,'خوی'],[2,'مهاباد'], + [3,'اردبیل'],[3,'مشگین‌شهر'],[3,'پارس‌آباد'], + [4,'اصفهان'],[4,'کاشان'],[4,'خمینی‌شهر'],[4,'نجف‌آباد'], + [5,'کرج'],[5,'فردیس'],[5,'نظرآباد'], + [6,'ایلام'],[6,'دهلران'],[6,'آبدانان'], + [7,'بوشهر'],[7,'برازجان'],[7,'کنگان'], + [8,'تهران'],[8,'ری'],[8,'شهریار'],[8,'اسلام‌شهر'],[8,'پردیس'], + [9,'شهرکرد'],[9,'بروجن'],[9,'فارسان'], + [10,'بیرجند'],[10,'قائنات'],[10,'فردوس'], + [11,'مشهد'],[11,'نیشابور'],[11,'سبزوار'],[11,'تربت‌حیدریه'], + [12,'بجنورد'],[12,'شیروان'],[12,'اسفراین'], + [13,'اهواز'],[13,'آبادان'],[13,'خرمشهر'],[13,'دزفول'],[13,'بهبهان'], + [14,'زنجان'],[14,'ابهر'],[14,'قیدار'], + [15,'سمنان'],[15,'شاهرود'],[15,'دامغان'], + [16,'زاهدان'],[16,'چابهار'],[16,'ایرانشهر'],[16,'زابل'], + [17,'شیراز'],[17,'مرودشت'],[17,'جهرم'],[17,'فسا'], + [18,'قزوین'],[18,'تاکستان'],[18,'البرز'], + [19,'قم'], + [20,'سنندج'],[20,'سقز'],[20,'مریوان'], + [21,'کرمان'],[21,'رفسنجان'],[21,'جیرفت'],[21,'سیرجان'], + [22,'کرمانشاه'],[22,'اسلام‌آباد غرب'],[22,'سرپل‌ذهاب'], + [23,'یاسوج'],[23,'دهدشت'],[23,'گچساران'], + [24,'گرگان'],[24,'گنبدکاووس'],[24,'علی‌آباد'], + [25,'رشت'],[25,'لاهیجان'],[25,'بندرانزلی'],[25,'رودبار'], + [26,'خرم‌آباد'],[26,'بروجرد'],[26,'دورود'], + [27,'ساری'],[27,'بابل'],[27,'آمل'],[27,'قائم‌شهر'],[27,'نوشهر'], + [28,'اراک'],[28,'خمین'],[28,'ساوه'], + [29,'بندرعباس'],[29,'قشم'],[29,'کیش'],[29,'میناب'], + [30,'همدان'],[30,'ملایر'],[30,'نهاوند'], + [31,'یزد'],[31,'میبد'],[31,'اردکان'], + ]; + foreach ($cities as [$stateId,$label]) { + $uuid = $this->genUuid(); + $this->addSql("INSERT INTO categories (uuid,bundle,label,status,parent_id,weight) VALUES ('{$uuid}','city','{$label}',1,{$stateId},0)"); + } + + // Medical Specialties + $specialties = [ + 'پزشک عمومی','قلب و عروق','مغز و اعصاب','ارتوپدی','چشم‌پزشکی', + 'گوش و حلق و بینی','دندانپزشکی','زنان و زایمان','اطفال','پوست و مو', + 'جراحی عمومی','داخلی','روانپزشکی','اورولوژی','فیزیوتراپی','روانشناسی', + 'غدد و متابولیسم','گوارش و کبد','ریه','کلیه و مجاری ادراری', + 'جراحی مغز و اعصاب','جراحی قلب','جراحی پلاستیک','تغذیه', + 'طب سنتی','دندانپزشکی کودکان','ارتودنسی','پریودنتیکس', + 'رادیولوژی','آزمایشگاه', + ]; + foreach ($specialties as $i => $label) { + $uuid = $this->genUuid(); + $this->addSql("INSERT INTO categories (uuid,bundle,label,status,weight) VALUES ('{$uuid}','specially_doctor','{$label}',1,{$i})"); + } + + // Doctor Services + $services = [ + 'ویزیت حضوری','ویزیت آنلاین','نوبت آنلاین','خدمات در منزل', + 'اورژانس','جراحی','آندوسکوپی','اکوکاردیوگرافی','نوار قلب (ECG)','اسپیرومتری', + ]; + foreach ($services as $i => $label) { + $uuid = $this->genUuid(); + $this->addSql("INSERT INTO categories (uuid,bundle,label,status,weight) VALUES ('{$uuid}','doctor_services','{$label}',1,{$i})"); + } + + // Insurance Types + $insuranceTypes = [ + 'تامین اجتماعی','بیمه خدمات درمانی','بیمه ارتش', + 'آزاد (بدون بیمه)','بیمه روستایی','بیمه کارمندی دولت', + ]; + foreach ($insuranceTypes as $i => $label) { + $uuid = $this->genUuid(); + $this->addSql("INSERT INTO categories (uuid,bundle,label,status,weight) VALUES ('{$uuid}','insurance_type','{$label}',1,{$i})"); + } + + // Supplementary Insurance + $suppInsurances = [ + 'بیمه ایران','بیمه آسیا','بیمه دی','بیمه ملت','بیمه پارسیان', + 'بیمه البرز','بیمه سینا','بیمه رازی','بیمه کوثر','بیمه نوین', + ]; + foreach ($suppInsurances as $i => $label) { + $uuid = $this->genUuid(); + $this->addSql("INSERT INTO categories (uuid,bundle,label,status,weight) VALUES ('{$uuid}','supplementary_insurance','{$label}',1,{$i})"); + } + + // Tags + $tags = [ + 'سلامت','تغذیه','ورزش','بیماری‌های قلبی','دیابت', + 'فشارخون','سرطان','زیبایی','کودکان','سالمندان', + ]; + foreach ($tags as $i => $label) { + $uuid = $this->genUuid(); + $this->addSql("INSERT INTO categories (uuid,bundle,label,status,weight) VALUES ('{$uuid}','tag','{$label}',1,{$i})"); + } + } + + public function down(Schema $schema): void + { + $this->addSql('DROP TABLE categories'); + } + + private function genUuid(): string + { + return sprintf( + '%04x%04x-%04x-%04x-%04x-%04x%04x%04x', + mt_rand(0, 0xffff), mt_rand(0, 0xffff), + mt_rand(0, 0xffff), + mt_rand(0, 0x0fff) | 0x4000, + mt_rand(0, 0x3fff) | 0x8000, + mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff) + ); + } +} diff --git a/migrations/Version20260609131553.php b/migrations/Version20260609131553.php new file mode 100644 index 00000000..41a891a2 --- /dev/null +++ b/migrations/Version20260609131553.php @@ -0,0 +1,33 @@ +addSql('ALTER TABLE categories CHANGE status status SMALLINT NOT NULL, CHANGE weight weight INT NOT NULL, CHANGE description description LONGTEXT DEFAULT NULL, CHANGE footer_description footer_description LONGTEXT DEFAULT NULL, CHANGE footer_disclaimer footer_disclaimer LONGTEXT DEFAULT NULL'); + $this->addSql('ALTER TABLE categories RENAME INDEX uniq_3af346686b548b0f TO UNIQ_3AF34668D17F50A6'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('ALTER TABLE categories CHANGE status status SMALLINT DEFAULT 1 NOT NULL, CHANGE weight weight INT DEFAULT 0 NOT NULL, CHANGE description description TEXT DEFAULT NULL, CHANGE footer_description footer_description TEXT DEFAULT NULL, CHANGE footer_disclaimer footer_disclaimer TEXT DEFAULT NULL'); + $this->addSql('ALTER TABLE categories RENAME INDEX uniq_3af34668d17f50a6 TO UNIQ_3AF346686B548B0F'); + } +} diff --git a/migrations/Version20260609132009.php b/migrations/Version20260609132009.php new file mode 100644 index 00000000..9fe488ce --- /dev/null +++ b/migrations/Version20260609132009.php @@ -0,0 +1,33 @@ +addSql('CREATE TABLE profiles (id INT AUTO_INCREMENT NOT NULL, uuid VARCHAR(36) NOT NULL, label VARCHAR(255) DEFAULT NULL, family VARCHAR(25) DEFAULT NULL, fathers_name VARCHAR(255) DEFAULT NULL, national_code VARCHAR(10) DEFAULT NULL, national_code_approved TINYINT NOT NULL, gender VARCHAR(10) DEFAULT NULL, date_of_birth INT DEFAULT NULL, blood_type VARCHAR(20) DEFAULT NULL, marital_status VARCHAR(30) DEFAULT NULL, education VARCHAR(100) DEFAULT NULL, job VARCHAR(100) DEFAULT NULL, address LONGTEXT DEFAULT NULL, home_phone VARCHAR(30) DEFAULT NULL, work_phone VARCHAR(30) DEFAULT NULL, insurance_id VARCHAR(50) DEFAULT NULL, basic_insurance_id INT DEFAULT NULL, supplementary_insurance_id INT DEFAULT NULL, other JSON DEFAULT NULL, sharing_with_user TINYINT NOT NULL, description LONGTEXT DEFAULT NULL, created_at INT NOT NULL, updated_at INT NOT NULL, user_id INT NOT NULL, UNIQUE INDEX UNIQ_8B308530D17F50A6 (uuid), INDEX idx_profiles_national_code (national_code), UNIQUE INDEX idx_profiles_user (user_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4'); + $this->addSql('ALTER TABLE profiles ADD CONSTRAINT FK_8B308530A76ED395 FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('ALTER TABLE profiles DROP FOREIGN KEY FK_8B308530A76ED395'); + $this->addSql('DROP TABLE profiles'); + } +} diff --git a/migrations/Version20260609132708.php b/migrations/Version20260609132708.php new file mode 100644 index 00000000..1c4563f4 --- /dev/null +++ b/migrations/Version20260609132708.php @@ -0,0 +1,61 @@ +addSql('CREATE TABLE doctor_addresses (id INT AUTO_INCREMENT NOT NULL, uuid VARCHAR(36) NOT NULL, name VARCHAR(255) DEFAULT NULL, address LONGTEXT DEFAULT NULL, telephone VARCHAR(50) DEFAULT NULL, latitude DOUBLE PRECISION DEFAULT NULL, longitude DOUBLE PRECISION DEFAULT NULL, created_at INT NOT NULL, updated_at INT NOT NULL, doctor_id INT NOT NULL, UNIQUE INDEX UNIQ_A0D814FDD17F50A6 (uuid), INDEX idx_doctor_addresses_doctor (doctor_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4'); + $this->addSql('CREATE TABLE doctors (id INT AUTO_INCREMENT NOT NULL, uuid VARCHAR(36) NOT NULL, name VARCHAR(255) NOT NULL, gender VARCHAR(10) DEFAULT NULL, medical_system_code VARCHAR(25) DEFAULT NULL, mobile_number VARCHAR(15) DEFAULT NULL, activity_time INT DEFAULT NULL, degree VARCHAR(30) DEFAULT NULL, info LONGTEXT DEFAULT NULL, images JSON DEFAULT NULL, doctor_rate DOUBLE PRECISION NOT NULL, doctor_rate_percentage DOUBLE PRECISION NOT NULL, active_doctor_appointment TINYINT NOT NULL, representation_id INT DEFAULT NULL, created_at INT NOT NULL, updated_at INT NOT NULL, user_id INT NOT NULL, UNIQUE INDEX UNIQ_B67687BED17F50A6 (uuid), INDEX idx_doctors_active (active_doctor_appointment), UNIQUE INDEX idx_doctors_user (user_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4'); + $this->addSql('CREATE TABLE doctor_specialties (doctor_id INT NOT NULL, category_id INT NOT NULL, INDEX IDX_C638E04B87F4FB17 (doctor_id), INDEX IDX_C638E04B12469DE2 (category_id), PRIMARY KEY (doctor_id, category_id)) DEFAULT CHARACTER SET utf8mb4'); + $this->addSql('CREATE TABLE doctor_expertise (doctor_id INT NOT NULL, category_id INT NOT NULL, INDEX IDX_ED88BE6087F4FB17 (doctor_id), INDEX IDX_ED88BE6012469DE2 (category_id), PRIMARY KEY (doctor_id, category_id)) DEFAULT CHARACTER SET utf8mb4'); + $this->addSql('CREATE TABLE doctor_states (doctor_id INT NOT NULL, category_id INT NOT NULL, INDEX IDX_955E47C087F4FB17 (doctor_id), INDEX IDX_955E47C012469DE2 (category_id), PRIMARY KEY (doctor_id, category_id)) DEFAULT CHARACTER SET utf8mb4'); + $this->addSql('CREATE TABLE doctor_cities (doctor_id INT NOT NULL, category_id INT NOT NULL, INDEX IDX_7DC181E687F4FB17 (doctor_id), INDEX IDX_7DC181E612469DE2 (category_id), PRIMARY KEY (doctor_id, category_id)) DEFAULT CHARACTER SET utf8mb4'); + $this->addSql('ALTER TABLE doctor_addresses ADD CONSTRAINT FK_A0D814FD87F4FB17 FOREIGN KEY (doctor_id) REFERENCES doctors (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE doctors ADD CONSTRAINT FK_B67687BEA76ED395 FOREIGN KEY (user_id) REFERENCES users (id)'); + $this->addSql('ALTER TABLE doctor_specialties ADD CONSTRAINT FK_C638E04B87F4FB17 FOREIGN KEY (doctor_id) REFERENCES doctors (id)'); + $this->addSql('ALTER TABLE doctor_specialties ADD CONSTRAINT FK_C638E04B12469DE2 FOREIGN KEY (category_id) REFERENCES categories (id)'); + $this->addSql('ALTER TABLE doctor_expertise ADD CONSTRAINT FK_ED88BE6087F4FB17 FOREIGN KEY (doctor_id) REFERENCES doctors (id)'); + $this->addSql('ALTER TABLE doctor_expertise ADD CONSTRAINT FK_ED88BE6012469DE2 FOREIGN KEY (category_id) REFERENCES categories (id)'); + $this->addSql('ALTER TABLE doctor_states ADD CONSTRAINT FK_955E47C087F4FB17 FOREIGN KEY (doctor_id) REFERENCES doctors (id)'); + $this->addSql('ALTER TABLE doctor_states ADD CONSTRAINT FK_955E47C012469DE2 FOREIGN KEY (category_id) REFERENCES categories (id)'); + $this->addSql('ALTER TABLE doctor_cities ADD CONSTRAINT FK_7DC181E687F4FB17 FOREIGN KEY (doctor_id) REFERENCES doctors (id)'); + $this->addSql('ALTER TABLE doctor_cities ADD CONSTRAINT FK_7DC181E612469DE2 FOREIGN KEY (category_id) REFERENCES categories (id)'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('ALTER TABLE doctor_addresses DROP FOREIGN KEY FK_A0D814FD87F4FB17'); + $this->addSql('ALTER TABLE doctors DROP FOREIGN KEY FK_B67687BEA76ED395'); + $this->addSql('ALTER TABLE doctor_specialties DROP FOREIGN KEY FK_C638E04B87F4FB17'); + $this->addSql('ALTER TABLE doctor_specialties DROP FOREIGN KEY FK_C638E04B12469DE2'); + $this->addSql('ALTER TABLE doctor_expertise DROP FOREIGN KEY FK_ED88BE6087F4FB17'); + $this->addSql('ALTER TABLE doctor_expertise DROP FOREIGN KEY FK_ED88BE6012469DE2'); + $this->addSql('ALTER TABLE doctor_states DROP FOREIGN KEY FK_955E47C087F4FB17'); + $this->addSql('ALTER TABLE doctor_states DROP FOREIGN KEY FK_955E47C012469DE2'); + $this->addSql('ALTER TABLE doctor_cities DROP FOREIGN KEY FK_7DC181E687F4FB17'); + $this->addSql('ALTER TABLE doctor_cities DROP FOREIGN KEY FK_7DC181E612469DE2'); + $this->addSql('DROP TABLE doctor_addresses'); + $this->addSql('DROP TABLE doctors'); + $this->addSql('DROP TABLE doctor_specialties'); + $this->addSql('DROP TABLE doctor_expertise'); + $this->addSql('DROP TABLE doctor_states'); + $this->addSql('DROP TABLE doctor_cities'); + } +} diff --git a/migrations/Version20260609133121.php b/migrations/Version20260609133121.php new file mode 100644 index 00000000..5857c21a --- /dev/null +++ b/migrations/Version20260609133121.php @@ -0,0 +1,57 @@ +addSql('CREATE TABLE clinics (id INT AUTO_INCREMENT NOT NULL, uuid VARCHAR(36) NOT NULL, name VARCHAR(255) DEFAULT NULL, info LONGTEXT DEFAULT NULL, address LONGTEXT DEFAULT NULL, telephone VARCHAR(50) DEFAULT NULL, is_24_7 TINYINT NOT NULL, working_days VARCHAR(255) DEFAULT NULL, latitude DOUBLE PRECISION DEFAULT NULL, longitude DOUBLE PRECISION DEFAULT NULL, city_id INT DEFAULT NULL, state_id INT DEFAULT NULL, representation_id INT DEFAULT NULL, images_clinic JSON DEFAULT NULL, clinic_logo JSON DEFAULT NULL, created_at INT NOT NULL, updated_at INT NOT NULL, user_id INT NOT NULL, UNIQUE INDEX UNIQ_D7053B66D17F50A6 (uuid), INDEX idx_clinics_owner (user_id), INDEX idx_clinics_city (city_id), INDEX idx_clinics_state (state_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4'); + $this->addSql('CREATE TABLE clinic_doctors (clinic_id INT NOT NULL, doctor_id INT NOT NULL, INDEX IDX_ED177046CC22AD4 (clinic_id), INDEX IDX_ED17704687F4FB17 (doctor_id), PRIMARY KEY (clinic_id, doctor_id)) DEFAULT CHARACTER SET utf8mb4'); + $this->addSql('CREATE TABLE clinic_specialties (clinic_id INT NOT NULL, category_id INT NOT NULL, INDEX IDX_3201DD37CC22AD4 (clinic_id), INDEX IDX_3201DD3712469DE2 (category_id), PRIMARY KEY (clinic_id, category_id)) DEFAULT CHARACTER SET utf8mb4'); + $this->addSql('CREATE TABLE clinic_services (clinic_id INT NOT NULL, category_id INT NOT NULL, INDEX IDX_C00FFAB0CC22AD4 (clinic_id), INDEX IDX_C00FFAB012469DE2 (category_id), PRIMARY KEY (clinic_id, category_id)) DEFAULT CHARACTER SET utf8mb4'); + $this->addSql('CREATE TABLE clinic_insurances (clinic_id INT NOT NULL, category_id INT NOT NULL, INDEX IDX_BE9FC898CC22AD4 (clinic_id), INDEX IDX_BE9FC89812469DE2 (category_id), PRIMARY KEY (clinic_id, category_id)) DEFAULT CHARACTER SET utf8mb4'); + $this->addSql('ALTER TABLE clinics ADD CONSTRAINT FK_D7053B66A76ED395 FOREIGN KEY (user_id) REFERENCES users (id)'); + $this->addSql('ALTER TABLE clinic_doctors ADD CONSTRAINT FK_ED177046CC22AD4 FOREIGN KEY (clinic_id) REFERENCES clinics (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE clinic_doctors ADD CONSTRAINT FK_ED17704687F4FB17 FOREIGN KEY (doctor_id) REFERENCES doctors (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE clinic_specialties ADD CONSTRAINT FK_3201DD37CC22AD4 FOREIGN KEY (clinic_id) REFERENCES clinics (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE clinic_specialties ADD CONSTRAINT FK_3201DD3712469DE2 FOREIGN KEY (category_id) REFERENCES categories (id)'); + $this->addSql('ALTER TABLE clinic_services ADD CONSTRAINT FK_C00FFAB0CC22AD4 FOREIGN KEY (clinic_id) REFERENCES clinics (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE clinic_services ADD CONSTRAINT FK_C00FFAB012469DE2 FOREIGN KEY (category_id) REFERENCES categories (id)'); + $this->addSql('ALTER TABLE clinic_insurances ADD CONSTRAINT FK_BE9FC898CC22AD4 FOREIGN KEY (clinic_id) REFERENCES clinics (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE clinic_insurances ADD CONSTRAINT FK_BE9FC89812469DE2 FOREIGN KEY (category_id) REFERENCES categories (id)'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('ALTER TABLE clinics DROP FOREIGN KEY FK_D7053B66A76ED395'); + $this->addSql('ALTER TABLE clinic_doctors DROP FOREIGN KEY FK_ED177046CC22AD4'); + $this->addSql('ALTER TABLE clinic_doctors DROP FOREIGN KEY FK_ED17704687F4FB17'); + $this->addSql('ALTER TABLE clinic_specialties DROP FOREIGN KEY FK_3201DD37CC22AD4'); + $this->addSql('ALTER TABLE clinic_specialties DROP FOREIGN KEY FK_3201DD3712469DE2'); + $this->addSql('ALTER TABLE clinic_services DROP FOREIGN KEY FK_C00FFAB0CC22AD4'); + $this->addSql('ALTER TABLE clinic_services DROP FOREIGN KEY FK_C00FFAB012469DE2'); + $this->addSql('ALTER TABLE clinic_insurances DROP FOREIGN KEY FK_BE9FC898CC22AD4'); + $this->addSql('ALTER TABLE clinic_insurances DROP FOREIGN KEY FK_BE9FC89812469DE2'); + $this->addSql('DROP TABLE clinics'); + $this->addSql('DROP TABLE clinic_doctors'); + $this->addSql('DROP TABLE clinic_specialties'); + $this->addSql('DROP TABLE clinic_services'); + $this->addSql('DROP TABLE clinic_insurances'); + } +} diff --git a/migrations/Version20260609133334.php b/migrations/Version20260609133334.php new file mode 100644 index 00000000..ad81bf3d --- /dev/null +++ b/migrations/Version20260609133334.php @@ -0,0 +1,35 @@ +addSql('CREATE TABLE doctor_insurances (id INT AUTO_INCREMENT NOT NULL, price INT DEFAULT NULL, doctor_id INT NOT NULL, category_id INT NOT NULL, INDEX IDX_5317E58E87F4FB17 (doctor_id), INDEX idx_doctor_insurance_cat (category_id), UNIQUE INDEX idx_doctor_insurance (doctor_id, category_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4'); + $this->addSql('ALTER TABLE doctor_insurances ADD CONSTRAINT FK_5317E58E87F4FB17 FOREIGN KEY (doctor_id) REFERENCES doctors (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE doctor_insurances ADD CONSTRAINT FK_5317E58E12469DE2 FOREIGN KEY (category_id) REFERENCES categories (id)'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('ALTER TABLE doctor_insurances DROP FOREIGN KEY FK_5317E58E87F4FB17'); + $this->addSql('ALTER TABLE doctor_insurances DROP FOREIGN KEY FK_5317E58E12469DE2'); + $this->addSql('DROP TABLE doctor_insurances'); + } +} diff --git a/migrations/Version20260609133546.php b/migrations/Version20260609133546.php new file mode 100644 index 00000000..6298bfed --- /dev/null +++ b/migrations/Version20260609133546.php @@ -0,0 +1,35 @@ +addSql('CREATE TABLE doctor_secretaries (id INT AUTO_INCREMENT NOT NULL, uuid VARCHAR(36) NOT NULL, permission JSON DEFAULT NULL, active TINYINT NOT NULL, created_at INT NOT NULL, updated_at INT NOT NULL, doctor_id INT NOT NULL, secretary_id INT NOT NULL, UNIQUE INDEX UNIQ_8DF480E9D17F50A6 (uuid), INDEX IDX_8DF480E987F4FB17 (doctor_id), INDEX IDX_8DF480E9A2A63DB2 (secretary_id), UNIQUE INDEX idx_doctor_secretaries_pair (doctor_id, secretary_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4'); + $this->addSql('ALTER TABLE doctor_secretaries ADD CONSTRAINT FK_8DF480E987F4FB17 FOREIGN KEY (doctor_id) REFERENCES doctors (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE doctor_secretaries ADD CONSTRAINT FK_8DF480E9A2A63DB2 FOREIGN KEY (secretary_id) REFERENCES users (id)'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('ALTER TABLE doctor_secretaries DROP FOREIGN KEY FK_8DF480E987F4FB17'); + $this->addSql('ALTER TABLE doctor_secretaries DROP FOREIGN KEY FK_8DF480E9A2A63DB2'); + $this->addSql('DROP TABLE doctor_secretaries'); + } +} diff --git a/migrations/Version20260609134112.php b/migrations/Version20260609134112.php new file mode 100644 index 00000000..e1a3702d --- /dev/null +++ b/migrations/Version20260609134112.php @@ -0,0 +1,41 @@ +addSql('CREATE TABLE date_overrides (id INT AUTO_INCREMENT NOT NULL, uuid VARCHAR(36) NOT NULL, date INT NOT NULL, active TINYINT NOT NULL, setting JSON DEFAULT NULL, reason VARCHAR(255) DEFAULT NULL, created_at INT NOT NULL, updated_at INT NOT NULL, doctor_id INT NOT NULL, UNIQUE INDEX UNIQ_F49AE94ED17F50A6 (uuid), INDEX IDX_F49AE94E87F4FB17 (doctor_id), INDEX idx_date_overrides_doctor_date (doctor_id, date), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4'); + $this->addSql('CREATE TABLE holidays (id INT AUTO_INCREMENT NOT NULL, uuid VARCHAR(36) NOT NULL, start_date INT NOT NULL, end_date INT NOT NULL, active TINYINT NOT NULL, reason VARCHAR(255) DEFAULT NULL, created_at INT NOT NULL, updated_at INT NOT NULL, doctor_id INT NOT NULL, UNIQUE INDEX UNIQ_3A66A10CD17F50A6 (uuid), INDEX IDX_3A66A10C87F4FB17 (doctor_id), INDEX idx_holidays_doctor_range (doctor_id, start_date, end_date), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4'); + $this->addSql('CREATE TABLE weekly_schedules (id INT AUTO_INCREMENT NOT NULL, uuid VARCHAR(36) NOT NULL, setting JSON NOT NULL, created_at INT NOT NULL, updated_at INT NOT NULL, doctor_id INT NOT NULL, UNIQUE INDEX UNIQ_69C327F2D17F50A6 (uuid), UNIQUE INDEX idx_weekly_schedules_doctor (doctor_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4'); + $this->addSql('ALTER TABLE date_overrides ADD CONSTRAINT FK_F49AE94E87F4FB17 FOREIGN KEY (doctor_id) REFERENCES doctors (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE holidays ADD CONSTRAINT FK_3A66A10C87F4FB17 FOREIGN KEY (doctor_id) REFERENCES doctors (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE weekly_schedules ADD CONSTRAINT FK_69C327F287F4FB17 FOREIGN KEY (doctor_id) REFERENCES doctors (id) ON DELETE CASCADE'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('ALTER TABLE date_overrides DROP FOREIGN KEY FK_F49AE94E87F4FB17'); + $this->addSql('ALTER TABLE holidays DROP FOREIGN KEY FK_3A66A10C87F4FB17'); + $this->addSql('ALTER TABLE weekly_schedules DROP FOREIGN KEY FK_69C327F287F4FB17'); + $this->addSql('DROP TABLE date_overrides'); + $this->addSql('DROP TABLE holidays'); + $this->addSql('DROP TABLE weekly_schedules'); + } +} diff --git a/migrations/Version20260609134741.php b/migrations/Version20260609134741.php new file mode 100644 index 00000000..bd310cc7 --- /dev/null +++ b/migrations/Version20260609134741.php @@ -0,0 +1,35 @@ +addSql('CREATE TABLE appointments (version INT DEFAULT 1 NOT NULL, id INT AUTO_INCREMENT NOT NULL, uuid VARCHAR(36) NOT NULL, slot_start INT NOT NULL, slot_end INT NOT NULL, status VARCHAR(30) NOT NULL, note VARCHAR(255) DEFAULT NULL, created_at INT NOT NULL, updated_at INT NOT NULL, doctor_id INT NOT NULL, user_id INT NOT NULL, UNIQUE INDEX UNIQ_6A41727AD17F50A6 (uuid), INDEX IDX_6A41727A87F4FB17 (doctor_id), INDEX IDX_6A41727AA76ED395 (user_id), INDEX idx_appointments_doctor_slot (doctor_id, slot_start), INDEX idx_appointments_user_status (user_id, status), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4'); + $this->addSql('ALTER TABLE appointments ADD CONSTRAINT FK_6A41727A87F4FB17 FOREIGN KEY (doctor_id) REFERENCES doctors (id) ON DELETE RESTRICT'); + $this->addSql('ALTER TABLE appointments ADD CONSTRAINT FK_6A41727AA76ED395 FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE RESTRICT'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('ALTER TABLE appointments DROP FOREIGN KEY FK_6A41727A87F4FB17'); + $this->addSql('ALTER TABLE appointments DROP FOREIGN KEY FK_6A41727AA76ED395'); + $this->addSql('DROP TABLE appointments'); + } +} diff --git a/migrations/Version20260609135126.php b/migrations/Version20260609135126.php new file mode 100644 index 00000000..a6f18dcc --- /dev/null +++ b/migrations/Version20260609135126.php @@ -0,0 +1,35 @@ +addSql('CREATE TABLE payments (id INT AUTO_INCREMENT NOT NULL, uuid VARCHAR(36) NOT NULL, order_id VARCHAR(64) NOT NULL, amount_rials INT NOT NULL, status VARCHAR(30) NOT NULL, gateway VARCHAR(20) NOT NULL, type VARCHAR(30) NOT NULL, gateway_token VARCHAR(255) DEFAULT NULL, reference_id VARCHAR(255) DEFAULT NULL, frontend_address VARCHAR(500) DEFAULT NULL, callback_ip VARCHAR(45) DEFAULT NULL, created_at INT NOT NULL, updated_at INT NOT NULL, user_id INT NOT NULL, appointment_id INT DEFAULT NULL, UNIQUE INDEX UNIQ_65D29B32D17F50A6 (uuid), UNIQUE INDEX UNIQ_65D29B328D9F6D38 (order_id), INDEX IDX_65D29B32E5B533F9 (appointment_id), INDEX idx_payments_order (order_id), INDEX idx_payments_user (user_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4'); + $this->addSql('ALTER TABLE payments ADD CONSTRAINT FK_65D29B32A76ED395 FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE RESTRICT'); + $this->addSql('ALTER TABLE payments ADD CONSTRAINT FK_65D29B32E5B533F9 FOREIGN KEY (appointment_id) REFERENCES appointments (id) ON DELETE SET NULL'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('ALTER TABLE payments DROP FOREIGN KEY FK_65D29B32A76ED395'); + $this->addSql('ALTER TABLE payments DROP FOREIGN KEY FK_65D29B32E5B533F9'); + $this->addSql('DROP TABLE payments'); + } +} diff --git a/migrations/Version20260609135514.php b/migrations/Version20260609135514.php new file mode 100644 index 00000000..ba982d2e --- /dev/null +++ b/migrations/Version20260609135514.php @@ -0,0 +1,33 @@ +addSql('CREATE TABLE representations (id INT AUTO_INCREMENT NOT NULL, uuid VARCHAR(36) NOT NULL, full_name VARCHAR(255) NOT NULL, mobile_number VARCHAR(20) DEFAULT NULL, city VARCHAR(255) DEFAULT NULL, commission_percent NUMERIC(5, 2) NOT NULL, bank_account JSON DEFAULT NULL, active TINYINT NOT NULL, created_at INT NOT NULL, updated_at INT NOT NULL, user_id INT NOT NULL, UNIQUE INDEX UNIQ_C90A401D17F50A6 (uuid), UNIQUE INDEX UNIQ_C90A401A76ED395 (user_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4'); + $this->addSql('ALTER TABLE representations ADD CONSTRAINT FK_C90A401A76ED395 FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('ALTER TABLE representations DROP FOREIGN KEY FK_C90A401A76ED395'); + $this->addSql('DROP TABLE representations'); + } +} diff --git a/migrations/Version20260609135704.php b/migrations/Version20260609135704.php new file mode 100644 index 00000000..d8cbda39 --- /dev/null +++ b/migrations/Version20260609135704.php @@ -0,0 +1,39 @@ +addSql('CREATE TABLE settlements (id INT AUTO_INCREMENT NOT NULL, uuid VARCHAR(36) NOT NULL, amount_rials INT NOT NULL, status VARCHAR(20) NOT NULL, bank_account JSON DEFAULT NULL, admin_note VARCHAR(500) DEFAULT NULL, reviewed_by INT DEFAULT NULL, reviewed_at INT DEFAULT NULL, created_at INT NOT NULL, updated_at INT NOT NULL, user_id INT NOT NULL, UNIQUE INDEX UNIQ_7BF3172D17F50A6 (uuid), INDEX IDX_7BF3172A76ED395 (user_id), INDEX idx_settlements_user_status (user_id, status), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4'); + $this->addSql('CREATE TABLE wallet_transactions (id INT AUTO_INCREMENT NOT NULL, uuid VARCHAR(36) NOT NULL, amount_rials INT NOT NULL, type VARCHAR(10) NOT NULL, description VARCHAR(255) DEFAULT NULL, balance_after INT NOT NULL, created_at INT NOT NULL, user_id INT NOT NULL, payment_id INT DEFAULT NULL, UNIQUE INDEX UNIQ_A50205E2D17F50A6 (uuid), INDEX IDX_A50205E2A76ED395 (user_id), INDEX IDX_A50205E24C3A3BB (payment_id), INDEX idx_wallet_user_date (user_id, created_at), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4'); + $this->addSql('ALTER TABLE settlements ADD CONSTRAINT FK_7BF3172A76ED395 FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE RESTRICT'); + $this->addSql('ALTER TABLE wallet_transactions ADD CONSTRAINT FK_A50205E2A76ED395 FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE RESTRICT'); + $this->addSql('ALTER TABLE wallet_transactions ADD CONSTRAINT FK_A50205E24C3A3BB FOREIGN KEY (payment_id) REFERENCES payments (id) ON DELETE SET NULL'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('ALTER TABLE settlements DROP FOREIGN KEY FK_7BF3172A76ED395'); + $this->addSql('ALTER TABLE wallet_transactions DROP FOREIGN KEY FK_A50205E2A76ED395'); + $this->addSql('ALTER TABLE wallet_transactions DROP FOREIGN KEY FK_A50205E24C3A3BB'); + $this->addSql('DROP TABLE settlements'); + $this->addSql('DROP TABLE wallet_transactions'); + } +} diff --git a/migrations/Version20260609135923.php b/migrations/Version20260609135923.php new file mode 100644 index 00000000..8becdcb0 --- /dev/null +++ b/migrations/Version20260609135923.php @@ -0,0 +1,47 @@ +addSql('CREATE TABLE comments (id INT AUTO_INCREMENT NOT NULL, uuid VARCHAR(36) NOT NULL, body LONGTEXT NOT NULL, status VARCHAR(20) NOT NULL, created_at INT NOT NULL, updated_at INT NOT NULL, user_id INT NOT NULL, doctor_id INT NOT NULL, UNIQUE INDEX UNIQ_5F9E962AD17F50A6 (uuid), INDEX IDX_5F9E962AA76ED395 (user_id), INDEX IDX_5F9E962A87F4FB17 (doctor_id), INDEX idx_comments_doctor_status (doctor_id, status), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4'); + $this->addSql('CREATE TABLE likes (id INT AUTO_INCREMENT NOT NULL, uuid VARCHAR(36) NOT NULL, created_at INT NOT NULL, user_id INT NOT NULL, comment_id INT NOT NULL, UNIQUE INDEX UNIQ_49CA4E7DD17F50A6 (uuid), INDEX IDX_49CA4E7DA76ED395 (user_id), INDEX IDX_49CA4E7DF8697D13 (comment_id), UNIQUE INDEX idx_likes_user_comment (user_id, comment_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4'); + $this->addSql('CREATE TABLE rates (id INT AUTO_INCREMENT NOT NULL, uuid VARCHAR(36) NOT NULL, score SMALLINT NOT NULL, created_at INT NOT NULL, updated_at INT NOT NULL, user_id INT NOT NULL, doctor_id INT NOT NULL, UNIQUE INDEX UNIQ_44D4AB3CD17F50A6 (uuid), INDEX IDX_44D4AB3CA76ED395 (user_id), INDEX IDX_44D4AB3C87F4FB17 (doctor_id), UNIQUE INDEX idx_rates_user_doctor (user_id, doctor_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4'); + $this->addSql('ALTER TABLE comments ADD CONSTRAINT FK_5F9E962AA76ED395 FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE comments ADD CONSTRAINT FK_5F9E962A87F4FB17 FOREIGN KEY (doctor_id) REFERENCES doctors (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE likes ADD CONSTRAINT FK_49CA4E7DA76ED395 FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE likes ADD CONSTRAINT FK_49CA4E7DF8697D13 FOREIGN KEY (comment_id) REFERENCES comments (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE rates ADD CONSTRAINT FK_44D4AB3CA76ED395 FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE rates ADD CONSTRAINT FK_44D4AB3C87F4FB17 FOREIGN KEY (doctor_id) REFERENCES doctors (id) ON DELETE CASCADE'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('ALTER TABLE comments DROP FOREIGN KEY FK_5F9E962AA76ED395'); + $this->addSql('ALTER TABLE comments DROP FOREIGN KEY FK_5F9E962A87F4FB17'); + $this->addSql('ALTER TABLE likes DROP FOREIGN KEY FK_49CA4E7DA76ED395'); + $this->addSql('ALTER TABLE likes DROP FOREIGN KEY FK_49CA4E7DF8697D13'); + $this->addSql('ALTER TABLE rates DROP FOREIGN KEY FK_44D4AB3CA76ED395'); + $this->addSql('ALTER TABLE rates DROP FOREIGN KEY FK_44D4AB3C87F4FB17'); + $this->addSql('DROP TABLE comments'); + $this->addSql('DROP TABLE likes'); + $this->addSql('DROP TABLE rates'); + } +} diff --git a/migrations/Version20260609140223.php b/migrations/Version20260609140223.php new file mode 100644 index 00000000..678c3286 --- /dev/null +++ b/migrations/Version20260609140223.php @@ -0,0 +1,33 @@ +addSql('CREATE TABLE sms_logs (id INT AUTO_INCREMENT NOT NULL, uuid VARCHAR(36) NOT NULL, mobile VARCHAR(20) NOT NULL, message LONGTEXT NOT NULL, provider VARCHAR(20) NOT NULL, success TINYINT NOT NULL, template_uuid VARCHAR(36) DEFAULT NULL, created_at INT NOT NULL, UNIQUE INDEX UNIQ_4BA25A0AD17F50A6 (uuid), INDEX idx_sms_logs_mobile (mobile, created_at), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4'); + $this->addSql('CREATE TABLE sms_templates (id INT AUTO_INCREMENT NOT NULL, uuid VARCHAR(36) NOT NULL, name VARCHAR(100) NOT NULL, body LONGTEXT NOT NULL, provider_code VARCHAR(100) DEFAULT NULL, status VARCHAR(20) NOT NULL, variables JSON NOT NULL, admin_note VARCHAR(500) DEFAULT NULL, created_at INT NOT NULL, updated_at INT NOT NULL, UNIQUE INDEX UNIQ_1849BB39D17F50A6 (uuid), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('DROP TABLE sms_logs'); + $this->addSql('DROP TABLE sms_templates'); + } +} diff --git a/migrations/Version20260609140423.php b/migrations/Version20260609140423.php new file mode 100644 index 00000000..25623ebf --- /dev/null +++ b/migrations/Version20260609140423.php @@ -0,0 +1,33 @@ +addSql('CREATE TABLE blogs (id INT AUTO_INCREMENT NOT NULL, uuid VARCHAR(36) NOT NULL, title VARCHAR(255) NOT NULL, slug VARCHAR(255) NOT NULL, body LONGTEXT NOT NULL, summary VARCHAR(500) DEFAULT NULL, image_url VARCHAR(500) DEFAULT NULL, image_path VARCHAR(500) DEFAULT NULL, tags JSON NOT NULL, status VARCHAR(20) NOT NULL, created_at INT NOT NULL, updated_at INT NOT NULL, author_id INT NOT NULL, UNIQUE INDEX UNIQ_F41BCA70D17F50A6 (uuid), UNIQUE INDEX UNIQ_F41BCA70989D9B62 (slug), INDEX IDX_F41BCA70F675F31B (author_id), INDEX idx_blogs_status (status, created_at), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4'); + $this->addSql('ALTER TABLE blogs ADD CONSTRAINT FK_F41BCA70F675F31B FOREIGN KEY (author_id) REFERENCES users (id) ON DELETE RESTRICT'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('ALTER TABLE blogs DROP FOREIGN KEY FK_F41BCA70F675F31B'); + $this->addSql('DROP TABLE blogs'); + } +} diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 00000000..06a70fe9 --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,14 @@ +includes: + - vendor/phpstan/phpstan-symfony/extension.neon + - vendor/phpstan/phpstan-doctrine/extension.neon + +parameters: + level: 5 + paths: + - src + symfony: + container_xml_path: var/cache/dev/App_KernelDevDebugContainer.xml + doctrine: + objectManagerLoader: tests/doctrine_object_manager.php + ignoreErrors: + - '#Call to an undefined method Doctrine\\ORM\\QueryBuilder::#' diff --git a/phpunit.dist.xml b/phpunit.dist.xml new file mode 100644 index 00000000..22bd8791 --- /dev/null +++ b/phpunit.dist.xml @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + tests + + + + + + src + + + + Doctrine\Deprecations\Deprecation::trigger + Doctrine\Deprecations\Deprecation::delegateTriggerToBackend + trigger_deprecation + + + + + + diff --git a/public/index.php b/public/index.php new file mode 100644 index 00000000..c0037a8d --- /dev/null +++ b/public/index.php @@ -0,0 +1,9 @@ +appointmentRepo->findExpiredPending(time()); + $count = 0; + + foreach ($expired as $appointment) { + $appointment->transitionTo(Appointment::STATUS_EXPIRED); + $this->appointmentRepo->save($appointment, false); + $count++; + } + + if ($count > 0) { + $this->appointmentRepo->save($expired[0]); // flush once + } + + $output->writeln(sprintf('Expired %d appointments.', $count)); + return Command::SUCCESS; + } +} diff --git a/src/Appointment/Controller/AppointmentController.php b/src/Appointment/Controller/AppointmentController.php new file mode 100644 index 00000000..80bd694d --- /dev/null +++ b/src/Appointment/Controller/AppointmentController.php @@ -0,0 +1,177 @@ +query->get('doctor_uuid', '')); + $date = trim($request->query->get('date', '')); + + $doctor = $this->doctorRepo->findByUuid($doctorUuid); + if ($doctor === null) { + return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دکتر یافت نشد', 404); + } + + if (empty($date) || !preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) { + return $this->error(ErrorCodes::ERR_VALIDATION_001, 'فرمت تاریخ نادرست است (Y-m-d)', 422, 'date'); + } + + $slots = $this->slotCalculator->getAvailableSlots($doctor, $date); + + return $this->success([ + 'doctor_uuid' => $doctorUuid, + 'date' => $date, + 'slots' => $slots, + ]); + } + + // ── Authenticated: book / manage ───────────────────────────────────────── + + #[IsGranted('IS_AUTHENTICATED_FULLY')] + #[Route('/api/v1/appointment', methods: ['POST'])] + public function book(Request $request, #[CurrentUser] User $user): JsonResponse + { + $data = json_decode($request->getContent(), true) ?? []; + $doctorUuid = trim($data['doctor_uuid'] ?? ''); + $slotStart = (int) ($data['slot_start'] ?? 0); + $slotEnd = (int) ($data['slot_end'] ?? 0); + + if (empty($doctorUuid) || $slotStart <= 0 || $slotEnd <= $slotStart) { + return $this->error(ErrorCodes::ERR_VALIDATION_001, 'doctor_uuid، slot_start و slot_end الزامی است', 422); + } + + $doctor = $this->doctorRepo->findByUuid($doctorUuid); + if ($doctor === null) { + return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دکتر یافت نشد', 404); + } + + if ($this->appointmentRepo->isSlotTaken($doctor, $slotStart, $slotEnd)) { + return $this->error(ErrorCodes::ERR_CONFLICT_001, 'این نوبت قبلاً رزرو شده است', 409); + } + + $appointment = new Appointment($doctor, $user, $slotStart, $slotEnd); + if (isset($data['note'])) $appointment->setNote($data['note']); + + $this->appointmentRepo->save($appointment); + + return $this->success(['data' => $appointment->toArray()], 201); + } + + #[IsGranted('IS_AUTHENTICATED_FULLY')] + #[Route('/api/v1/appointment/{uuid}', methods: ['GET'])] + public function get(string $uuid, #[CurrentUser] User $user): JsonResponse + { + $appointment = $this->appointmentRepo->findByUuid($uuid); + if ($appointment === null) { + return $this->error(ErrorCodes::ERR_VALIDATION_002, 'نوبت یافت نشد', 404); + } + + if (!$this->canView($appointment, $user)) { + return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403); + } + + return $this->success(['data' => $appointment->toArray()]); + } + + #[IsGranted('IS_AUTHENTICATED_FULLY')] + #[Route('/api/v1/appointments/doctor/{doctorUuid}', methods: ['GET'])] + public function listByDoctor(string $doctorUuid, Request $request, #[CurrentUser] User $user): JsonResponse + { + $doctor = $this->doctorRepo->findByUuid($doctorUuid); + if ($doctor === null) { + return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دکتر یافت نشد', 404); + } + + if ($doctor->getUser()->getId() !== $user->getId() && !$user->hasRole('ROLE_ADMIN')) { + return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403); + } + + $status = $request->query->get('status'); + $appointments = $this->appointmentRepo->findByDoctor($doctor, $status); + + return $this->success(['data' => array_map(fn(Appointment $a) => $a->toArray(), $appointments)]); + } + + #[IsGranted('IS_AUTHENTICATED_FULLY')] + #[Route('/api/v1/appointments/user', methods: ['GET'])] + public function listByUser(Request $request, #[CurrentUser] User $user): JsonResponse + { + $status = $request->query->get('status'); + $appointments = $this->appointmentRepo->findByUser($user, $status); + + return $this->success(['data' => array_map(fn(Appointment $a) => $a->toArray(), $appointments)]); + } + + private function canView(Appointment $a, User $user): bool + { + return $a->getUser()->getId() === $user->getId() + || $a->getDoctor()->getUser()->getId() === $user->getId() + || $user->hasRole('ROLE_ADMIN'); + } + + private function canManage(Appointment $a, User $user): bool + { + return $a->getUser()->getId() === $user->getId() + || $a->getDoctor()->getUser()->getId() === $user->getId() + || $user->hasRole('ROLE_ADMIN'); + } + + #[IsGranted('IS_AUTHENTICATED_FULLY')] + #[Route('/api/v1/appointment/{uuid}/status', methods: ['PATCH'])] + public function updateStatus(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse + { + $appointment = $this->appointmentRepo->findByUuid($uuid); + if ($appointment === null) { + return $this->error(ErrorCodes::ERR_VALIDATION_002, 'نوبت یافت نشد', 404); + } + + if (!$this->canManage($appointment, $user)) { + return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403); + } + + $data = json_decode($request->getContent(), true) ?? []; + $newStatus = trim($data['status'] ?? ''); + $version = (int) ($data['version'] ?? $appointment->getVersion()); + + if (!$appointment->canTransitionTo($newStatus)) { + return $this->error(ErrorCodes::ERR_VALIDATION_001, sprintf( + 'انتقال از "%s" به "%s" مجاز نیست', $appointment->getStatus(), $newStatus + ), 422); + } + + $appointment->transitionTo($newStatus); + + try { + $this->appointmentRepo->saveWithLock($appointment, $version); + } catch (OptimisticLockException) { + return $this->error(ErrorCodes::ERR_CONFLICT_001, 'تداخل ویرایش همزمان. لطفاً دوباره تلاش کنید', 409); + } + + return $this->success(['data' => $appointment->toArray()]); + } +} diff --git a/src/Appointment/Controller/AppointmentSettingsController.php b/src/Appointment/Controller/AppointmentSettingsController.php new file mode 100644 index 00000000..c6a3b2fd --- /dev/null +++ b/src/Appointment/Controller/AppointmentSettingsController.php @@ -0,0 +1,286 @@ +getContent(), true) ?? []; + $doctorUuid = trim($data['doctor_uuid'] ?? ''); + + $doctor = $this->doctorRepo->findByUuid($doctorUuid); + if ($doctor === null) { + return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دکتر یافت نشد', 404); + } + + if ($doctor->getUser()->getId() !== $user->getId() && !$user->hasRole('ROLE_ADMIN')) { + return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403); + } + + // Only one schedule per doctor — upsert + $schedule = $this->scheduleRepo->findByDoctor($doctor); + if ($schedule !== null) { + $schedule->setSetting($data['schedule'] ?? []); + } else { + $schedule = new WeeklySchedule($doctor, $data['schedule'] ?? []); + } + + $this->scheduleRepo->save($schedule); + + return $this->success(['data' => $schedule->toArray()], 201); + } + + #[Route('/api/v1/appointment-settings/weekly-schedule/{uuid}', methods: ['PATCH'])] + public function updateSchedule(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse + { + // uuid may be doctor uuid or schedule uuid + $schedule = $this->scheduleRepo->findByUuid($uuid); + if ($schedule === null) { + $doctor = $this->doctorRepo->findByUuid($uuid); + $schedule = $doctor ? $this->scheduleRepo->findByDoctor($doctor) : null; + } + + if ($schedule === null) { + return $this->error(ErrorCodes::ERR_VALIDATION_002, 'برنامه یافت نشد', 404); + } + + if ($schedule->getDoctor()->getUser()->getId() !== $user->getId() && !$user->hasRole('ROLE_ADMIN')) { + return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403); + } + + $data = json_decode($request->getContent(), true) ?? []; + if (isset($data['schedule'])) { + $schedule->setSetting($data['schedule']); + } + + $this->scheduleRepo->save($schedule); + + return $this->success(['data' => $schedule->toArray()]); + } + + #[Route('/api/v1/appointment-settings/weekly-schedule/{uuid}', methods: ['GET'])] + public function getSchedule(string $uuid): JsonResponse + { + // Try doctor uuid first, then schedule uuid + $doctor = $this->doctorRepo->findByUuid($uuid); + $schedule = $doctor + ? $this->scheduleRepo->findByDoctor($doctor) + : $this->scheduleRepo->findByUuid($uuid); + + if ($schedule === null) { + return $this->error(ErrorCodes::ERR_VALIDATION_002, 'برنامه یافت نشد', 404); + } + + return $this->success(['data' => $schedule->toArray()]); + } + + #[Route('/api/v1/booking-setting/{uuid}', methods: ['DELETE'])] + public function deleteSchedule(string $uuid, #[CurrentUser] User $user): JsonResponse + { + $schedule = $this->scheduleRepo->findByUuid($uuid); + if ($schedule === null) { + return $this->error(ErrorCodes::ERR_VALIDATION_002, 'برنامه یافت نشد', 404); + } + + if ($schedule->getDoctor()->getUser()->getId() !== $user->getId() && !$user->hasRole('ROLE_ADMIN')) { + return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403); + } + + $this->scheduleRepo->remove($schedule); + + return $this->success(['message' => 'برنامه هفتگی با موفقیت حذف شد']); + } + + // ── Date Overrides ──────────────────────────────────────────────────────── + + #[Route('/api/v1/appointment-settings/date-override/list/{doctorUuid}', methods: ['GET'])] + public function listOverrides(string $doctorUuid): JsonResponse + { + $doctor = $this->doctorRepo->findByUuid($doctorUuid); + if ($doctor === null) { + return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دکتر یافت نشد', 404); + } + + $overrides = array_map( + fn(DateOverride $o) => $o->toArray(), + $this->overrideRepo->findByDoctor($doctor) + ); + + return $this->success(['data' => $overrides]); + } + + #[Route('/api/v1/appointment-settings/date-override', methods: ['POST'])] + public function createOverride(Request $request, #[CurrentUser] User $user): JsonResponse + { + $data = json_decode($request->getContent(), true) ?? []; + $doctorUuid = trim($data['doctor_uuid'] ?? ''); + $dateStr = trim($data['date'] ?? ''); + + $doctor = $this->doctorRepo->findByUuid($doctorUuid); + if ($doctor === null) { + return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دکتر یافت نشد', 404); + } + + if ($doctor->getUser()->getId() !== $user->getId() && !$user->hasRole('ROLE_ADMIN')) { + return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403); + } + + $timestamp = strtotime($dateStr); + if ($timestamp === false || $timestamp === -1) { + return $this->error(ErrorCodes::ERR_VALIDATION_001, 'فرمت تاریخ نادرست است', 422, 'date'); + } + + $override = new DateOverride($doctor, $timestamp, (bool) ($data['active'] ?? false)); + if (isset($data['reason'])) $override->setReason($data['reason']); + if (isset($data['custom_slots'])) $override->setSetting($data['custom_slots']); + + $this->overrideRepo->save($override); + + return $this->success(['data' => $override->toArray()], 201); + } + + #[Route('/api/v1/appointment-settings/date-override/{uuid}', methods: ['PATCH'])] + public function updateOverride(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse + { + $override = $this->overrideRepo->findByUuid($uuid); + if ($override === null) { + return $this->error(ErrorCodes::ERR_VALIDATION_002, 'Override یافت نشد', 404); + } + + if ($override->getDoctor()->getUser()->getId() !== $user->getId() && !$user->hasRole('ROLE_ADMIN')) { + return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403); + } + + $data = json_decode($request->getContent(), true) ?? []; + if (array_key_exists('active', $data)) $override->setActive((bool) $data['active']); + if (array_key_exists('reason', $data)) $override->setReason($data['reason']); + if (array_key_exists('custom_slots', $data)) $override->setSetting($data['custom_slots']); + if (!empty($data['date'])) { + $ts = strtotime($data['date']); + if ($ts !== false) $override->setDate($ts); + } + + $this->overrideRepo->save($override); + + return $this->success(['data' => $override->toArray()]); + } + + #[Route('/api/v1/appointment-settings/date-override/{uuid}', methods: ['DELETE'])] + public function deleteOverride(string $uuid, #[CurrentUser] User $user): JsonResponse + { + $override = $this->overrideRepo->findByUuid($uuid); + if ($override === null) { + return $this->error(ErrorCodes::ERR_VALIDATION_002, 'Override یافت نشد', 404); + } + + if ($override->getDoctor()->getUser()->getId() !== $user->getId() && !$user->hasRole('ROLE_ADMIN')) { + return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403); + } + + $this->overrideRepo->remove($override); + + return $this->success(['message' => 'Override با موفقیت حذف شد']); + } + + #[Route('/api/v1/appointment-settings/date-override/{uuid}', methods: ['GET'])] + public function getOverride(string $uuid): JsonResponse + { + $override = $this->overrideRepo->findByUuid($uuid); + if ($override === null) { + return $this->error(ErrorCodes::ERR_VALIDATION_002, 'Override یافت نشد', 404); + } + + return $this->success(['data' => $override->toArray()]); + } + + // ── Holidays ────────────────────────────────────────────────────────────── + + #[Route('/api/v1/appointment-settings/holidays', methods: ['POST'])] + public function createHoliday(Request $request, #[CurrentUser] User $user): JsonResponse + { + $data = json_decode($request->getContent(), true) ?? []; + $doctorUuid = trim($data['doctor_uuid'] ?? ''); + $startStr = trim($data['start_date'] ?? ''); + $endStr = trim($data['end_date'] ?? ''); + + $doctor = $this->doctorRepo->findByUuid($doctorUuid); + if ($doctor === null) { + return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دکتر یافت نشد', 404); + } + + if ($doctor->getUser()->getId() !== $user->getId() && !$user->hasRole('ROLE_ADMIN')) { + return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403); + } + + $startTs = strtotime($startStr); + $endTs = strtotime($endStr); + + if (!$startTs || !$endTs || $endTs < $startTs) { + return $this->error(ErrorCodes::ERR_VALIDATION_001, 'تاریخ نادرست است', 422); + } + + $holiday = new Holiday($doctor, $startTs, $endTs); + if (isset($data['reason'])) $holiday->setReason($data['reason']); + + $this->holidayRepo->save($holiday); + + return $this->success(['data' => $holiday->toArray()], 201); + } + + #[Route('/api/v1/appointment-settings/holidays/{uuid}', methods: ['PATCH'])] + public function updateHoliday(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse + { + $holiday = $this->holidayRepo->findByUuid($uuid); + if ($holiday === null) { + return $this->error(ErrorCodes::ERR_VALIDATION_002, 'تعطیلات یافت نشد', 404); + } + + if ($holiday->getDoctor()->getUser()->getId() !== $user->getId() && !$user->hasRole('ROLE_ADMIN')) { + return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403); + } + + $data = json_decode($request->getContent(), true) ?? []; + if (array_key_exists('active', $data)) $holiday->setActive((bool) $data['active']); + if (array_key_exists('reason', $data)) $holiday->setReason($data['reason']); + if (!empty($data['start_date'])) { + $ts = strtotime($data['start_date']); + if ($ts) $holiday->setStartDate($ts); + } + if (!empty($data['end_date'])) { + $ts = strtotime($data['end_date']); + if ($ts) $holiday->setEndDate($ts); + } + + $this->holidayRepo->save($holiday); + + return $this->success(['data' => $holiday->toArray()]); + } +} diff --git a/src/Appointment/Entity/Appointment.php b/src/Appointment/Entity/Appointment.php new file mode 100644 index 00000000..06e85382 --- /dev/null +++ b/src/Appointment/Entity/Appointment.php @@ -0,0 +1,134 @@ + [self::STATUS_CONFIRMED, self::STATUS_CANCELLED_BY_DOCTOR, self::STATUS_CANCELLED_BY_USER, self::STATUS_EXPIRED], + self::STATUS_CONFIRMED => [self::STATUS_COMPLETED, self::STATUS_CANCELLED_BY_DOCTOR, self::STATUS_CANCELLED_BY_USER, self::STATUS_NO_SHOW], + ]; + + // Optimistic locking + #[ORM\Version] + #[ORM\Column(type: 'integer')] + private int $version = 1; + + #[ORM\Id] + #[ORM\GeneratedValue] + #[ORM\Column(type: 'integer')] + private ?int $id = null; + + #[ORM\Column(type: 'string', length: 36, unique: true)] + private string $uuid; + + #[ORM\ManyToOne(targetEntity: Doctor::class)] + #[ORM\JoinColumn(name: 'doctor_id', referencedColumnName: 'id', nullable: false, onDelete: 'RESTRICT')] + private Doctor $doctor; + + #[ORM\ManyToOne(targetEntity: User::class)] + #[ORM\JoinColumn(name: 'user_id', referencedColumnName: 'id', nullable: false, onDelete: 'RESTRICT')] + private User $user; + + #[ORM\Column(name: 'slot_start', type: 'integer')] + private int $slotStart; + + #[ORM\Column(name: 'slot_end', type: 'integer')] + private int $slotEnd; + + #[ORM\Column(type: 'string', length: 30)] + private string $status = self::STATUS_PENDING; + + #[ORM\Column(type: 'string', length: 255, nullable: true)] + private ?string $note = null; + + #[ORM\Column(name: 'created_at', type: 'integer')] + private int $createdAt; + + #[ORM\Column(name: 'updated_at', type: 'integer')] + private int $updatedAt; + + public function __construct(Doctor $doctor, User $user, int $slotStart, int $slotEnd) + { + $this->uuid = Uuid::v4()->toRfc4122(); + $this->doctor = $doctor; + $this->user = $user; + $this->slotStart = $slotStart; + $this->slotEnd = $slotEnd; + $this->createdAt = time(); + $this->updatedAt = time(); + } + + public function getId(): ?int { return $this->id; } + public function getUuid(): string { return $this->uuid; } + public function getDoctor(): Doctor { return $this->doctor; } + public function getUser(): User { return $this->user; } + public function getSlotStart(): int { return $this->slotStart; } + public function getSlotEnd(): int { return $this->slotEnd; } + public function getStatus(): string { return $this->status; } + public function getNote(): ?string { return $this->note; } + public function getVersion(): int { return $this->version; } + + public function setNote(?string $v): self { $this->note = $v; return $this; } + + public function canTransitionTo(string $newStatus): bool + { + return in_array($newStatus, self::ALLOWED_TRANSITIONS[$this->status] ?? [], true); + } + + public function transitionTo(string $newStatus): self + { + if (!$this->canTransitionTo($newStatus)) { + throw new \LogicException(sprintf( + 'Cannot transition appointment from "%s" to "%s"', + $this->status, $newStatus + )); + } + $this->status = $newStatus; + $this->updatedAt = time(); + return $this; + } + + public function toArray(): array + { + return [ + 'uuid' => $this->uuid, + 'doctor' => [ + 'uuid' => $this->doctor->getUuid(), + 'name' => $this->doctor->getName(), + ], + 'user' => [ + 'uuid' => $this->user->getUuid(), + 'mobile' => $this->user->getMobileNumber(), + ], + 'slot_start' => $this->slotStart, + 'slot_end' => $this->slotEnd, + 'status' => $this->status, + 'note' => $this->note, + 'version' => $this->version, + 'created_at' => $this->createdAt, + 'updated_at' => $this->updatedAt, + ]; + } +} diff --git a/src/Appointment/Entity/DateOverride.php b/src/Appointment/Entity/DateOverride.php new file mode 100644 index 00000000..f0742c8b --- /dev/null +++ b/src/Appointment/Entity/DateOverride.php @@ -0,0 +1,81 @@ +uuid = Uuid::v4()->toRfc4122(); + $this->doctor = $doctor; + $this->date = $date; + $this->active = $active; + $this->createdAt = time(); + $this->updatedAt = time(); + } + + public function getId(): ?int { return $this->id; } + public function getUuid(): string { return $this->uuid; } + public function getDoctor(): Doctor { return $this->doctor; } + public function getDate(): int { return $this->date; } + public function isActive(): bool { return $this->active; } + public function getSetting(): ?array { return $this->setting; } + public function getReason(): ?string { return $this->reason; } + + public function setDate(int $v): self { $this->date = $v; $this->touch(); return $this; } + public function setActive(bool $v): self { $this->active = $v; $this->touch(); return $this; } + public function setSetting(?array $v): self { $this->setting = $v; $this->touch(); return $this; } + public function setReason(?string $v): self { $this->reason = $v; $this->touch(); return $this; } + + private function touch(): void { $this->updatedAt = time(); } + + public function toArray(): array + { + return [ + 'uuid' => $this->uuid, + 'doctor_uuid' => $this->doctor->getUuid(), + 'date' => $this->date, + 'active' => $this->active, + 'reason' => $this->reason, + 'custom_slots' => $this->setting ?? [], + 'created_at' => $this->createdAt, + ]; + } +} diff --git a/src/Appointment/Entity/Holiday.php b/src/Appointment/Entity/Holiday.php new file mode 100644 index 00000000..787b86e7 --- /dev/null +++ b/src/Appointment/Entity/Holiday.php @@ -0,0 +1,81 @@ +uuid = Uuid::v4()->toRfc4122(); + $this->doctor = $doctor; + $this->startDate = $startDate; + $this->endDate = $endDate; + $this->createdAt = time(); + $this->updatedAt = time(); + } + + public function getId(): ?int { return $this->id; } + public function getUuid(): string { return $this->uuid; } + public function getDoctor(): Doctor { return $this->doctor; } + public function getStartDate(): int { return $this->startDate; } + public function getEndDate(): int { return $this->endDate; } + public function isActive(): bool { return $this->active; } + public function getReason(): ?string { return $this->reason; } + + public function setStartDate(int $v): self { $this->startDate = $v; $this->touch(); return $this; } + public function setEndDate(int $v): self { $this->endDate = $v; $this->touch(); return $this; } + public function setActive(bool $v): self { $this->active = $v; $this->touch(); return $this; } + public function setReason(?string $v): self { $this->reason = $v; $this->touch(); return $this; } + + private function touch(): void { $this->updatedAt = time(); } + + public function toArray(): array + { + return [ + 'uuid' => $this->uuid, + 'doctor_uuid' => $this->doctor->getUuid(), + 'start_date' => $this->startDate, + 'end_date' => $this->endDate, + 'active' => $this->active, + 'reason' => $this->reason, + 'created_at' => $this->createdAt, + ]; + } +} diff --git a/src/Appointment/Entity/WeeklySchedule.php b/src/Appointment/Entity/WeeklySchedule.php new file mode 100644 index 00000000..11e23647 --- /dev/null +++ b/src/Appointment/Entity/WeeklySchedule.php @@ -0,0 +1,63 @@ +uuid = Uuid::v4()->toRfc4122(); + $this->doctor = $doctor; + $this->setting = $setting; + $this->createdAt = time(); + $this->updatedAt = time(); + } + + public function getId(): ?int { return $this->id; } + public function getUuid(): string { return $this->uuid; } + public function getDoctor(): Doctor { return $this->doctor; } + public function getSetting(): array { return $this->setting; } + + public function setSetting(array $setting): self { $this->setting = $setting; $this->updatedAt = time(); return $this; } + + public function toArray(): array + { + return [ + 'uuid' => $this->uuid, + 'doctor_uuid' => $this->doctor->getUuid(), + 'schedule' => $this->setting, + 'created_at' => $this->createdAt, + 'updated_at' => $this->updatedAt, + ]; + } +} diff --git a/src/Appointment/Repository/AppointmentRepository.php b/src/Appointment/Repository/AppointmentRepository.php new file mode 100644 index 00000000..7b12012d --- /dev/null +++ b/src/Appointment/Repository/AppointmentRepository.php @@ -0,0 +1,94 @@ +findOneBy(['uuid' => $uuid]); + } + + /** Check if a slot is already taken (confirmed or pending) */ + public function isSlotTaken(Doctor $doctor, int $slotStart, int $slotEnd, ?int $excludeId = null): bool + { + $qb = $this->createQueryBuilder('a') + ->select('COUNT(a.id)') + ->where('a.doctor = :doctor') + ->andWhere('a.status IN (:activeStatuses)') + ->andWhere('a.slotStart < :slotEnd') + ->andWhere('a.slotEnd > :slotStart') + ->setParameter('doctor', $doctor) + ->setParameter('activeStatuses', [Appointment::STATUS_PENDING, Appointment::STATUS_CONFIRMED]) + ->setParameter('slotStart', $slotStart) + ->setParameter('slotEnd', $slotEnd); + + if ($excludeId !== null) { + $qb->andWhere('a.id != :excludeId')->setParameter('excludeId', $excludeId); + } + + return (int) $qb->getQuery()->getSingleScalarResult() > 0; + } + + /** @return Appointment[] */ + public function findByDoctor(Doctor $doctor, ?string $status = null): array + { + $criteria = ['doctor' => $doctor]; + if ($status !== null) $criteria['status'] = $status; + return $this->findBy($criteria, ['slotStart' => 'ASC']); + } + + /** @return Appointment[] */ + public function findByUser(User $user, ?string $status = null): array + { + $criteria = ['user' => $user]; + if ($status !== null) $criteria['status'] = $status; + return $this->findBy($criteria, ['slotStart' => 'DESC']); + } + + /** @return Appointment[] pending appointments older than given timestamp */ + public function findExpiredPending(int $before): array + { + return $this->createQueryBuilder('a') + ->where('a.status = :status') + ->andWhere('a.slotStart < :before') + ->setParameter('status', Appointment::STATUS_PENDING) + ->setParameter('before', $before) + ->getQuery() + ->getResult(); + } + + public function save(Appointment $entity, bool $flush = true): void + { + $this->getEntityManager()->persist($entity); + if ($flush) $this->getEntityManager()->flush(); + } + + /** + * @throws OptimisticLockException + */ + public function saveWithLock(Appointment $entity, int $expectedVersion): void + { + $this->getEntityManager()->lock($entity, \Doctrine\DBAL\LockMode::OPTIMISTIC, $expectedVersion); + $this->getEntityManager()->persist($entity); + $this->getEntityManager()->flush(); + } + + public function remove(Appointment $entity, bool $flush = true): void + { + $this->getEntityManager()->remove($entity); + if ($flush) $this->getEntityManager()->flush(); + } +} diff --git a/src/Appointment/Repository/DateOverrideRepository.php b/src/Appointment/Repository/DateOverrideRepository.php new file mode 100644 index 00000000..01611208 --- /dev/null +++ b/src/Appointment/Repository/DateOverrideRepository.php @@ -0,0 +1,39 @@ +findOneBy(['uuid' => $uuid]); + } + + /** @return DateOverride[] */ + public function findByDoctor(Doctor $doctor): array + { + return $this->findBy(['doctor' => $doctor], ['date' => 'ASC']); + } + + public function save(DateOverride $entity, bool $flush = true): void + { + $this->getEntityManager()->persist($entity); + if ($flush) $this->getEntityManager()->flush(); + } + + public function remove(DateOverride $entity, bool $flush = true): void + { + $this->getEntityManager()->remove($entity); + if ($flush) $this->getEntityManager()->flush(); + } +} diff --git a/src/Appointment/Repository/HolidayRepository.php b/src/Appointment/Repository/HolidayRepository.php new file mode 100644 index 00000000..aa10349f --- /dev/null +++ b/src/Appointment/Repository/HolidayRepository.php @@ -0,0 +1,48 @@ +findOneBy(['uuid' => $uuid]); + } + + /** @return Holiday[] */ + public function findActiveByDoctor(Doctor $doctor, int $from, int $to): array + { + return $this->createQueryBuilder('h') + ->where('h.doctor = :doctor') + ->andWhere('h.active = true') + ->andWhere('h.startDate <= :to') + ->andWhere('h.endDate >= :from') + ->setParameter('doctor', $doctor) + ->setParameter('from', $from) + ->setParameter('to', $to) + ->getQuery() + ->getResult(); + } + + public function save(Holiday $entity, bool $flush = true): void + { + $this->getEntityManager()->persist($entity); + if ($flush) $this->getEntityManager()->flush(); + } + + public function remove(Holiday $entity, bool $flush = true): void + { + $this->getEntityManager()->remove($entity); + if ($flush) $this->getEntityManager()->flush(); + } +} diff --git a/src/Appointment/Repository/WeeklyScheduleRepository.php b/src/Appointment/Repository/WeeklyScheduleRepository.php new file mode 100644 index 00000000..87023080 --- /dev/null +++ b/src/Appointment/Repository/WeeklyScheduleRepository.php @@ -0,0 +1,38 @@ +findOneBy(['doctor' => $doctor]); + } + + public function findByUuid(string $uuid): ?WeeklySchedule + { + return $this->findOneBy(['uuid' => $uuid]); + } + + public function save(WeeklySchedule $entity, bool $flush = true): void + { + $this->getEntityManager()->persist($entity); + if ($flush) $this->getEntityManager()->flush(); + } + + public function remove(WeeklySchedule $entity, bool $flush = true): void + { + $this->getEntityManager()->remove($entity); + if ($flush) $this->getEntityManager()->flush(); + } +} diff --git a/src/Appointment/Service/SlotCalculatorService.php b/src/Appointment/Service/SlotCalculatorService.php new file mode 100644 index 00000000..619691b7 --- /dev/null +++ b/src/Appointment/Service/SlotCalculatorService.php @@ -0,0 +1,104 @@ + 'sunday', 1 => 'monday', 2 => 'tuesday', 3 => 'wednesday', + 4 => 'thursday', 5 => 'friday', 6 => 'saturday', + ]; + + public function __construct( + private readonly WeeklyScheduleRepository $scheduleRepo, + private readonly DateOverrideRepository $overrideRepo, + private readonly HolidayRepository $holidayRepo, + private readonly AppointmentRepository $appointmentRepo, + ) {} + + /** + * Returns available slots for a doctor on a given date. + * @param string $date 'Y-m-d' format + * @return array[] [{start: int, end: int, start_time: string, end_time: string}] + */ + public function getAvailableSlots(Doctor $doctor, string $date): array + { + $dayStart = (int) strtotime($date . ' 00:00:00'); + $dayEnd = $dayStart + 86400; + + // Check if in holiday + $holidays = $this->holidayRepo->findActiveByDoctor($doctor, $dayStart, $dayEnd - 1); + if (!empty($holidays)) return []; + + // Check date override first + $overrides = $this->overrideRepo->findByDoctor($doctor); + foreach ($overrides as $override) { + $overDate = date('Y-m-d', $override->getDate()); + if ($overDate === $date) { + if (!$override->isActive()) return []; + return $this->buildSlots($override->getSetting() ?? [], $dayStart); + } + } + + // Fall back to weekly schedule + $schedule = $this->scheduleRepo->findByDoctor($doctor); + if ($schedule === null) return []; + + $dow = (int) date('w', $dayStart); + $dayName = self::DAY_MAP[$dow]; + $setting = $schedule->getSetting(); + + $dayConfig = $setting[$dayName] ?? null; + if ($dayConfig === null || !($dayConfig['active'] ?? false)) return []; + + $rawSlots = $this->buildSlots($dayConfig['slots'] ?? [], $dayStart); + + // Filter out already-booked slots + return $this->filterBookedSlots($doctor, $rawSlots); + } + + /** @return array[] */ + private function buildSlots(array $slotConfigs, int $dayStart): array + { + $slots = []; + foreach ($slotConfigs as $config) { + $startSec = $this->parseTime($config['start'] ?? '00:00'); + $endSec = $this->parseTime($config['end'] ?? '00:00'); + $duration = (int) ($config['duration'] ?? 30) * 60; + + if ($duration <= 0 || $endSec <= $startSec) continue; + + for ($t = $startSec; $t + $duration <= $endSec; $t += $duration) { + $slotStart = $dayStart + $t; + $slotEnd = $slotStart + $duration; + $slots[] = [ + 'start' => $slotStart, + 'end' => $slotEnd, + 'start_time' => gmdate('H:i', $t), + 'end_time' => gmdate('H:i', $t + $duration), + ]; + } + } + return $slots; + } + + private function filterBookedSlots(Doctor $doctor, array $slots): array + { + return array_values(array_filter($slots, function (array $slot) use ($doctor): bool { + return !$this->appointmentRepo->isSlotTaken($doctor, $slot['start'], $slot['end']); + })); + } + + private function parseTime(string $time): int + { + [$h, $m] = explode(':', $time, 2) + [0, 0]; + return ((int)$h * 3600) + ((int)$m * 60); + } +} diff --git a/src/Auth/Controller/AuthController.php b/src/Auth/Controller/AuthController.php new file mode 100644 index 00000000..d44354c9 --- /dev/null +++ b/src/Auth/Controller/AuthController.php @@ -0,0 +1,176 @@ +error(ErrorCodes::ERR_AUTH_005, ErrorCodes::message(ErrorCodes::ERR_AUTH_005), 401); + } + + #[Route('/api/v1/user/send-code', methods: ['POST'])] + public function sendCode(Request $request): JsonResponse + { + $limiter = $this->sendCodeLimiter->create($request->getClientIp() ?? 'unknown'); + if (!$limiter->consume(1)->isAccepted()) { + return $this->error(ErrorCodes::ERR_RATE_LIMIT_001, ErrorCodes::message(ErrorCodes::ERR_RATE_LIMIT_001), 429); + } + + $data = json_decode($request->getContent(), true) ?? []; + $mobile = trim($data['mobile'] ?? ''); + + if (!preg_match('/^09[0-9]{9}$/', $mobile)) { + return $this->error(ErrorCodes::ERR_VALIDATION_001, 'فرمت شماره موبایل نادرست است', 422, 'mobile'); + } + + $uuid = $this->otpService->sendCode($mobile); + + return new JsonResponse(['uuid' => $uuid, 'message' => 'کد تایید با موفقیت ارسال شد.']); + } + + #[Route('/api/v1/user/verify-code', methods: ['POST'])] + public function verifyCode(Request $request): JsonResponse + { + $data = json_decode($request->getContent(), true) ?? []; + $uuid = trim($data['uuid'] ?? ''); + $code = trim($data['code'] ?? ''); + + if (empty($uuid) || empty($code)) { + return $this->error(ErrorCodes::ERR_VALIDATION_002, 'uuid و code الزامی است', 422); + } + + $this->otpService->verifyCode($uuid, $code); + + return $this->success(['message' => 'کد با موفقیت تایید شد.']); + } + + #[Route('/api/v1/user/register', methods: ['POST'])] + public function register(Request $request): JsonResponse + { + $data = json_decode($request->getContent(), true) ?? []; + $uuid = trim($data['uuid'] ?? ''); + $realName = trim($data['real_name'] ?? ''); + + if (empty($uuid)) { + return $this->error(ErrorCodes::ERR_VALIDATION_002, 'uuid الزامی است', 422); + } + + $otpData = $this->otpService->getVerifiedOtpData($uuid); + $mobile = $otpData['mobile']; + + $user = $this->userRepo->findByMobile($mobile) ?? new User($mobile); + if ($realName !== '') { + $user->setRealName($realName); + } + + $this->userRepo->save($user); + $this->otpService->deleteOtp($uuid); + + return $this->success(['message' => 'ثبت‌نام با موفقیت انجام شد.', 'uuid' => $user->getUuid()], 201); + } + + #[Route('/oauth/token', methods: ['POST'])] + public function issueToken(Request $request): JsonResponse + { + $data = json_decode($request->getContent(), true) ?? []; + $grant = $data['grant_type'] ?? ''; + $uuid = trim($data['uuid'] ?? ''); + + if ($grant !== 'mobile') { + return $this->error(ErrorCodes::ERR_VALIDATION_001, 'grant_type نامعتبر است', 400); + } + + $otpData = $this->otpService->getVerifiedOtpData($uuid); + $mobile = $otpData['mobile']; + + $user = $this->userRepo->findByMobile($mobile) ?? new User($mobile); + $this->userRepo->save($user); + $this->otpService->deleteOtp($uuid); + + return new JsonResponse($this->tokenService->issueTokens($user)); + } + + #[Route('/oauth/token/refresh', methods: ['POST'])] + public function refreshToken(Request $request): JsonResponse + { + $data = json_decode($request->getContent(), true) ?? []; + $refreshToken = trim($data['refresh_token'] ?? ''); + + if (empty($refreshToken)) { + return $this->error(ErrorCodes::ERR_AUTH_001, 'refresh_token الزامی است', 401); + } + + $result = $this->tokenService->refreshToken($refreshToken); + $user = $this->userRepo->find($result['userId']); + + if ($user === null) { + return $this->error(ErrorCodes::ERR_AUTH_001, ErrorCodes::message(ErrorCodes::ERR_AUTH_001), 401); + } + + $tokens = $this->tokenService->issueTokens($user); + $tokens['refresh_token'] = $result['rawToken']; + + return new JsonResponse($tokens); + } + + #[Route('/oauth/userinfo', methods: ['GET'])] + public function userInfo(#[CurrentUser] ?User $user): JsonResponse + { + if ($user === null) { + return $this->error(ErrorCodes::ERR_AUTH_001, ErrorCodes::message(ErrorCodes::ERR_AUTH_001), 401); + } + + return $this->success([ + 'id' => $user->getId(), + 'uuid' => $user->getUuid(), + 'mobile_number' => $user->getMobileNumber(), + 'realName' => $user->getRealName(), + 'status' => $user->getStatus(), + 'roles' => $user->getRoles(), + ]); + } + + #[Route('/oauth/logout', methods: ['POST'])] + public function logout(Request $request): JsonResponse + { + $data = json_decode($request->getContent(), true) ?? []; + $refreshToken = trim($data['refresh_token'] ?? ''); + + if ($refreshToken !== '') { + $this->tokenService->revokeRefreshToken($refreshToken); + } + + return $this->success(['message' => 'خروج با موفقیت انجام شد']); + } + + #[Route('/session/token', methods: ['GET'])] + public function sessionToken(): JsonResponse + { + return new JsonResponse(['token' => bin2hex(random_bytes(16))]); + } +} diff --git a/src/Auth/Entity/User.php b/src/Auth/Entity/User.php new file mode 100644 index 00000000..6b6038a3 --- /dev/null +++ b/src/Auth/Entity/User.php @@ -0,0 +1,105 @@ +uuid = Uuid::v4()->toRfc4122(); + $this->mobileNumber = $mobileNumber; + $this->createdAt = time(); + $this->updatedAt = time(); + } + + public function getId(): ?int { return $this->id; } + public function getUuid(): string { return $this->uuid; } + public function getMobileNumber(): string { return $this->mobileNumber; } + public function getEmail(): ?string { return $this->email; } + public function getRealName(): ?string { return $this->realName; } + public function getStatus(): int { return $this->status; } + public function getCreatedAt(): int { return $this->createdAt; } + + public function getPassword(): ?string { return $this->passwordHash; } + public function getPasswordHash(): ?string { return $this->passwordHash; } + + public function getRoles(): array + { + $roles = $this->roles; + if (!in_array('ROLE_USER', $roles, true)) { + $roles[] = 'ROLE_USER'; + } + return array_unique($roles); + } + + public function getUserIdentifier(): string { return $this->mobileNumber; } + public function eraseCredentials(): void {} + + public function setEmail(?string $email): self { $this->email = $email; return $this; } + public function setRealName(?string $name): self { $this->realName = $name; $this->updatedAt = time(); return $this; } + public function setPasswordHash(?string $hash): self { $this->passwordHash = $hash; $this->updatedAt = time(); return $this; } + public function setRoles(array $roles): self { $this->roles = $roles; $this->updatedAt = time(); return $this; } + public function setStatus(int $status): self { $this->status = $status; $this->updatedAt = time(); return $this; } + + public function addRole(string $role): self + { + if (!in_array($role, $this->roles, true)) { + $this->roles[] = $role; + $this->updatedAt = time(); + } + return $this; + } + + public function hasRole(string $role): bool + { + return in_array($role, $this->getRoles(), true); + } + + public function isStaff(): bool + { + return $this->hasRole('ROLE_DOCTOR') + || $this->hasRole('ROLE_CLINIC') + || $this->hasRole('ROLE_SECRETARY') + || $this->hasRole('ROLE_ADMIN'); + } +} diff --git a/src/Auth/Repository/UserRepository.php b/src/Auth/Repository/UserRepository.php new file mode 100644 index 00000000..ef059888 --- /dev/null +++ b/src/Auth/Repository/UserRepository.php @@ -0,0 +1,41 @@ +findOneBy(['mobileNumber' => $mobile]); + } + + public function findByUuid(string $uuid): ?User + { + return $this->findOneBy(['uuid' => $uuid]); + } + + public function save(User $user, bool $flush = true): void + { + $this->getEntityManager()->persist($user); + if ($flush) { + $this->getEntityManager()->flush(); + } + } + + public function remove(User $user, bool $flush = true): void + { + $this->getEntityManager()->remove($user); + if ($flush) { + $this->getEntityManager()->flush(); + } + } +} diff --git a/src/Auth/Security/PasswordAuthenticator.php b/src/Auth/Security/PasswordAuthenticator.php new file mode 100644 index 00000000..baec4c33 --- /dev/null +++ b/src/Auth/Security/PasswordAuthenticator.php @@ -0,0 +1,105 @@ +getPathInfo() === '/api/v1/user/login' + && $request->isMethod('POST'); + } + + public function authenticate(Request $request): Passport + { + $limiter = $this->loginLimiter->create($request->getClientIp() ?? 'unknown'); + if (!$limiter->consume(1)->isAccepted()) { + throw new TooManyRequestsHttpException(60, 'تعداد تلاش‌های ورود از حد مجاز گذشت'); + } + + $data = json_decode($request->getContent(), true) ?? []; + $mobile = trim($data['mobile_number'] ?? ''); + $pass = $data['password'] ?? ''; + + return new Passport( + new UserBadge($mobile, fn(string $id) => $this->userRepository->findByMobile($id)), + new PasswordCredentials($pass) + ); + } + + public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response + { + $user = $token->getUser(); + + if (!$user->isStaff()) { + return new JsonResponse([ + 'success' => false, + 'data' => null, + 'errors' => [['code' => ErrorCodes::ERR_AUTH_006, 'message' => ErrorCodes::message(ErrorCodes::ERR_AUTH_006)]], + ], 403); + } + + $accessToken = $this->jwtManager->create($user); + $rawToken = bin2hex(random_bytes(32)); + $cacheKey = 'refresh_' . hash('sha256', $rawToken); + + $item = $this->cache->getItem($cacheKey); + $item->set((string) $user->getId()); + $item->expiresAfter($this->refreshTokenTtl); + $this->cache->save($item); + + $this->logger->info('login_success', [ + 'user_id' => $user->getId(), + 'ip' => $request->getClientIp(), + 'method' => 'password', + ]); + + return new JsonResponse([ + 'access_token' => $accessToken, + 'refresh_token' => $rawToken, + 'token_type' => 'Bearer', + 'expires_in' => 3600, + 'refresh_token_expires_in' => $this->refreshTokenTtl, + ]); + } + + public function onAuthenticationFailure(Request $request, AuthenticationException $exception): Response + { + $this->logger->warning('login_failed', [ + 'ip' => $request->getClientIp(), + 'reason' => $exception->getMessage(), + ]); + + return new JsonResponse([ + 'success' => false, + 'data' => null, + 'errors' => [['code' => ErrorCodes::ERR_AUTH_005, 'message' => ErrorCodes::message(ErrorCodes::ERR_AUTH_005)]], + ], 401); + } +} diff --git a/src/Auth/Service/OtpService.php b/src/Auth/Service/OtpService.php new file mode 100644 index 00000000..f860ac3a --- /dev/null +++ b/src/Auth/Service/OtpService.php @@ -0,0 +1,98 @@ +toRfc4122(); + $code = $this->appEnv === 'dev' + ? '12345' + : str_pad((string) random_int(10000, 99999), 5, '0', STR_PAD_LEFT); + + $item = $this->cache->getItem($this->key($uuid)); + $item->set(json_encode(['mobile' => $mobile, 'code' => $code, 'attempts' => 0, 'verified' => false])); + $item->expiresAfter($this->otpTtl); + $this->cache->save($item); + + if ($this->appEnv !== 'dev') { + $this->bus->dispatch(new SendSmsMessage($mobile, "کد تأیید شما: {$code}")); + } + + return $uuid; + } + + public function verifyCode(string $uuid, string $submittedCode): array + { + $item = $this->cache->getItem($this->key($uuid)); + + if (!$item->isHit()) { + throw new AppException(ErrorCodes::ERR_AUTH_003, null, 400); + } + + $data = json_decode($item->get(), true); + + if ($data['attempts'] >= 5) { + $this->cache->delete($this->key($uuid)); + throw new AppException(ErrorCodes::ERR_AUTH_004, null, 429); + } + + if (!hash_equals($data['code'], $submittedCode)) { + $data['attempts']++; + $item->set(json_encode($data)); + $item->expiresAfter($this->otpTtl); + $this->cache->save($item); + throw new AppException(ErrorCodes::ERR_AUTH_002, null, 400); + } + + $data['verified'] = true; + $item->set(json_encode($data)); + $item->expiresAfter($this->otpTtl); + $this->cache->save($item); + + return $data; + } + + public function getVerifiedOtpData(string $uuid): array + { + $item = $this->cache->getItem($this->key($uuid)); + + if (!$item->isHit()) { + throw new AppException(ErrorCodes::ERR_AUTH_003, null, 400); + } + + $data = json_decode($item->get(), true); + + if (!($data['verified'] ?? false)) { + throw new AppException(ErrorCodes::ERR_AUTH_002, null, 400); + } + + return $data; + } + + public function deleteOtp(string $uuid): void + { + $this->cache->delete($this->key($uuid)); + } +} diff --git a/src/Auth/Service/TokenService.php b/src/Auth/Service/TokenService.php new file mode 100644 index 00000000..bd3f61f3 --- /dev/null +++ b/src/Auth/Service/TokenService.php @@ -0,0 +1,71 @@ +jwtManager->create($user); + $rawToken = $this->storeRefreshToken($user->getId()); + + return [ + 'access_token' => $accessToken, + 'refresh_token' => $rawToken, + 'token_type' => 'Bearer', + 'expires_in' => 3600, + 'refresh_token_expires_in' => $this->refreshTokenTtl, + ]; + } + + public function refreshToken(string $submittedToken): array + { + $key = $this->refreshKey($submittedToken); + $item = $this->cache->getItem($key); + + if (!$item->isHit()) { + throw new AppException(ErrorCodes::ERR_AUTH_001, 'Refresh Token نامعتبر یا منقضی شده است', 401); + } + + $userId = (int) $item->get(); + $this->cache->delete($key); + + return ['userId' => $userId, 'rawToken' => $this->storeRefreshToken($userId)]; + } + + public function revokeRefreshToken(string $rawToken): void + { + $this->cache->delete($this->refreshKey($rawToken)); + } + + private function storeRefreshToken(int $userId): string + { + $rawToken = bin2hex(random_bytes(32)); + $key = $this->refreshKey($rawToken); + + $item = $this->cache->getItem($key); + $item->set((string) $userId); + $item->expiresAfter($this->refreshTokenTtl); + $this->cache->save($item); + + return $rawToken; + } + + private function refreshKey(string $rawToken): string + { + // hash → hex string → safe cache key (no reserved chars) + return 'refresh_' . hash('sha256', $rawToken); + } +} diff --git a/src/Blog/Controller/BlogController.php b/src/Blog/Controller/BlogController.php new file mode 100644 index 00000000..fec61377 --- /dev/null +++ b/src/Blog/Controller/BlogController.php @@ -0,0 +1,151 @@ +query->get('page', 1)); + $limit = min(50, max(1, (int) $request->query->get('limit', 20))); + + $blogs = array_map(fn(Blog $b) => $b->toListArray(), $this->blogRepo->findPublished($page, $limit)); + $total = $this->blogRepo->countPublished(); + + return $this->paginated($blogs, $total, $page, $limit); + } + + #[Route('/api/v1/blog/{slug}', methods: ['GET'])] + public function detail(string $slug): JsonResponse + { + $blog = $this->blogRepo->findBySlug($slug) ?? $this->blogRepo->findByUuid($slug); + if ($blog === null || $blog->getStatus() !== Blog::STATUS_PUBLISHED) { + return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'مقاله یافت نشد', 404); + } + + return $this->success(['data' => $blog->toArray()]); + } + + // ── Admin CRUD ──────────────────────────────────────────────────────────── + + #[IsGranted('ROLE_ADMIN')] + #[Route('/api/v1/blog', methods: ['POST'])] + public function create(Request $request, #[CurrentUser] User $user): JsonResponse + { + $data = json_decode($request->getContent(), true) ?? []; + $title = trim($data['title'] ?? ''); + $body = trim($data['body'] ?? ''); + + if (empty($title) || empty($body)) { + return $this->error(ErrorCodes::ERR_VALIDATION_002, 'title و body الزامی است', 422); + } + + $blog = new Blog($user, $title, $body); + if (!empty($data['summary'])) $blog->setSummary($data['summary']); + if (!empty($data['tags'])) $blog->setTags((array)$data['tags']); + if (!empty($data['status'])) $blog->setStatus($data['status']); + + // Ensure slug uniqueness + if ($this->blogRepo->findBySlug($blog->getSlug()) !== null) { + $blog->setSlug($blog->getSlug() . '-' . substr(uniqid(), -4)); + } + + $this->blogRepo->save($blog); + + return $this->success(['data' => $blog->toArray()], 201); + } + + #[IsGranted('ROLE_ADMIN')] + #[Route('/api/v1/blog/{uuid}', methods: ['PATCH'])] + public function update(string $uuid, Request $request): JsonResponse + { + $blog = $this->blogRepo->findByUuid($uuid); + if ($blog === null) { + return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'مقاله یافت نشد', 404); + } + + $data = json_decode($request->getContent(), true) ?? []; + if (array_key_exists('title', $data)) $blog->setTitle($data['title']); + if (array_key_exists('body', $data)) $blog->setBody($data['body']); + if (array_key_exists('summary', $data)) $blog->setSummary($data['summary']); + if (array_key_exists('tags', $data)) $blog->setTags((array)$data['tags']); + if (array_key_exists('status', $data)) $blog->setStatus($data['status']); + + $this->blogRepo->save($blog); + + return $this->success(['data' => $blog->toArray()]); + } + + #[IsGranted('ROLE_ADMIN')] + #[Route('/api/v1/blog/{uuid}', methods: ['DELETE'])] + public function delete(string $uuid): JsonResponse + { + $blog = $this->blogRepo->findByUuid($uuid); + if ($blog === null) { + return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'مقاله یافت نشد', 404); + } + + $this->blogRepo->remove($blog); + return $this->success(['message' => 'مقاله با موفقیت حذف شد']); + } + + // ── Image upload ────────────────────────────────────────────────────────── + + #[IsGranted('ROLE_ADMIN')] + #[Route('/file/upload/clinic_pro/blog/field_image', methods: ['POST'])] + public function uploadImage(Request $request): JsonResponse + { + $file = $request->files->get('file'); + $blogUuid = $request->request->get('blog_uuid', ''); + + if ($file === null) { + return $this->error(ErrorCodes::ERR_FILE_001, 'فایل ارسال نشده است', 422); + } + + try { + $safeFilename = $this->fileValidator->validateUploadedFile($file); + } catch (\App\Shared\Exception\AppException $e) { + return $this->error($e->getErrorCode(), $e->getMessage(), $e->getHttpStatus()); + } + + $uploadDir = $this->projectDir . '/public/uploads/blogs/'; + if (!is_dir($uploadDir)) mkdir($uploadDir, 0755, true); + + $filename = uniqid('blog_') . '_' . $safeFilename; + $file->move($uploadDir, $filename); + + $imageUrl = '/uploads/blogs/' . $filename; + $imagePath = $uploadDir . $filename; + + if (!empty($blogUuid)) { + $blog = $this->blogRepo->findByUuid($blogUuid); + if ($blog !== null) { + $blog->setImageUrl($imageUrl)->setImagePath($imagePath); + $this->blogRepo->save($blog); + } + } + + return $this->success(['image_url' => $imageUrl, 'filename' => $filename]); + } +} diff --git a/src/Blog/Entity/Blog.php b/src/Blog/Entity/Blog.php new file mode 100644 index 00000000..0f59671e --- /dev/null +++ b/src/Blog/Entity/Blog.php @@ -0,0 +1,135 @@ +uuid = Uuid::v4()->toRfc4122(); + $this->author = $author; + $this->title = $title; + $this->slug = $this->generateSlug($title); + $this->body = $body; + $this->createdAt = time(); + $this->updatedAt = time(); + } + + public function getId(): ?int { return $this->id; } + public function getUuid(): string { return $this->uuid; } + public function getTitle(): string { return $this->title; } + public function getSlug(): string { return $this->slug; } + public function getBody(): string { return $this->body; } + public function getSummary(): ?string { return $this->summary; } + public function getImageUrl(): ?string { return $this->imageUrl; } + public function getImagePath(): ?string { return $this->imagePath; } + public function getAuthor(): User { return $this->author; } + public function getTags(): array { return $this->tags; } + public function getStatus(): string { return $this->status; } + + public function setTitle(string $v): self { $this->title = $v; $this->touch(); return $this; } + public function setSlug(string $v): self { $this->slug = $v; $this->touch(); return $this; } + public function setBody(string $v): self { $this->body = $v; $this->touch(); return $this; } + public function setSummary(?string $v): self { $this->summary = $v; $this->touch(); return $this; } + public function setImageUrl(?string $v): self { $this->imageUrl = $v; $this->touch(); return $this; } + public function setImagePath(?string $v): self { $this->imagePath = $v; $this->touch(); return $this; } + public function setTags(array $v): self { $this->tags = $v; $this->touch(); return $this; } + public function setStatus(string $v): self { $this->status = $v; $this->touch(); return $this; } + + private function touch(): void { $this->updatedAt = time(); } + + private function generateSlug(string $title): string + { + $slug = mb_strtolower(trim($title)); + $slug = preg_replace('/\s+/', '-', $slug); + $slug = preg_replace('/[^a-z0-9\-\p{Arabic}]/u', '', $slug); + return $slug . '-' . substr(str_replace('-', '', $this->uuid), 0, 8); + } + + public function toArray(): array + { + return [ + 'uuid' => $this->uuid, + 'title' => $this->title, + 'slug' => $this->slug, + 'summary' => $this->summary, + 'body' => $this->body, + 'image_url' => $this->imageUrl, + 'tags' => $this->tags, + 'status' => $this->status, + 'author' => [ + 'uuid' => $this->author->getUuid(), + 'name' => $this->author->getRealName(), + ], + 'created_at' => $this->createdAt, + 'updated_at' => $this->updatedAt, + ]; + } + + public function toListArray(): array + { + return [ + 'uuid' => $this->uuid, + 'title' => $this->title, + 'slug' => $this->slug, + 'summary' => $this->summary, + 'image_url' => $this->imageUrl, + 'tags' => $this->tags, + 'status' => $this->status, + 'created_at' => $this->createdAt, + ]; + } +} diff --git a/src/Blog/Repository/BlogRepository.php b/src/Blog/Repository/BlogRepository.php new file mode 100644 index 00000000..dca86c17 --- /dev/null +++ b/src/Blog/Repository/BlogRepository.php @@ -0,0 +1,39 @@ +findOneBy(['uuid' => $uuid]); } + public function findBySlug(string $slug): ?Blog { return $this->findOneBy(['slug' => $slug]); } + + /** @return Blog[] published, newest first */ + public function findPublished(int $page = 1, int $limit = 20): array + { + return $this->createQueryBuilder('b') + ->where('b.status = :status') + ->setParameter('status', Blog::STATUS_PUBLISHED) + ->orderBy('b.createdAt', 'DESC') + ->setFirstResult(($page - 1) * $limit) + ->setMaxResults($limit) + ->getQuery()->getResult(); + } + + public function countPublished(): int + { + return (int) $this->createQueryBuilder('b') + ->select('COUNT(b.id)') + ->where('b.status = :status') + ->setParameter('status', Blog::STATUS_PUBLISHED) + ->getQuery()->getSingleScalarResult(); + } + + public function save(Blog $e, bool $flush = true): void { $this->getEntityManager()->persist($e); if ($flush) $this->getEntityManager()->flush(); } + public function remove(Blog $e, bool $flush = true): void { $this->getEntityManager()->remove($e); if ($flush) $this->getEntityManager()->flush(); } +} diff --git a/src/Category/Controller/CategoryController.php b/src/Category/Controller/CategoryController.php new file mode 100644 index 00000000..f53832e5 --- /dev/null +++ b/src/Category/Controller/CategoryController.php @@ -0,0 +1,123 @@ +success(['data' => $this->service->listByBundle('tag')]); + } + + #[Route('/api/v1/categorys/supplementary_insurance', methods: ['GET'])] + public function listSupplementaryInsurance(): JsonResponse + { + return $this->success(['data' => $this->service->listByBundle('supplementary_insurance')]); + } + + #[Route('/api/v1/categorys/insurance_type', methods: ['GET'])] + public function listInsuranceType(): JsonResponse + { + return $this->success(['data' => $this->service->listByBundle('insurance_type')]); + } + + #[Route('/api/v1/categorys/state', methods: ['GET'])] + public function listStates(): JsonResponse + { + return $this->success(['data' => $this->service->listByBundle('state')]); + } + + #[Route('/api/v1/categorys/city', methods: ['GET'])] + public function listCities(Request $request): JsonResponse + { + $stateId = $request->query->get('state_id'); + $parentId = $stateId !== null ? (int) $stateId : null; + + return $this->success(['data' => $this->service->listByBundle('city', $parentId)]); + } + + #[Route('/api/v1/categorys/specially_doctor', methods: ['GET'])] + public function listSpecialties(): JsonResponse + { + return $this->success(['data' => $this->service->listByBundle('specially_doctor')]); + } + + #[Route('/api/v1/categorys/doctor_services', methods: ['GET'])] + public function listDoctorServices(): JsonResponse + { + return $this->success(['data' => $this->service->listByBundle('doctor_services')]); + } + + #[Route('/api/v1/category', methods: ['POST'])] + #[IsGranted('ROLE_ADMIN')] + public function create(Request $request): JsonResponse + { + $data = json_decode($request->getContent(), true) ?? []; + $bundle = trim($data['bundle'] ?? ''); + $label = trim($data['label'] ?? ''); + + if (!in_array($bundle, Category::BUNDLES, true)) { + return $this->error(ErrorCodes::ERR_VALIDATION_001, 'bundle نامعتبر است', 422, 'bundle'); + } + + if ($label === '') { + return $this->error(ErrorCodes::ERR_VALIDATION_001, 'label الزامی است', 422, 'label'); + } + + $category = $this->service->create($bundle, $label, $data); + + return $this->success(['data' => $category->toArray()], 201); + } + + #[Route('/api/v1/category/{id}', methods: ['PATCH'])] + #[IsGranted('ROLE_ADMIN')] + public function update(int $id, Request $request): JsonResponse + { + $category = $this->repository->find($id); + + if ($category === null) { + return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دسته‌بندی یافت نشد', 404); + } + + $data = json_decode($request->getContent(), true) ?? []; + + if (isset($data['bundle']) && !in_array($data['bundle'], Category::BUNDLES, true)) { + return $this->error(ErrorCodes::ERR_VALIDATION_001, 'bundle نامعتبر است', 422, 'bundle'); + } + + $category = $this->service->update($category, $data); + + return $this->success(['data' => $category->toArray()]); + } + + #[Route('/api/v1/category/{id}', methods: ['DELETE'])] + #[IsGranted('ROLE_ADMIN')] + public function delete(int $id): JsonResponse + { + $category = $this->repository->find($id); + + if ($category === null) { + return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دسته‌بندی یافت نشد', 404); + } + + $this->service->delete($category); + + return $this->success(['message' => 'دسته‌بندی با موفقیت حذف شد']); + } +} diff --git a/src/Category/Entity/Category.php b/src/Category/Entity/Category.php new file mode 100644 index 00000000..1ca124f8 --- /dev/null +++ b/src/Category/Entity/Category.php @@ -0,0 +1,155 @@ +uuid = Uuid::v4()->toRfc4122(); + $this->bundle = $bundle; + $this->label = $label; + } + + public function getId(): ?int { return $this->id; } + public function getUuid(): string { return $this->uuid; } + public function getBundle(): string { return $this->bundle; } + public function getLabel(): ?string { return $this->label; } + public function getStatus(): int { return $this->status; } + public function getParentId(): ?int { return $this->parentId; } + public function getWeight(): int { return $this->weight; } + public function getLogoId(): ?int { return $this->logoId; } + public function getTitle(): ?string { return $this->title; } + public function getRepresentationId(): ?int { return $this->representationId; } + public function getContactPhone(): ?string { return $this->contactPhone; } + public function getEmail(): ?string { return $this->email; } + public function getDescription(): ?string { return $this->description; } + public function getSlogan(): ?string { return $this->slogan; } + public function getDomain(): ?string { return $this->domain; } + public function getKeywords(): ?string { return $this->keywords; } + public function getFooterDescription(): ?string { return $this->footerDescription; } + public function getFooterDisclaimer(): ?string { return $this->footerDisclaimer; } + public function getSocialMedia(): ?array { return $this->socialMedia; } + + public function setBundle(string $bundle): self { $this->bundle = $bundle; return $this; } + public function setLabel(?string $label): self { $this->label = $label; return $this; } + public function setStatus(int $status): self { $this->status = $status; return $this; } + public function setParentId(?int $id): self { $this->parentId = $id; return $this; } + public function setWeight(int $weight): self { $this->weight = $weight; return $this; } + public function setLogoId(?int $id): self { $this->logoId = $id; return $this; } + public function setTitle(?string $title): self { $this->title = $title; return $this; } + public function setRepresentationId(?int $id): self { $this->representationId = $id; return $this; } + public function setContactPhone(?string $v): self { $this->contactPhone = $v; return $this; } + public function setEmail(?string $v): self { $this->email = $v; return $this; } + public function setDescription(?string $v): self { $this->description = $v; return $this; } + public function setSlogan(?string $v): self { $this->slogan = $v; return $this; } + public function setDomain(?string $v): self { $this->domain = $v; return $this; } + public function setKeywords(?string $v): self { $this->keywords = $v; return $this; } + public function setFooterDescription(?string $v): self { $this->footerDescription = $v; return $this; } + public function setFooterDisclaimer(?string $v): self { $this->footerDisclaimer = $v; return $this; } + public function setSocialMedia(?array $v): self { $this->socialMedia = $v; return $this; } + + public function toArray(): array + { + $data = [ + 'id' => $this->id, + 'uuid' => $this->uuid, + 'bundle' => $this->bundle, + 'label' => $this->label, + 'status' => $this->status, + 'weight' => $this->weight, + ]; + + if ($this->parentId !== null) { + $data['parent_id'] = $this->parentId; + } + if ($this->title !== null) { + $data['title'] = $this->title; + } + if ($this->bundle === 'city') { + $data['contact_phone'] = $this->contactPhone; + $data['email'] = $this->email; + $data['description'] = $this->description; + $data['slogan'] = $this->slogan; + $data['domain'] = $this->domain; + $data['keywords'] = $this->keywords; + $data['footer_description'] = $this->footerDescription; + $data['social_media'] = $this->socialMedia; + } + + return $data; + } +} diff --git a/src/Category/Repository/CategoryRepository.php b/src/Category/Repository/CategoryRepository.php new file mode 100644 index 00000000..270b7b28 --- /dev/null +++ b/src/Category/Repository/CategoryRepository.php @@ -0,0 +1,48 @@ +createQueryBuilder('c') + ->where('c.bundle = :bundle') + ->andWhere('c.status = 1') + ->setParameter('bundle', $bundle) + ->orderBy('c.weight', 'ASC') + ->addOrderBy('c.label', 'ASC'); + + if ($parentId !== null) { + $qb->andWhere('c.parentId = :parentId')->setParameter('parentId', $parentId); + } + + return $qb->getQuery()->getResult(); + } + + public function save(Category $category, bool $flush = true): void + { + $this->getEntityManager()->persist($category); + if ($flush) { + $this->getEntityManager()->flush(); + } + } + + public function remove(Category $category, bool $flush = true): void + { + $this->getEntityManager()->remove($category); + if ($flush) { + $this->getEntityManager()->flush(); + } + } +} diff --git a/src/Category/Service/CategoryService.php b/src/Category/Service/CategoryService.php new file mode 100644 index 00000000..df081707 --- /dev/null +++ b/src/Category/Service/CategoryService.php @@ -0,0 +1,97 @@ +cache->getItem($cacheKey); + + if ($item->isHit()) { + return $item->get(); + } + + $rows = array_map(fn(Category $c) => $c->toArray(), $this->repository->findByBundle($bundle, $parentId)); + + $item->set($rows)->expiresAfter(self::TTL); + $this->cache->save($item); + + return $rows; + } + + public function create(string $bundle, string $label, array $extra = []): Category + { + $category = new Category($bundle, $label); + $this->applyExtra($category, $extra); + $this->repository->save($category); + $this->invalidate($bundle); + + return $category; + } + + public function update(Category $category, array $data): Category + { + $bundle = $data['bundle'] ?? $category->getBundle(); + + if (isset($data['label'])) $category->setLabel($data['label']); + if (isset($data['status'])) $category->setStatus((int) $data['status']); + if (isset($data['weight'])) $category->setWeight((int) $data['weight']); + if (isset($data['title'])) $category->setTitle($data['title']); + if (isset($data['bundle'])) $category->setBundle($data['bundle']); + if (array_key_exists('parent_id', $data)) $category->setParentId($data['parent_id']); + + $this->applyExtra($category, $data); + $this->repository->save($category); + $this->invalidate($bundle); + $this->invalidate($category->getBundle()); + + return $category; + } + + public function delete(Category $category): void + { + $bundle = $category->getBundle(); + $this->repository->remove($category); + $this->invalidate($bundle); + } + + private function applyExtra(Category $category, array $data): void + { + if (array_key_exists('parent_id', $data)) $category->setParentId($data['parent_id']); + if (array_key_exists('weight', $data)) $category->setWeight((int) $data['weight']); + if (array_key_exists('logo_id', $data)) $category->setLogoId($data['logo_id']); + if (array_key_exists('title', $data)) $category->setTitle($data['title']); + if (array_key_exists('contact_phone', $data)) $category->setContactPhone($data['contact_phone']); + if (array_key_exists('email', $data)) $category->setEmail($data['email']); + if (array_key_exists('description', $data)) $category->setDescription($data['description']); + if (array_key_exists('slogan', $data)) $category->setSlogan($data['slogan']); + if (array_key_exists('domain', $data)) $category->setDomain($data['domain']); + if (array_key_exists('keywords', $data)) $category->setKeywords($data['keywords']); + if (array_key_exists('footer_description', $data)) $category->setFooterDescription($data['footer_description']); + if (array_key_exists('footer_disclaimer', $data)) $category->setFooterDisclaimer($data['footer_disclaimer']); + if (array_key_exists('social_media', $data)) $category->setSocialMedia($data['social_media']); + } + + private function invalidate(string $bundle): void + { + $this->cache->deleteItem('cat_' . $bundle); + // Also delete any parent-filtered variants + foreach (range(1, 50) as $id) { + $this->cache->deleteItem('cat_' . $bundle . '_p' . $id); + } + } +} diff --git a/src/Clinic/Controller/ClinicController.php b/src/Clinic/Controller/ClinicController.php new file mode 100644 index 00000000..f8d6ca30 --- /dev/null +++ b/src/Clinic/Controller/ClinicController.php @@ -0,0 +1,268 @@ +getContent(), true) ?? []; + $clinic = new Clinic($user); + $this->hydrateClinic($clinic, $data); + $this->clinicRepo->save($clinic); + + // Grant ROLE_CLINIC to user + $roles = $user->getRoles(); + if (!in_array('ROLE_CLINIC', $roles, true)) { + $roles[] = 'ROLE_CLINIC'; + $user->setRoles(array_values(array_unique($roles))); + $this->userRepo->save($user); + } + + return $this->success(['data' => $clinic->toDetailArray()], 201); + } + + #[Route('/api/v1/clinic/{uuid}', methods: ['GET'])] + public function show(string $uuid): JsonResponse + { + $clinic = $this->clinicRepo->findByUuid($uuid); + if ($clinic === null) { + return $this->error(ErrorCodes::ERR_VALIDATION_002, 'کلینیک یافت نشد', 404); + } + + [$stateData, $cityData] = $this->loadLocationData($clinic); + + return $this->success(['data' => $clinic->toDetailArray($stateData, $cityData)]); + } + + #[Route('/api/v1/clinic/{uuid}', methods: ['PATCH'])] + #[IsGranted('IS_AUTHENTICATED_FULLY')] + public function update(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse + { + $clinic = $this->clinicRepo->findByUuid($uuid); + if ($clinic === null) { + return $this->error(ErrorCodes::ERR_VALIDATION_002, 'کلینیک یافت نشد', 404); + } + + if ($clinic->getUser()->getId() !== $user->getId() && !$user->hasRole('ROLE_ADMIN')) { + return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403); + } + + $data = json_decode($request->getContent(), true) ?? []; + $this->hydrateClinic($clinic, $data); + $this->clinicRepo->save($clinic); + + [$stateData, $cityData] = $this->loadLocationData($clinic); + + return $this->success(['data' => $clinic->toDetailArray($stateData, $cityData)]); + } + + #[Route('/api/v1/clinics', methods: ['GET'])] + public function list(Request $request): JsonResponse + { + $filters = $request->query->all(); + $result = $this->clinicRepo->findWithFilters($filters); + + return $this->paginated( + array_map(fn(Clinic $c) => $c->toListArray(), $result['items']), + $result['total'], + $result['page'], + $result['limit'] + ); + } + + #[Route('/api/v1/clinic/doctor-list/{clinicUuid}', methods: ['GET'])] + public function doctorList(string $clinicUuid): JsonResponse + { + $clinic = $this->clinicRepo->findByUuid($clinicUuid); + if ($clinic === null) { + return $this->error(ErrorCodes::ERR_VALIDATION_002, 'کلینیک یافت نشد', 404); + } + + $doctors = array_map( + fn(Doctor $d) => $d->toListArray(), + $clinic->getDoctors()->toArray() + ); + + return $this->success(['data' => $doctors]); + } + + #[Route('/file/upload/clinic_pro/clinic/field_image_clinic', methods: ['POST'])] + #[IsGranted('IS_AUTHENTICATED_FULLY')] + public function uploadImage(Request $request): JsonResponse + { + return $this->handleFileUpload($request, 'clinics/gallery'); + } + + #[Route('/file/upload/clinic_pro/clinic/field_clinic_logo', methods: ['POST'])] + #[IsGranted('IS_AUTHENTICATED_FULLY')] + public function uploadLogo(Request $request): JsonResponse + { + return $this->handleFileUpload($request, 'clinics/logo'); + } + + // ── Helpers ─────────────────────────────────────────────────────────────── + + private function hydrateClinic(Clinic $clinic, array $data): void + { + if (array_key_exists('name', $data)) $clinic->setName($data['name']); + if (array_key_exists('info', $data)) $clinic->setInfo($data['info']); + if (array_key_exists('address', $data)) $clinic->setAddress($data['address']); + if (array_key_exists('telephone', $data)) $clinic->setTelephone($data['telephone']); + if (array_key_exists('working_days', $data)) $clinic->setWorkingDays($data['working_days']); + if (array_key_exists('24_7', $data)) $clinic->setIs247((bool) $data['24_7']); + if (array_key_exists('latitude', $data)) $clinic->setLatitude((float) $data['latitude']); + if (array_key_exists('longitude', $data)) $clinic->setLongitude((float) $data['longitude']); + + // Location + if (!empty($data['state']) && is_array($data['state'])) { + $clinic->setStateId((int) $data['state'][0]); + } + if (!empty($data['city']) && is_array($data['city'])) { + $clinic->setCityId((int) $data['city'][0]); + } + + // Images stored as JSON (from upload response) + if (array_key_exists('image_clinic', $data) && is_array($data['image_clinic'])) { + $clinic->setImagesClinic($data['image_clinic']); + } + if (array_key_exists('clinic_logo', $data) && is_array($data['clinic_logo'])) { + $clinic->setClinicLogo($data['clinic_logo']); + } + + // ManyToMany: doctors + if (array_key_exists('doctors', $data) && is_array($data['doctors'])) { + $clinic->getDoctors()->clear(); + foreach ($data['doctors'] as $doctorId) { + $doctor = $this->doctorRepo->find((int) $doctorId); + if ($doctor !== null) { + $clinic->getDoctors()->add($doctor); + } + } + } + + // ManyToMany: specialties + if (array_key_exists('specialties', $data) && is_array($data['specialties'])) { + $clinic->getSpecialties()->clear(); + foreach ($data['specialties'] as $catId) { + $cat = $this->categoryRepo->find((int) $catId); + if ($cat !== null) { + $clinic->getSpecialties()->add($cat); + } + } + } + + // ManyToMany: services (doctor_services) + if (array_key_exists('doctor_services', $data) && is_array($data['doctor_services'])) { + $clinic->getServices()->clear(); + foreach ($data['doctor_services'] as $catId) { + $cat = $this->categoryRepo->find((int) $catId); + if ($cat !== null) { + $clinic->getServices()->add($cat); + } + } + } + + // ManyToMany: insurances + if (array_key_exists('insurance', $data) && is_array($data['insurance'])) { + $clinic->getInsurances()->clear(); + foreach ($data['insurance'] as $catId) { + $cat = $this->categoryRepo->find((int) $catId); + if ($cat !== null) { + $clinic->getInsurances()->add($cat); + } + } + } + } + + private function loadLocationData(Clinic $clinic): array + { + $stateData = []; + $cityData = []; + + if ($clinic->getStateId() !== null) { + $state = $this->categoryRepo->find($clinic->getStateId()); + if ($state !== null) { + $stateData = ['uuid' => $state->getUuid(), 'id' => (string) $state->getId(), 'name' => $state->getLabel()]; + } + } + if ($clinic->getCityId() !== null) { + $city = $this->categoryRepo->find($clinic->getCityId()); + if ($city !== null) { + $cityData = [ + 'uuid' => $city->getUuid(), + 'id' => (string) $city->getId(), + 'name' => $city->getLabel(), + 'parent' => $city->getParentId() !== null ? (string) $city->getParentId() : null, + ]; + } + } + + return [$stateData, $cityData]; + } + + private function handleFileUpload(Request $request, string $subDir): JsonResponse + { + $content = $request->getContent(); + $disposition = $request->headers->get('Content-Disposition', ''); + preg_match('/filename=["\']?([^"\';\s]+)["\']?/i', $disposition, $m); + $filename = $m[1] ?? 'upload.jpg'; + + $tmpPath = sys_get_temp_dir() . '/' . uniqid('upload_', true); + file_put_contents($tmpPath, $content); + + try { + $safeFilename = $this->fileValidator->sanitizeFilename($filename); + $mime = $this->fileValidator->detectMimeType($tmpPath); + + $year = date('Y'); $month = date('m'); + $dir = $this->projectDir . '/public/uploads/' . $subDir . '/' . $year . '-' . $month; + if (!is_dir($dir)) mkdir($dir, 0755, true); + + $storedName = uniqid('', true) . '_' . $safeFilename; + rename($tmpPath, $dir . '/' . $storedName); + + $url = '/uploads/' . $subDir . '/' . $year . '-' . $month . '/' . $storedName; + + return $this->success([ + 'fid' => time(), + 'uuid' => Uuid::v4()->toRfc4122(), + 'url' => $url, + 'filename' => $safeFilename, + 'filemime' => $mime, + 'filesize' => strlen($content), + ]); + } catch (\Throwable $e) { + if (file_exists($tmpPath)) unlink($tmpPath); + return $this->error(ErrorCodes::ERR_VALIDATION_001, $e->getMessage(), 422); + } + } +} diff --git a/src/Clinic/Entity/Clinic.php b/src/Clinic/Entity/Clinic.php new file mode 100644 index 00000000..2967ed05 --- /dev/null +++ b/src/Clinic/Entity/Clinic.php @@ -0,0 +1,212 @@ +uuid = Uuid::v4()->toRfc4122(); + $this->user = $user; + $this->createdAt = time(); + $this->updatedAt = time(); + $this->doctors = new ArrayCollection(); + $this->specialties = new ArrayCollection(); + $this->services = new ArrayCollection(); + $this->insurances = new ArrayCollection(); + } + + public function getId(): ?int { return $this->id; } + public function getUuid(): string { return $this->uuid; } + public function getUser(): User { return $this->user; } + public function getName(): ?string { return $this->name; } + public function getInfo(): ?string { return $this->info; } + public function getAddress(): ?string { return $this->address; } + public function getTelephone(): ?string { return $this->telephone; } + public function isIs247(): bool { return $this->is247; } + public function getWorkingDays(): ?string { return $this->workingDays; } + public function getLatitude(): ?float { return $this->latitude; } + public function getLongitude(): ?float { return $this->longitude; } + public function getCityId(): ?int { return $this->cityId; } + public function getStateId(): ?int { return $this->stateId; } + public function getRepresentationId(): ?int { return $this->representationId; } + public function getImagesClinic(): ?array { return $this->imagesClinic; } + public function getClinicLogo(): ?array { return $this->clinicLogo; } + public function getDoctors(): Collection { return $this->doctors; } + public function getSpecialties(): Collection { return $this->specialties; } + public function getServices(): Collection { return $this->services; } + public function getInsurances(): Collection { return $this->insurances; } + + public function setName(?string $v): self { $this->name = $v; $this->touch(); return $this; } + public function setInfo(?string $v): self { $this->info = $v; $this->touch(); return $this; } + public function setAddress(?string $v): self { $this->address = $v; $this->touch(); return $this; } + public function setTelephone(?string $v): self { $this->telephone = $v; $this->touch(); return $this; } + public function setIs247(bool $v): self { $this->is247 = $v; $this->touch(); return $this; } + public function setWorkingDays(?string $v): self { $this->workingDays = $v; $this->touch(); return $this; } + public function setLatitude(?float $v): self { $this->latitude = $v; $this->touch(); return $this; } + public function setLongitude(?float $v): self { $this->longitude = $v; $this->touch(); return $this; } + public function setCityId(?int $v): self { $this->cityId = $v; $this->touch(); return $this; } + public function setStateId(?int $v): self { $this->stateId = $v; $this->touch(); return $this; } + public function setRepresentationId(?int $v): self { $this->representationId = $v; $this->touch(); return $this; } + public function setImagesClinic(?array $v): self { $this->imagesClinic = $v; $this->touch(); return $this; } + public function setClinicLogo(?array $v): self { $this->clinicLogo = $v; $this->touch(); return $this; } + + private function touch(): void { $this->updatedAt = time(); } + + public function toDetailArray(array $stateData = [], array $cityData = []): array + { + $formatCat = fn(Category $c) => [ + 'uuid' => $c->getUuid(), 'id' => (string) $c->getId(), 'name' => $c->getLabel(), + ]; + $formatCatWithParent = fn(Category $c) => [ + 'uuid' => $c->getUuid(), 'id' => (string) $c->getId(), 'name' => $c->getLabel(), + 'parent' => $c->getParentId() !== null ? (string) $c->getParentId() : null, + ]; + + return [ + 'id' => (string) $this->id, + 'uuid' => $this->uuid, + 'title' => $this->name, + 'images_clinic' => $this->imagesClinic ?? [], + 'clinic_logo' => $this->clinicLogo ?? [], + 'phone_number' => $this->telephone, + 'caption' => $this->info, + 'list_bime' => array_map($formatCat, $this->insurances->toArray()), + 'specialties' => array_map($formatCatWithParent, $this->specialties->toArray()), + 'services' => array_map($formatCat, $this->services->toArray()), + 'clinic_specialty' => array_map($formatCatWithParent, $this->specialties->toArray()), + 'doctors' => $this->doctors->count(), + 'doctor_list' => null, + 'city' => $cityData ? [$cityData] : [], + 'state' => $stateData ? [$stateData] : [], + 'location' => $this->address, + 'map' => [ + 'latitude' => $this->latitude !== null ? (string) $this->latitude : null, + 'longitude' => $this->longitude !== null ? (string) $this->longitude : null, + ], + '24_7' => $this->is247, + 'field_working_days' => $this->workingDays, + ]; + } + + public function toListArray(): array + { + $formatCat = fn(Category $c) => [ + 'uuid' => $c->getUuid(), 'id' => (string) $c->getId(), 'name' => $c->getLabel(), + ]; + + return [ + 'id' => (string) $this->id, + 'uuid' => $this->uuid, + 'title' => $this->name, + 'images_clinic' => $this->imagesClinic ?? [], + 'clinic_logo' => $this->clinicLogo ?? [], + 'phone_number' => $this->telephone, + 'specialties' => array_map($formatCat, $this->specialties->toArray()), + 'doctors' => $this->doctors->count(), + '24_7' => $this->is247, + ]; + } +} diff --git a/src/Clinic/Repository/ClinicRepository.php b/src/Clinic/Repository/ClinicRepository.php new file mode 100644 index 00000000..aee8d881 --- /dev/null +++ b/src/Clinic/Repository/ClinicRepository.php @@ -0,0 +1,80 @@ +findOneBy(['uuid' => $uuid]); + } + + public function findByUser(User $user): ?Clinic + { + return $this->findOneBy(['user' => $user]); + } + + public function findWithFilters(array $filters): array + { + $page = max(1, (int) ($filters['page'] ?? 1)); + $limit = min(50, max(1, (int) ($filters['limit'] ?? 10))); + $sort = strtoupper($filters['sort'] ?? 'DESC') === 'ASC' ? 'ASC' : 'DESC'; + + $qb = $this->createQueryBuilder('c') + ->leftJoin('c.specialties', 's') + ->distinct(); + + if (!empty($filters['state'])) { + $qb->andWhere('c.stateId = :state')->setParameter('state', (int) $filters['state']); + } + if (!empty($filters['city'])) { + $qb->andWhere('c.cityId = :city')->setParameter('city', (int) $filters['city']); + } + if (!empty($filters['specialty'])) { + $qb->andWhere('s.id = :specialty')->setParameter('specialty', (int) $filters['specialty']); + } + + $qb->orderBy('c.id', $sort); + + $total = (new Paginator($qb))->count(); + $results = $qb->setFirstResult(($page - 1) * $limit) + ->setMaxResults($limit) + ->getQuery() + ->getResult(); + + return [ + 'items' => $results, + 'total' => $total, + 'page' => $page, + 'limit' => $limit, + 'totalPages' => (int) ceil($total / $limit), + ]; + } + + public function save(Clinic $clinic, bool $flush = true): void + { + $this->getEntityManager()->persist($clinic); + if ($flush) { + $this->getEntityManager()->flush(); + } + } + + public function remove(Clinic $clinic, bool $flush = true): void + { + $this->getEntityManager()->remove($clinic); + if ($flush) { + $this->getEntityManager()->flush(); + } + } +} diff --git a/src/Controller/.gitignore b/src/Controller/.gitignore new file mode 100644 index 00000000..e69de29b diff --git a/src/Doctor/Controller/DoctorController.php b/src/Doctor/Controller/DoctorController.php new file mode 100644 index 00000000..d64fbe0e --- /dev/null +++ b/src/Doctor/Controller/DoctorController.php @@ -0,0 +1,367 @@ +doctorRepo->findByUser($user) !== null) { + return $this->error(ErrorCodes::ERR_VALIDATION_001, 'پروفایل دکتر قبلاً ایجاد شده است', 409); + } + + $data = json_decode($request->getContent(), true) ?? []; + $name = trim($data['title'] ?? $data['name'] ?? ''); + + if ($name === '') { + return $this->error(ErrorCodes::ERR_VALIDATION_001, 'نام دکتر الزامی است', 422, 'title'); + } + + $doctor = new Doctor($user, $name); + $this->hydrateDoctor($doctor, $data); + $this->doctorRepo->save($doctor); + + // Grant ROLE_DOCTOR to user + $roles = $user->getRoles(); + if (!in_array('ROLE_DOCTOR', $roles, true)) { + $roles[] = 'ROLE_DOCTOR'; + $user->setRoles(array_values(array_unique($roles))); + $this->userRepo->save($user); + } + + return $this->success(['data' => $doctor->toDetailArray()], 201); + } + + #[Route('/api/v1/doctor/{uuid}', methods: ['GET'])] + public function show(string $uuid): JsonResponse + { + $doctor = $this->doctorRepo->findByUuid($uuid); + if ($doctor === null) { + return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دکتر یافت نشد', 404); + } + + return $this->success(['data' => $doctor->toDetailArray()]); + } + + #[Route('/api/v1/doctors', methods: ['GET'])] + public function list(Request $request): JsonResponse + { + $filters = $request->query->all(); + $result = $this->doctorRepo->findWithFilters($filters); + + return $this->paginated( + array_map(fn(Doctor $d) => $d->toListArray(), $result['items']), + $result['total'], + $result['page'], + $result['limit'] + ); + } + + #[Route('/api/v1/doctor/{uuid}', methods: ['PATCH'])] + #[IsGranted('IS_AUTHENTICATED_FULLY')] + public function update(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse + { + $doctor = $this->doctorRepo->findByUuid($uuid); + if ($doctor === null) { + return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دکتر یافت نشد', 404); + } + + if ($doctor->getUser()->getId() !== $user->getId() && !$user->hasRole('ROLE_ADMIN')) { + return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403); + } + + $data = json_decode($request->getContent(), true) ?? []; + if (!empty($data['title'])) $doctor->setName($data['title']); + + $this->hydrateDoctor($doctor, $data); + $this->doctorRepo->save($doctor); + + return $this->success(['data' => $doctor->toDetailArray()]); + } + + #[Route('/api/v1/doctor/{uuid}', methods: ['DELETE'])] + #[IsGranted('ROLE_ADMIN')] + public function delete(string $uuid): JsonResponse + { + $doctor = $this->doctorRepo->findByUuid($uuid); + if ($doctor === null) { + return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دکتر یافت نشد', 404); + } + + $this->doctorRepo->remove($doctor); + return $this->success(['message' => 'دکتر با موفقیت حذف شد']); + } + + // ── File Upload ─────────────────────────────────────────────────────────── + + #[Route('/file/upload/clinic_pro/doctor/field_image', methods: ['POST'])] + #[IsGranted('IS_AUTHENTICATED_FULLY')] + public function uploadImage(Request $request): JsonResponse + { + $content = $request->getContent(); + $disposition = $request->headers->get('Content-Disposition', ''); + preg_match('/filename=["\']?([^"\';\s]+)["\']?/i', $disposition, $m); + $filename = $m[1] ?? 'upload.jpg'; + + // Write to a tmp file for magic bytes validation + $tmpPath = sys_get_temp_dir() . '/' . uniqid('upload_', true); + file_put_contents($tmpPath, $content); + + try { + // validate() checks size, magic bytes, and sanitizes filename + $safeFilename = $this->fileValidator->validate($content, $filename); + $mime = $this->fileValidator->detectMimeType($tmpPath); + + $year = date('Y'); + $month = date('m'); + $dir = $this->projectDir . '/public/uploads/doctors/' . $year . '-' . $month; + if (!is_dir($dir)) { + mkdir($dir, 0755, true); + } + + $storedName = uniqid('', true) . '_' . $safeFilename; + $fullPath = $dir . '/' . $storedName; + rename($tmpPath, $fullPath); + + $filesize = filesize($fullPath); + $url = '/uploads/doctors/' . $year . '-' . $month . '/' . $storedName; + + return $this->success([ + 'fid' => time(), + 'uuid' => \Symfony\Component\Uid\Uuid::v4()->toRfc4122(), + 'url' => $url, + 'filename' => $safeFilename, + 'filemime' => $mime, + 'filesize' => $filesize, + ]); + } catch (\Throwable $e) { + if (file_exists($tmpPath)) unlink($tmpPath); + return $this->error(ErrorCodes::ERR_VALIDATION_001, $e->getMessage(), 422); + } + } + + // ── Doctor Addresses ────────────────────────────────────────────────────── + + #[Route('/api/v1/clinic-pro/doctor-address', methods: ['POST'])] + #[IsGranted('IS_AUTHENTICATED_FULLY')] + public function createAddress(Request $request, #[CurrentUser] User $user): JsonResponse + { + $doctor = $this->doctorRepo->findByUser($user); + if ($doctor === null && !$user->hasRole('ROLE_ADMIN')) { + return $this->error(ErrorCodes::ERR_AUTH_006, 'فقط دکتر می‌تواند آدرس اضافه کند', 403); + } + + $data = json_decode($request->getContent(), true) ?? []; + + // Admin can specify doctor_id/doctor_uuid + if ($doctor === null && $user->hasRole('ROLE_ADMIN')) { + $doctorUuid = $data['doctor_uuid'] ?? null; + if (!$doctorUuid) { + return $this->error(ErrorCodes::ERR_VALIDATION_001, 'doctor_uuid الزامی است', 422); + } + $doctor = $this->doctorRepo->findByUuid($doctorUuid); + if ($doctor === null) { + return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دکتر یافت نشد', 404); + } + } + + $address = new DoctorAddress($doctor); + $this->hydrateAddress($address, $data); + $this->addressRepo->save($address); + + return $this->success(['data' => $address->toArray()], 201); + } + + #[Route('/api/v1/clinic-pro/doctor-address/{id}', methods: ['GET'])] + #[IsGranted('IS_AUTHENTICATED_FULLY')] + public function showAddress(int $id): JsonResponse + { + $address = $this->addressRepo->find($id); + if ($address === null) { + return $this->error(ErrorCodes::ERR_VALIDATION_002, 'آدرس یافت نشد', 404); + } + + return $this->success(['data' => $address->toArray()]); + } + + #[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 + { + $address = $this->addressRepo->find($id); + if ($address === null) { + return $this->error(ErrorCodes::ERR_VALIDATION_002, 'آدرس یافت نشد', 404); + } + + if ($address->getDoctor()->getUser()->getId() !== $user->getId() && !$user->hasRole('ROLE_ADMIN')) { + return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403); + } + + $data = json_decode($request->getContent(), true) ?? []; + $this->hydrateAddress($address, $data); + $this->addressRepo->save($address); + + return $this->success(['data' => $address->toArray()]); + } + + #[Route('/api/v1/clinic-pro/doctor-address/{id}', methods: ['DELETE'])] + #[IsGranted('IS_AUTHENTICATED_FULLY')] + public function deleteAddress(int $id, #[CurrentUser] User $user): JsonResponse + { + $address = $this->addressRepo->find($id); + if ($address === null) { + return $this->error(ErrorCodes::ERR_VALIDATION_002, 'آدرس یافت نشد', 404); + } + + if ($address->getDoctor()->getUser()->getId() !== $user->getId() && !$user->hasRole('ROLE_ADMIN')) { + return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403); + } + + $this->addressRepo->remove($address); + return $this->success(['message' => 'آدرس با موفقیت حذف شد']); + } + + #[Route('/api/v1/clinic-pro/doctor-addresses/{doctorId}', methods: ['GET'])] + public function listAddresses(int $doctorId): JsonResponse + { + $doctor = $this->doctorRepo->find($doctorId); + if ($doctor === null) { + return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دکتر یافت نشد', 404); + } + + $addresses = array_map(fn(DoctorAddress $a) => $a->toArray(), $doctor->getAddresses()->toArray()); + + 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 + { + if (array_key_exists('gender', $data)) $doctor->setGender($data['gender']); + if (array_key_exists('medical_system_code', $data)) $doctor->setMedicalSystemCode($data['medical_system_code']); + if (array_key_exists('mobile_number', $data)) $doctor->setMobileNumber($data['mobile_number']); + if (array_key_exists('activity_time', $data)) $doctor->setActivityTime((int) $data['activity_time']); + if (array_key_exists('degree', $data)) $doctor->setDegree($data['degree']); + if (array_key_exists('info', $data)) $doctor->setInfo($data['info']); + if (array_key_exists('detail', $data)) $doctor->setInfo($data['detail']); + if (array_key_exists('active', $data)) $doctor->setActiveDoctorAppointment((bool) $data['active']); + + // Images array (from file upload response) + if (array_key_exists('image_data', $data)) { + $existing = $doctor->getImages() ?? []; + $existing[] = $data['image_data']; + $doctor->setImages($existing); + } + if (array_key_exists('images', $data) && is_array($data['images'])) { + $doctor->setImages($data['images']); + } + + // Specialties (array of category IDs) + if (array_key_exists('specialties', $data) && is_array($data['specialties'])) { + $doctor->getSpecialties()->clear(); + foreach ($data['specialties'] as $catId) { + $cat = $this->categoryRepo->find((int) $catId); + if ($cat !== null) { + $doctor->getSpecialties()->add($cat); + } + } + } + + // Expertise / doctor_services + if (array_key_exists('doctor_services', $data) && is_array($data['doctor_services'])) { + $doctor->getExpertise()->clear(); + foreach ($data['doctor_services'] as $catId) { + $cat = is_numeric($catId) + ? $this->categoryRepo->find((int) $catId) + : $this->categoryRepo->findOneBy(['label' => $catId, 'bundle' => 'doctor_services']); + if ($cat !== null) { + $doctor->getExpertise()->add($cat); + } + } + } + if (array_key_exists('expertise', $data) && is_array($data['expertise'])) { + $doctor->getExpertise()->clear(); + foreach ($data['expertise'] as $catId) { + $cat = $this->categoryRepo->find((int) $catId); + if ($cat !== null) { + $doctor->getExpertise()->add($cat); + } + } + } + + // States + if (array_key_exists('states', $data) && is_array($data['states'])) { + $doctor->getStates()->clear(); + foreach ($data['states'] as $catId) { + $cat = $this->categoryRepo->find((int) $catId); + if ($cat !== null) { + $doctor->getStates()->add($cat); + } + } + } + + // Cities + if (array_key_exists('cities', $data) && is_array($data['cities'])) { + $doctor->getCities()->clear(); + foreach ($data['cities'] as $catId) { + $cat = $this->categoryRepo->find((int) $catId); + if ($cat !== null) { + $doctor->getCities()->add($cat); + } + } + } + } + + private function hydrateAddress(DoctorAddress $address, array $data): void + { + if (array_key_exists('name', $data)) $address->setName($data['name']); + if (array_key_exists('address', $data)) $address->setAddress($data['address']); + if (array_key_exists('telephone', $data)) $address->setTelephone($data['telephone']); + + if (isset($data['map']['latitude'])) $address->setLatitude((float) $data['map']['latitude']); + if (isset($data['map']['longitude'])) $address->setLongitude((float) $data['map']['longitude']); + + // Also support flat keys + if (array_key_exists('latitude', $data)) $address->setLatitude((float) $data['latitude']); + if (array_key_exists('longitude', $data)) $address->setLongitude((float) $data['longitude']); + } +} diff --git a/src/Doctor/Entity/Doctor.php b/src/Doctor/Entity/Doctor.php new file mode 100644 index 00000000..4df621aa --- /dev/null +++ b/src/Doctor/Entity/Doctor.php @@ -0,0 +1,232 @@ +uuid = Uuid::v4()->toRfc4122(); + $this->user = $user; + $this->name = $name; + $this->createdAt = time(); + $this->updatedAt = time(); + $this->specialties = new ArrayCollection(); + $this->expertise = new ArrayCollection(); + $this->states = new ArrayCollection(); + $this->cities = new ArrayCollection(); + $this->addresses = new ArrayCollection(); + } + + public function getId(): ?int { return $this->id; } + public function getUuid(): string { return $this->uuid; } + public function getUser(): User { return $this->user; } + public function getName(): string { return $this->name; } + public function getGender(): ?string { return $this->gender; } + public function getMedicalSystemCode(): ?string { return $this->medicalSystemCode; } + public function getMobileNumber(): ?string { return $this->mobileNumber; } + public function getActivityTime(): ?int { return $this->activityTime; } + public function getDegree(): ?string { return $this->degree; } + public function getInfo(): ?string { return $this->info; } + public function getImages(): ?array { return $this->images; } + public function getDoctorRate(): float { return $this->doctorRate; } + public function getDoctorRatePercentage(): float { return $this->doctorRatePercentage; } + public function isActiveDoctorAppointment(): bool { return $this->activeDoctorAppointment; } + public function getRepresentationId(): ?int { return $this->representationId; } + public function getCreatedAt(): int { return $this->createdAt; } + public function getUpdatedAt(): int { return $this->updatedAt; } + public function getSpecialties(): Collection { return $this->specialties; } + public function getExpertise(): Collection { return $this->expertise; } + public function getStates(): Collection { return $this->states; } + public function getCities(): Collection { return $this->cities; } + public function getAddresses(): Collection { return $this->addresses; } + + public function setName(string $v): self { $this->name = $v; return $this; } + public function setGender(?string $v): self { $this->gender = $v; $this->touch(); return $this; } + public function setMedicalSystemCode(?string $v): self { $this->medicalSystemCode = $v; $this->touch(); return $this; } + public function setMobileNumber(?string $v): self { $this->mobileNumber = $v; $this->touch(); return $this; } + public function setActivityTime(?int $v): self { $this->activityTime = $v; $this->touch(); return $this; } + public function setDegree(?string $v): self { $this->degree = $v; $this->touch(); return $this; } + public function setInfo(?string $v): self { $this->info = $v; $this->touch(); return $this; } + public function setImages(?array $v): self { $this->images = $v; $this->touch(); return $this; } + public function setDoctorRate(float $v): self { $this->doctorRate = $v; $this->touch(); return $this; } + public function setDoctorRatePercentage(float $v): self { $this->doctorRatePercentage = $v; $this->touch(); return $this; } + public function setActiveDoctorAppointment(bool $v): self { $this->activeDoctorAppointment = $v; $this->touch(); return $this; } + public function setRepresentationId(?int $v): self { $this->representationId = $v; $this->touch(); return $this; } + + private function touch(): void { $this->updatedAt = time(); } + + public function getExperience(): int + { + if ($this->activityTime === null) { + return 0; + } + return max(0, (int)((time() - $this->activityTime) / (365.25 * 24 * 3600))); + } + + public function toListArray(): array + { + return [ + 'id' => (string) $this->id, + 'uuid' => $this->uuid, + 'name' => $this->name, + 'gender' => $this->gender, + 'degree' => $this->degree, + 'img' => $this->images ?? [], + 'specialties' => $this->formatCategories($this->specialties), + 'satisfaction' => (string) $this->doctorRatePercentage, + 'point' => (string) $this->doctorRate, + 'free_turn' => 'نوبت آزادی موجود نیست', + 'hours_of_work' => 'برنامه کاری تنظیم نشده', + 'active' => $this->activeDoctorAppointment, + ]; + } + + public function toDetailArray(): array + { + return [ + 'id' => (string) $this->id, + 'uuid' => $this->uuid, + 'name' => $this->name, + 'gender' => $this->gender, + 'experience' => $this->getExperience(), + 'activity_time' => $this->activityTime !== null ? (string) $this->activityTime : null, + 'medical_system_code' => $this->medicalSystemCode, + 'detail' => $this->info, + 'degree' => $this->degree, + 'specialties' => $this->formatCategories($this->specialties), + 'img' => $this->images ?? [], + 'expertise' => $this->formatCategories($this->expertise), + 'satisfaction' => (string) $this->doctorRatePercentage, + 'point' => (string) $this->doctorRate, + 'free_turn' => 'نوبت آزادی موجود نیست', + 'hours_of_work' => 'برنامه کاری تنظیم نشده', + 'address' => array_map(fn(DoctorAddress $a) => $a->toArray(), $this->addresses->toArray()), + 'average_rate' => ['total_rates' => null], + 'state' => $this->formatCategories($this->states), + 'city' => $this->formatCategoriesWithParent($this->cities), + ]; + } + + private function formatCategories(Collection $collection): array + { + return array_map(fn(Category $c) => [ + 'uuid' => $c->getUuid(), + 'id' => (string) $c->getId(), + 'name' => $c->getLabel(), + ], $collection->toArray()); + } + + private function formatCategoriesWithParent(Collection $collection): array + { + return array_map(fn(Category $c) => [ + 'uuid' => $c->getUuid(), + 'id' => (string) $c->getId(), + 'name' => $c->getLabel(), + 'parent' => $c->getParentId() !== null ? (string) $c->getParentId() : null, + ], $collection->toArray()); + } +} diff --git a/src/Doctor/Entity/DoctorAddress.php b/src/Doctor/Entity/DoctorAddress.php new file mode 100644 index 00000000..e892ab6c --- /dev/null +++ b/src/Doctor/Entity/DoctorAddress.php @@ -0,0 +1,85 @@ +uuid = Uuid::v4()->toRfc4122(); + $this->doctor = $doctor; + $this->createdAt = time(); + $this->updatedAt = time(); + } + + public function getId(): ?int { return $this->id; } + public function getUuid(): string { return $this->uuid; } + public function getDoctor(): Doctor { return $this->doctor; } + public function getName(): ?string { return $this->name; } + public function getAddress(): ?string { return $this->address; } + public function getTelephone(): ?string { return $this->telephone; } + public function getLatitude(): ?float { return $this->latitude; } + public function getLongitude(): ?float { return $this->longitude; } + + public function setName(?string $v): self { $this->name = $v; return $this; } + public function setAddress(?string $v): self { $this->address = $v; $this->touch(); return $this; } + public function setTelephone(?string $v): self { $this->telephone = $v; $this->touch(); return $this; } + public function setLatitude(?float $v): self { $this->latitude = $v; $this->touch(); return $this; } + public function setLongitude(?float $v): self { $this->longitude = $v; $this->touch(); return $this; } + + private function touch(): void { $this->updatedAt = time(); } + + public function toArray(): array + { + return [ + 'id' => (string) $this->id, + 'uuid' => $this->uuid, + 'name' => $this->name, + 'map' => [ + 'latitude' => $this->latitude !== null ? (string) $this->latitude : null, + 'longitude' => $this->longitude !== null ? (string) $this->longitude : null, + ], + 'address' => $this->address, + 'telephone' => $this->telephone, + ]; + } +} diff --git a/src/Doctor/Repository/DoctorAddressRepository.php b/src/Doctor/Repository/DoctorAddressRepository.php new file mode 100644 index 00000000..a71bd246 --- /dev/null +++ b/src/Doctor/Repository/DoctorAddressRepository.php @@ -0,0 +1,31 @@ +getEntityManager()->persist($address); + if ($flush) { + $this->getEntityManager()->flush(); + } + } + + public function remove(DoctorAddress $address, bool $flush = true): void + { + $this->getEntityManager()->remove($address); + if ($flush) { + $this->getEntityManager()->flush(); + } + } +} diff --git a/src/Doctor/Repository/DoctorRepository.php b/src/Doctor/Repository/DoctorRepository.php new file mode 100644 index 00000000..6d61dec1 --- /dev/null +++ b/src/Doctor/Repository/DoctorRepository.php @@ -0,0 +1,95 @@ +findOneBy(['uuid' => $uuid]); + } + + public function findByUser(User $user): ?Doctor + { + return $this->findOneBy(['user' => $user]); + } + + public function findWithFilters(array $filters): array + { + $page = max(1, (int) ($filters['page'] ?? 1)); + $limit = min(50, max(1, (int) ($filters['limit'] ?? 10))); + $sort = strtoupper($filters['sort'] ?? 'DESC') === 'ASC' ? 'ASC' : 'DESC'; + + $qb = $this->createQueryBuilder('d') + ->leftJoin('d.specialties', 's') + ->leftJoin('d.states', 'st') + ->leftJoin('d.cities', 'ci') + ->distinct(); + + if (!empty($filters['state'])) { + $qb->andWhere('st.id = :state')->setParameter('state', (int) $filters['state']); + } + if (!empty($filters['city'])) { + $qb->andWhere('ci.id = :city')->setParameter('city', (int) $filters['city']); + } + if (!empty($filters['specialty'])) { + $qb->andWhere('s.id = :specialty')->setParameter('specialty', (int) $filters['specialty']); + } + if (!empty($filters['gender'])) { + $qb->andWhere('d.gender = :gender')->setParameter('gender', $filters['gender']); + } + if (!empty($filters['degree'])) { + $qb->andWhere('d.degree = :degree')->setParameter('degree', $filters['degree']); + } + if (!empty($filters['name'])) { + $qb->andWhere('d.name LIKE :name')->setParameter('name', '%' . $filters['name'] . '%'); + } + if (isset($filters['active'])) { + $qb->andWhere('d.activeDoctorAppointment = :active') + ->setParameter('active', (bool) $filters['active']); + } + + $qb->orderBy('d.doctorRate', $sort); + + $total = (new Paginator($qb))->count(); + $results = $qb->setFirstResult(($page - 1) * $limit) + ->setMaxResults($limit) + ->getQuery() + ->getResult(); + + return [ + 'items' => $results, + 'total' => $total, + 'page' => $page, + 'limit' => $limit, + 'totalPages' => (int) ceil($total / $limit), + ]; + } + + public function save(Doctor $doctor, bool $flush = true): void + { + $this->getEntityManager()->persist($doctor); + if ($flush) { + $this->getEntityManager()->flush(); + } + } + + public function remove(Doctor $doctor, bool $flush = true): void + { + $this->getEntityManager()->remove($doctor); + if ($flush) { + $this->getEntityManager()->flush(); + } + } +} diff --git a/src/Entity/.gitignore b/src/Entity/.gitignore new file mode 100644 index 00000000..e69de29b diff --git a/src/Insurance/Controller/InsuranceController.php b/src/Insurance/Controller/InsuranceController.php new file mode 100644 index 00000000..d7d65955 --- /dev/null +++ b/src/Insurance/Controller/InsuranceController.php @@ -0,0 +1,118 @@ +getContent(), true) ?? []; + $doctorId = $data['doctor_id'] ?? null; + $categoryId = $data['category_id'] ?? null; + + if (!$doctorId || !$categoryId) { + return $this->error(ErrorCodes::ERR_VALIDATION_001, 'doctor_id و category_id الزامی است', 422); + } + + $doctor = $this->doctorRepo->find((int) $doctorId); + if ($doctor === null) { + return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دکتر یافت نشد', 404); + } + + // Only the doctor owner or admin can add insurance + if ($doctor->getUser()->getId() !== $user->getId() && !$user->hasRole('ROLE_ADMIN')) { + return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403); + } + + $category = $this->categoryRepo->find((int) $categoryId); + if ($category === null) { + return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دسته‌بندی بیمه یافت نشد', 404); + } + + // Check duplicate + $existing = $this->repository->findOneBy(['doctor' => $doctor, 'category' => $category]); + if ($existing !== null) { + return $this->error(ErrorCodes::ERR_VALIDATION_001, 'این بیمه قبلاً اضافه شده است', 409); + } + + $insurance = new DoctorInsurance($doctor, $category); + if (isset($data['price'])) { + $insurance->setPrice((int) $data['price']); + } + + $this->repository->save($insurance); + + return $this->success(['data' => $insurance->toArray()], 201); + } + + #[Route('/api/v1/insurance/{id}', methods: ['GET'])] + public function show(int $id): JsonResponse + { + $insurance = $this->repository->find($id); + if ($insurance === null) { + return $this->error(ErrorCodes::ERR_VALIDATION_002, 'بیمه یافت نشد', 404); + } + + return $this->success(['data' => $insurance->toArray()]); + } + + #[Route('/api/v1/insurance/{id}', methods: ['PATCH'])] + public function update(int $id, Request $request, #[CurrentUser] User $user): JsonResponse + { + $insurance = $this->repository->find($id); + if ($insurance === null) { + return $this->error(ErrorCodes::ERR_VALIDATION_002, 'بیمه یافت نشد', 404); + } + + if ($insurance->getDoctor()->getUser()->getId() !== $user->getId() && !$user->hasRole('ROLE_ADMIN')) { + return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403); + } + + $data = json_decode($request->getContent(), true) ?? []; + if (array_key_exists('price', $data)) { + $insurance->setPrice($data['price'] !== null ? (int) $data['price'] : null); + } + + $this->repository->save($insurance); + + return $this->success(['data' => $insurance->toArray()]); + } + + #[Route('/api/v1/insurance/{id}', methods: ['DELETE'])] + public function delete(int $id, #[CurrentUser] User $user): JsonResponse + { + $insurance = $this->repository->find($id); + if ($insurance === null) { + return $this->error(ErrorCodes::ERR_VALIDATION_002, 'بیمه یافت نشد', 404); + } + + if ($insurance->getDoctor()->getUser()->getId() !== $user->getId() && !$user->hasRole('ROLE_ADMIN')) { + return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403); + } + + $this->repository->remove($insurance); + + return $this->success(['message' => 'بیمه با موفقیت حذف شد']); + } +} diff --git a/src/Insurance/Entity/DoctorInsurance.php b/src/Insurance/Entity/DoctorInsurance.php new file mode 100644 index 00000000..2401ef3a --- /dev/null +++ b/src/Insurance/Entity/DoctorInsurance.php @@ -0,0 +1,55 @@ +doctor = $doctor; + $this->category = $category; + } + + public function getId(): ?int { return $this->id; } + public function getDoctor(): Doctor { return $this->doctor; } + public function getCategory(): Category { return $this->category; } + public function getPrice(): ?int { return $this->price; } + + public function setPrice(?int $v): self { $this->price = $v; return $this; } + + public function toArray(): array + { + return [ + 'id' => $this->id, + 'doctor_id' => $this->doctor->getId(), + 'category_id' => $this->category->getId(), + 'category_name' => $this->category->getLabel(), + 'bundle' => $this->category->getBundle(), + 'price' => $this->price, + ]; + } +} diff --git a/src/Insurance/Repository/DoctorInsuranceRepository.php b/src/Insurance/Repository/DoctorInsuranceRepository.php new file mode 100644 index 00000000..7cf5ff0e --- /dev/null +++ b/src/Insurance/Repository/DoctorInsuranceRepository.php @@ -0,0 +1,31 @@ +getEntityManager()->persist($entity); + if ($flush) { + $this->getEntityManager()->flush(); + } + } + + public function remove(DoctorInsurance $entity, bool $flush = true): void + { + $this->getEntityManager()->remove($entity); + if ($flush) { + $this->getEntityManager()->flush(); + } + } +} diff --git a/src/Kernel.php b/src/Kernel.php new file mode 100644 index 00000000..779cd1f2 --- /dev/null +++ b/src/Kernel.php @@ -0,0 +1,11 @@ +getContent(), true) ?? []; + $appointmentUuid = trim($data['appointment_uuid'] ?? ''); + $gatewayName = trim($data['gateway'] ?? 'mellat'); + $frontendAddress = trim($data['frontend_address'] ?? ''); + + $appointment = $this->appointmentRepo->findByUuid($appointmentUuid); + if ($appointment === null) { + return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'نوبت یافت نشد', 404); + } + + if ($appointment->getUser()->getId() !== $user->getId()) { + return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403); + } + + if (!in_array($appointment->getStatus(), [Appointment::STATUS_PENDING, Appointment::STATUS_CONFIRMED], true)) { + return $this->error(ErrorCodes::ERR_PAYMENT_003, ErrorCodes::message(ErrorCodes::ERR_PAYMENT_003), 422); + } + + // Validate Open Redirect + if (!empty($frontendAddress) && !$this->isAllowedFrontend($frontendAddress)) { + return $this->error(ErrorCodes::ERR_VALIDATION_001, 'آدرس بازگشت مجاز نیست', 422, 'frontend_address'); + } + + $gateway = $this->resolveGateway($gatewayName); + if ($gateway === null) { + return $this->error(ErrorCodes::ERR_VALIDATION_001, 'درگاه پرداخت نامعتبر است', 422, 'gateway'); + } + + if ($this->circuitBreaker->isOpen($gatewayName)) { + return $this->error(ErrorCodes::ERR_PAYMENT_001, ErrorCodes::message(ErrorCodes::ERR_PAYMENT_001), 503); + } + + $payment = new Payment($user, 150000, $gatewayName, Payment::TYPE_APPOINTMENT, $frontendAddress); + $payment->setAppointment($appointment); + $this->paymentRepo->save($payment); + + $callbackUrl = $this->appBaseUrl . '/api/v1/payment/callback/' . $gatewayName . '?order_id=' . $payment->getOrderId(); + $result = $gateway->initiate($payment->getAmountRials(), $payment->getOrderId(), $callbackUrl); + + if (!$result->success) { + $this->circuitBreaker->recordFailure($gatewayName); + return $this->error(ErrorCodes::ERR_PAYMENT_001, $result->errorMessage, 503); + } + + $this->circuitBreaker->recordSuccess($gatewayName); + $payment->setGatewayToken($result->token); + $this->paymentRepo->save($payment); + + return $this->success([ + 'payment_uuid' => $payment->getUuid(), + 'redirect_url' => $result->redirectUrl, + 'order_id' => $payment->getOrderId(), + ]); + } + + // ── Payment Callback (public — no JWT) ─────────────────────────────────── + + #[Route('/api/v1/payment/callback/{gateway}', methods: ['POST', 'GET'])] + public function callback(string $gateway, Request $request): \Symfony\Component\HttpFoundation\Response + { + $clientIp = $request->getClientIp() ?? ''; + if (!$this->isAllowedCallbackIp($clientIp)) { + return new JsonResponse(['success' => false, 'message' => 'دسترسی ممنوع'], 403); + } + + $callbackData = array_merge($request->query->all(), $request->request->all()); + $orderId = $callbackData['order_id'] ?? $callbackData['ResNum'] ?? ''; + + $payment = $this->paymentRepo->findByOrderId($orderId); + if ($payment === null) { + return new JsonResponse(['success' => false, 'message' => 'payment not found'], 404); + } + + $payment->setCallbackIp($clientIp); + + $gw = $this->resolveGateway($gateway); + $result = $gw?->verify($callbackData) ?? null; + + if ($result === null || !$result->success) { + $payment->setStatus(Payment::STATUS_FAILED); + $this->paymentRepo->save($payment); + $this->circuitBreaker->recordFailure($gateway); + + return $this->redirectToFrontend($payment, false); + } + + $this->circuitBreaker->recordSuccess($gateway); + $payment->setStatus(Payment::STATUS_SUCCESS); + $payment->setReferenceId($result->referenceId); + $this->paymentRepo->save($payment); + + return $this->redirectToFrontend($payment, true); + } + + // ── Subscription Payment ────────────────────────────────────────────────── + + #[IsGranted('IS_AUTHENTICATED_FULLY')] + #[Route('/api/v1/subscription-payment', methods: ['POST'])] + public function initiateSubscription(Request $request, #[CurrentUser] User $user): JsonResponse + { + $data = json_decode($request->getContent(), true) ?? []; + $gatewayName = trim($data['gateway'] ?? 'mellat'); + $frontendAddress = trim($data['frontend_address'] ?? ''); + $amountRials = (int) ($data['amount_rials'] ?? 0); + + if ($amountRials <= 0) { + return $this->error(ErrorCodes::ERR_PAYMENT_002, ErrorCodes::message(ErrorCodes::ERR_PAYMENT_002), 422); + } + + if (!empty($frontendAddress) && !$this->isAllowedFrontend($frontendAddress)) { + return $this->error(ErrorCodes::ERR_VALIDATION_001, 'آدرس بازگشت مجاز نیست', 422, 'frontend_address'); + } + + $gateway = $this->resolveGateway($gatewayName); + if ($gateway === null) { + return $this->error(ErrorCodes::ERR_VALIDATION_001, 'درگاه پرداخت نامعتبر است', 422, 'gateway'); + } + + if ($this->circuitBreaker->isOpen($gatewayName)) { + return $this->error(ErrorCodes::ERR_PAYMENT_001, ErrorCodes::message(ErrorCodes::ERR_PAYMENT_001), 503); + } + + $payment = new Payment($user, $amountRials, $gatewayName, Payment::TYPE_SUBSCRIPTION, $frontendAddress); + $this->paymentRepo->save($payment); + + $callbackUrl = $this->appBaseUrl . '/api/v1/subscription-payment/callback/' . $gatewayName . '?order_id=' . $payment->getOrderId(); + $result = $gateway->initiate($amountRials, $payment->getOrderId(), $callbackUrl); + + if (!$result->success) { + $this->circuitBreaker->recordFailure($gatewayName); + return $this->error(ErrorCodes::ERR_PAYMENT_001, $result->errorMessage, 503); + } + + $this->circuitBreaker->recordSuccess($gatewayName); + $payment->setGatewayToken($result->token); + $this->paymentRepo->save($payment); + + return $this->success([ + 'payment_uuid' => $payment->getUuid(), + 'redirect_url' => $result->redirectUrl, + 'order_id' => $payment->getOrderId(), + ]); + } + + #[Route('/api/v1/subscription-payment/callback/{gateway}', methods: ['POST', 'GET'])] + public function subscriptionCallback(string $gateway, Request $request): \Symfony\Component\HttpFoundation\Response + { + return $this->callback($gateway, $request); + } + + // ── Status ──────────────────────────────────────────────────────────────── + + #[IsGranted('IS_AUTHENTICATED_FULLY')] + #[Route('/api/v1/payment/{uuid}', methods: ['GET'])] + public function getStatus(string $uuid, #[CurrentUser] User $user): JsonResponse + { + $payment = $this->paymentRepo->findByUuid($uuid); + if ($payment === null) { + return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'پرداخت یافت نشد', 404); + } + + if ($payment->getUser()->getId() !== $user->getId() && !$user->hasRole('ROLE_ADMIN')) { + return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403); + } + + return $this->success(['data' => $payment->toArray()]); + } + + // ── Private helpers ─────────────────────────────────────────────────────── + + private function resolveGateway(string $name): \App\Payment\Gateway\PaymentGatewayInterface|null + { + return match ($name) { + 'mellat' => $this->mellat, + 'sep' => $this->sep, + default => null, + }; + } + + private function isAllowedFrontend(string $url): bool + { + $hosts = array_filter(array_map('trim', explode(',', $this->allowedFrontendHosts))); + if (empty($hosts)) { + return false; + } + $host = parse_url($url, PHP_URL_HOST); + return in_array($host, $hosts, true); + } + + private function isAllowedCallbackIp(string $ip): bool + { + if (empty($ip)) { + return false; + } + foreach (self::ALLOWED_CALLBACK_IPS as $cidr) { + [$subnet, $maskBits] = explode('/', $cidr); + $maskBits = (int) $maskBits; + $ipLong = ip2long($ip); + $subnetLong = ip2long($subnet); + if ($ipLong === false || $subnetLong === false) { + continue; + } + $mask = -1 << (32 - $maskBits); + if (($ipLong & $mask) === ($subnetLong & $mask)) { + return true; + } + } + return false; + } + + private function redirectToFrontend(Payment $payment, bool $success): \Symfony\Component\HttpFoundation\Response + { + $base = $payment->getFrontendAddress(); + if (empty($base)) { + return new JsonResponse([ + 'success' => $success, + 'payment' => $payment->toArray(), + ]); + } + + $sep = str_contains($base, '?') ? '&' : '?'; + $url = $base . $sep . 'payment_uuid=' . $payment->getUuid() . '&status=' . $payment->getStatus(); + return new RedirectResponse($url); + } +} diff --git a/src/Payment/Entity/Payment.php b/src/Payment/Entity/Payment.php new file mode 100644 index 00000000..3b417a9d --- /dev/null +++ b/src/Payment/Entity/Payment.php @@ -0,0 +1,122 @@ +uuid = Uuid::v4()->toRfc4122(); + $this->orderId = 'ORD-' . strtoupper(substr(str_replace('-', '', Uuid::v4()->toRfc4122()), 0, 16)); + $this->user = $user; + $this->amountRials = $amountRials; + $this->gateway = $gateway; + $this->type = $type; + $this->frontendAddress = $frontendAddress ?: null; + $this->createdAt = time(); + $this->updatedAt = time(); + } + + public function getId(): ?int { return $this->id; } + public function getUuid(): string { return $this->uuid; } + public function getOrderId(): string { return $this->orderId; } + public function getUser(): User { return $this->user; } + public function getAppointment(): ?Appointment { return $this->appointment; } + public function getAmountRials(): int { return $this->amountRials; } + public function getStatus(): string { return $this->status; } + public function getGateway(): string { return $this->gateway; } + public function getType(): string { return $this->type; } + public function getGatewayToken(): ?string { return $this->gatewayToken; } + public function getReferenceId(): ?string { return $this->referenceId; } + public function getFrontendAddress(): ?string { return $this->frontendAddress; } + public function getCallbackIp(): ?string { return $this->callbackIp; } + + public function setAppointment(?Appointment $a): self { $this->appointment = $a; return $this; } + public function setGatewayToken(?string $t): self { $this->gatewayToken = $t; $this->touch(); return $this; } + public function setReferenceId(?string $r): self { $this->referenceId = $r; $this->touch(); return $this; } + public function setStatus(string $s): self { $this->status = $s; $this->touch(); return $this; } + public function setCallbackIp(?string $ip): self { $this->callbackIp = $ip; $this->touch(); return $this; } + + private function touch(): void { $this->updatedAt = time(); } + + public function toArray(): array + { + return [ + 'uuid' => $this->uuid, + 'order_id' => $this->orderId, + 'amount_rials' => $this->amountRials, + 'status' => $this->status, + 'gateway' => $this->gateway, + 'type' => $this->type, + 'reference_id' => $this->referenceId, + 'appointment_uuid' => $this->appointment?->getUuid(), + 'created_at' => $this->createdAt, + ]; + } +} diff --git a/src/Payment/Gateway/MellatGateway.php b/src/Payment/Gateway/MellatGateway.php new file mode 100644 index 00000000..957e7952 --- /dev/null +++ b/src/Payment/Gateway/MellatGateway.php @@ -0,0 +1,131 @@ +httpClient->request('POST', + 'https://bpm.shaparak.ir/pgwchannel/services/pgw?wsdl', [ + 'body' => $this->buildRequestPayload($amountRials, $orderId, $callbackUrl), + 'headers' => ['Content-Type' => 'text/xml; charset=utf-8'], + 'timeout' => 10, + ] + ); + + $resCode = $this->parseResCode($response->getContent()); + + if ($resCode !== '0') { + return new PaymentInitResult(false, errorMessage: "Mellat error: $resCode"); + } + + $refId = $this->parseRefId($response->getContent()); + $redirectUrl = self::PAYMENT_URL . '?RefId=' . $refId; + + return new PaymentInitResult(true, redirectUrl: $redirectUrl, token: $refId); + } catch (\Throwable $e) { + return new PaymentInitResult(false, errorMessage: $e->getMessage()); + } + } + + public function verify(array $callbackData): PaymentVerifyResult + { + $refId = $callbackData['RefId'] ?? ''; + $resCode = $callbackData['ResCode'] ?? ''; + + if ($resCode !== '0') { + return new PaymentVerifyResult(false, errorMessage: "Payment failed: $resCode"); + } + + try { + $response = $this->httpClient->request('POST', + 'https://bpm.shaparak.ir/pgwchannel/services/pgw?wsdl', [ + 'body' => $this->buildVerifyPayload($refId), + 'headers' => ['Content-Type' => 'text/xml; charset=utf-8'], + 'timeout' => 10, + ] + ); + + $verifyCode = $this->parseResCode($response->getContent()); + if ($verifyCode !== '0') { + return new PaymentVerifyResult(false, errorMessage: "Verify failed: $verifyCode"); + } + + return new PaymentVerifyResult(true, referenceId: $refId); + } catch (\Throwable $e) { + return new PaymentVerifyResult(false, errorMessage: $e->getMessage()); + } + } + + private function buildRequestPayload(int $amount, string $orderId, string $callbackUrl): string + { + return << + + + {$this->terminalId} + {$this->username} + {$this->password} + {$orderId} + {$amount} + {$this->date()} + {$this->time()} + + {$callbackUrl} + 0 + + + +XML; + } + + private function buildVerifyPayload(string $refId): string + { + return << + + + {$this->terminalId} + {$this->username} + {$this->password} + {$refId} + {$refId} + {$refId} + + + +XML; + } + + private function parseResCode(string $xml): string + { + preg_match('/(.*?)<\/return>/', $xml, $m); + $parts = explode(',', $m[1] ?? ''); + return trim($parts[0] ?? '-1'); + } + + private function parseRefId(string $xml): string + { + preg_match('/(.*?)<\/return>/', $xml, $m); + $parts = explode(',', $m[1] ?? ''); + return trim($parts[1] ?? ''); + } + + private function date(): string { return date('Ymd'); } + private function time(): string { return date('His'); } +} diff --git a/src/Payment/Gateway/PaymentGatewayInterface.php b/src/Payment/Gateway/PaymentGatewayInterface.php new file mode 100644 index 00000000..9b8b2ea9 --- /dev/null +++ b/src/Payment/Gateway/PaymentGatewayInterface.php @@ -0,0 +1,18 @@ +httpClient->request('POST', self::TOKEN_URL, [ + 'json' => [ + 'action' => 'token', + 'TerminalId' => $this->terminalId, + 'Amount' => $amountRials, + 'ResNum' => $orderId, + 'RedirectUrl' => $callbackUrl, + ], + 'timeout' => 10, + ]); + + $data = $response->toArray(); + if (($data['status'] ?? -1) !== 1) { + return new PaymentInitResult(false, errorMessage: $data['errorDesc'] ?? 'SEP error'); + } + + $token = $data['token']; + $redirectUrl = self::PAYMENT_URL . '?Token=' . $token . '&GetMethod=true'; + + return new PaymentInitResult(true, redirectUrl: $redirectUrl, token: $token); + } catch (\Throwable $e) { + return new PaymentInitResult(false, errorMessage: $e->getMessage()); + } + } + + public function verify(array $callbackData): PaymentVerifyResult + { + $state = $callbackData['State'] ?? ''; + if (strtolower($state) !== 'ok') { + return new PaymentVerifyResult(false, errorMessage: "Payment state: $state"); + } + + $refNum = $callbackData['RefNum'] ?? ''; + + try { + $response = $this->httpClient->request('POST', self::TOKEN_URL, [ + 'json' => [ + 'action' => 'verify', + 'TerminalId' => $this->terminalId, + 'RefNum' => $refNum, + ], + 'timeout' => 10, + ]); + + $data = $response->toArray(); + if (($data['TransactionDetail']['AffectiveAmount'] ?? 0) <= 0) { + return new PaymentVerifyResult(false, errorMessage: 'SEP verify failed'); + } + + return new PaymentVerifyResult( + true, + referenceId: $refNum, + amountRials: (int) $data['TransactionDetail']['AffectiveAmount'] + ); + } catch (\Throwable $e) { + return new PaymentVerifyResult(false, errorMessage: $e->getMessage()); + } + } +} diff --git a/src/Payment/Repository/PaymentRepository.php b/src/Payment/Repository/PaymentRepository.php new file mode 100644 index 00000000..b74ad2b1 --- /dev/null +++ b/src/Payment/Repository/PaymentRepository.php @@ -0,0 +1,31 @@ +findOneBy(['uuid' => $uuid]); + } + + public function findByOrderId(string $orderId): ?Payment + { + return $this->findOneBy(['orderId' => $orderId]); + } + + public function save(Payment $entity, bool $flush = true): void + { + $this->getEntityManager()->persist($entity); + if ($flush) $this->getEntityManager()->flush(); + } +} diff --git a/src/Payment/Service/CircuitBreakerService.php b/src/Payment/Service/CircuitBreakerService.php new file mode 100644 index 00000000..64c1a411 --- /dev/null +++ b/src/Payment/Service/CircuitBreakerService.php @@ -0,0 +1,42 @@ +cache->getItem('cb_open_' . $gateway); + return $item->isHit(); + } + + public function recordFailure(string $gateway): void + { + $countKey = 'cb_fail_' . $gateway; + $item = $this->cache->getItem($countKey); + $count = ($item->isHit() ? (int) $item->get() : 0) + 1; + + $item->set($count)->expiresAfter(self::OPEN_TTL); + $this->cache->save($item); + + if ($count >= self::FAIL_THRESHOLD) { + $openItem = $this->cache->getItem('cb_open_' . $gateway); + $openItem->set(true)->expiresAfter(self::OPEN_TTL); + $this->cache->save($openItem); + } + } + + public function recordSuccess(string $gateway): void + { + $this->cache->deleteItem('cb_fail_' . $gateway); + $this->cache->deleteItem('cb_open_' . $gateway); + } +} diff --git a/src/Rating/Controller/RatingController.php b/src/Rating/Controller/RatingController.php new file mode 100644 index 00000000..9dbcfd2c --- /dev/null +++ b/src/Rating/Controller/RatingController.php @@ -0,0 +1,188 @@ +getContent(), true) ?? []; + $doctorUuid = trim($data['doctor_uuid'] ?? ''); + $score = (int) ($data['score'] ?? 0); + + if ($score < 1 || $score > 5) { + return $this->error(ErrorCodes::ERR_VALIDATION_001, 'امتیاز باید بین ۱ تا ۵ باشد', 422); + } + + $doctor = $this->doctorRepo->findByUuid($doctorUuid); + if ($doctor === null) { + return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'دکتر یافت نشد', 404); + } + + $existing = $this->rateRepo->findByUserAndDoctor($user, $doctor); + if ($existing !== null) { + $existing->setScore($score); + $this->rateRepo->save($existing); + return $this->success(['data' => $existing->toArray()]); + } + + $rate = new Rate($user, $doctor, $score); + $this->rateRepo->save($rate); + + return $this->success(['data' => $rate->toArray()], 201); + } + + #[Route('/api/v1/rate/{doctorUuid}', methods: ['GET'])] + public function getAverage(string $doctorUuid): JsonResponse + { + $doctor = $this->doctorRepo->findByUuid($doctorUuid); + if ($doctor === null) { + return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'دکتر یافت نشد', 404); + } + + return $this->success(['average' => $this->rateRepo->getAverageScore($doctor)]); + } + + // ── Comments ────────────────────────────────────────────────────────────── + + #[IsGranted('IS_AUTHENTICATED_FULLY')] + #[Route('/api/v1/comment', methods: ['POST'])] + public function createComment(Request $request, #[CurrentUser] User $user): JsonResponse + { + $data = json_decode($request->getContent(), true) ?? []; + $doctorUuid = trim($data['doctor_uuid'] ?? ''); + $body = trim($data['body'] ?? ''); + + if (empty($body)) { + return $this->error(ErrorCodes::ERR_VALIDATION_002, 'متن نظر الزامی است', 422); + } + + $doctor = $this->doctorRepo->findByUuid($doctorUuid); + if ($doctor === null) { + return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'دکتر یافت نشد', 404); + } + + $comment = new Comment($user, $doctor, $body); + $this->commentRepo->save($comment); + + return $this->success(['data' => $comment->toArray()], 201); + } + + #[Route('/api/v1/comments/{doctorUuid}', methods: ['GET'])] + public function listComments(string $doctorUuid): JsonResponse + { + $doctor = $this->doctorRepo->findByUuid($doctorUuid); + if ($doctor === null) { + return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'دکتر یافت نشد', 404); + } + + $comments = array_map( + fn(Comment $c) => $c->toArray(), + $this->commentRepo->findApprovedByDoctor($doctor) + ); + + return $this->success(['data' => $comments]); + } + + #[IsGranted('IS_AUTHENTICATED_FULLY')] + #[Route('/api/v1/comment/{uuid}', methods: ['DELETE'])] + public function deleteComment(string $uuid, #[CurrentUser] User $user): JsonResponse + { + $comment = $this->commentRepo->findByUuid($uuid); + if ($comment === null) { + return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'نظر یافت نشد', 404); + } + + if ($comment->getUser()->getId() !== $user->getId() && !$user->hasRole('ROLE_ADMIN')) { + return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403); + } + + $this->commentRepo->remove($comment); + return $this->success(['message' => 'نظر با موفقیت حذف شد']); + } + + // ── Admin: comment moderation ───────────────────────────────────────────── + + #[IsGranted('ROLE_ADMIN')] + #[Route('/api/v1/admin/comments/pending', methods: ['GET'])] + public function pendingComments(): JsonResponse + { + $comments = array_map(fn(Comment $c) => $c->toArray(), $this->commentRepo->findPending()); + return $this->success(['data' => $comments]); + } + + #[IsGranted('ROLE_ADMIN')] + #[Route('/api/v1/admin/comment/{uuid}/approve', methods: ['POST'])] + public function approveComment(string $uuid): JsonResponse + { + $comment = $this->commentRepo->findByUuid($uuid); + if ($comment === null) { + return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'نظر یافت نشد', 404); + } + $comment->approve(); + $this->commentRepo->save($comment); + return $this->success(['data' => $comment->toArray()]); + } + + #[IsGranted('ROLE_ADMIN')] + #[Route('/api/v1/admin/comment/{uuid}/reject', methods: ['POST'])] + public function rejectComment(string $uuid): JsonResponse + { + $comment = $this->commentRepo->findByUuid($uuid); + if ($comment === null) { + return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'نظر یافت نشد', 404); + } + $comment->reject(); + $this->commentRepo->save($comment); + return $this->success(['data' => $comment->toArray()]); + } + + // ── Likes (toggle) ──────────────────────────────────────────────────────── + + #[IsGranted('IS_AUTHENTICATED_FULLY')] + #[Route('/api/v1/like/{commentUuid}', methods: ['POST'])] + public function toggleLike(string $commentUuid, #[CurrentUser] User $user): JsonResponse + { + $comment = $this->commentRepo->findByUuid($commentUuid); + if ($comment === null) { + return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'نظر یافت نشد', 404); + } + + $existing = $this->likeRepo->findByUserAndComment($user, $comment); + if ($existing !== null) { + $this->likeRepo->remove($existing); + return $this->success(['liked' => false, 'likes' => $comment->getLikes()->count() - 1]); + } + + $like = new Like($user, $comment); + $this->likeRepo->save($like); + return $this->success(['liked' => true, 'likes' => $comment->getLikes()->count()], 201); + } +} diff --git a/src/Rating/Entity/Comment.php b/src/Rating/Entity/Comment.php new file mode 100644 index 00000000..8f7e8447 --- /dev/null +++ b/src/Rating/Entity/Comment.php @@ -0,0 +1,88 @@ +uuid = Uuid::v4()->toRfc4122(); + $this->user = $user; + $this->doctor = $doctor; + $this->body = $body; + $this->likes = new ArrayCollection(); + $this->createdAt = time(); + $this->updatedAt = time(); + } + + public function getId(): ?int { return $this->id; } + public function getUuid(): string { return $this->uuid; } + public function getUser(): User { return $this->user; } + public function getDoctor(): Doctor { return $this->doctor; } + public function getBody(): string { return $this->body; } + public function getStatus(): string { return $this->status; } + public function getLikes(): Collection { return $this->likes; } + + public function setBody(string $v): self { $this->body = $v; $this->updatedAt = time(); return $this; } + + public function approve(): self { $this->status = self::STATUS_APPROVED; $this->updatedAt = time(); return $this; } + public function reject(): self { $this->status = self::STATUS_REJECTED; $this->updatedAt = time(); return $this; } + + public function toArray(): array + { + return [ + 'uuid' => $this->uuid, + 'doctor_uuid' => $this->doctor->getUuid(), + 'user_uuid' => $this->user->getUuid(), + 'body' => $this->body, + 'status' => $this->status, + 'likes' => $this->likes->count(), + 'created_at' => $this->createdAt, + ]; + } +} diff --git a/src/Rating/Entity/Like.php b/src/Rating/Entity/Like.php new file mode 100644 index 00000000..1129df34 --- /dev/null +++ b/src/Rating/Entity/Like.php @@ -0,0 +1,55 @@ +uuid = Uuid::v4()->toRfc4122(); + $this->user = $user; + $this->comment = $comment; + $this->createdAt = time(); + } + + public function getId(): ?int { return $this->id; } + public function getUuid(): string { return $this->uuid; } + public function getUser(): User { return $this->user; } + public function getComment(): Comment { return $this->comment; } + + public function toArray(): array + { + return [ + 'uuid' => $this->uuid, + 'comment_uuid' => $this->comment->getUuid(), + 'user_uuid' => $this->user->getUuid(), + 'created_at' => $this->createdAt, + ]; + } +} diff --git a/src/Rating/Entity/Rate.php b/src/Rating/Entity/Rate.php new file mode 100644 index 00000000..8b7f08dd --- /dev/null +++ b/src/Rating/Entity/Rate.php @@ -0,0 +1,67 @@ +uuid = Uuid::v4()->toRfc4122(); + $this->user = $user; + $this->doctor = $doctor; + $this->score = max(1, min(5, $score)); + $this->createdAt = time(); + $this->updatedAt = time(); + } + + public function getId(): ?int { return $this->id; } + public function getUuid(): string { return $this->uuid; } + public function getUser(): User { return $this->user; } + public function getDoctor(): Doctor { return $this->doctor; } + public function getScore(): int { return $this->score; } + + public function setScore(int $v): self { $this->score = max(1, min(5, $v)); $this->updatedAt = time(); return $this; } + + public function toArray(): array + { + return [ + 'uuid' => $this->uuid, + 'doctor_uuid' => $this->doctor->getUuid(), + 'score' => $this->score, + 'created_at' => $this->createdAt, + ]; + } +} diff --git a/src/Rating/Repository/CommentRepository.php b/src/Rating/Repository/CommentRepository.php new file mode 100644 index 00000000..c78ffbf2 --- /dev/null +++ b/src/Rating/Repository/CommentRepository.php @@ -0,0 +1,30 @@ +findOneBy(['uuid' => $uuid]); } + + /** @return Comment[] */ + public function findApprovedByDoctor(Doctor $doctor): array + { + return $this->findBy(['doctor' => $doctor, 'status' => Comment::STATUS_APPROVED], ['createdAt' => 'DESC']); + } + + /** @return Comment[] */ + public function findPending(): array + { + return $this->findBy(['status' => Comment::STATUS_PENDING], ['createdAt' => 'ASC']); + } + + public function save(Comment $e, bool $flush = true): void { $this->getEntityManager()->persist($e); if ($flush) $this->getEntityManager()->flush(); } + public function remove(Comment $e, bool $flush = true): void { $this->getEntityManager()->remove($e); if ($flush) $this->getEntityManager()->flush(); } +} diff --git a/src/Rating/Repository/LikeRepository.php b/src/Rating/Repository/LikeRepository.php new file mode 100644 index 00000000..25bfecc6 --- /dev/null +++ b/src/Rating/Repository/LikeRepository.php @@ -0,0 +1,19 @@ +findOneBy(['user' => $user, 'comment' => $comment]); } + + public function save(Like $e, bool $flush = true): void { $this->getEntityManager()->persist($e); if ($flush) $this->getEntityManager()->flush(); } + public function remove(Like $e, bool $flush = true): void { $this->getEntityManager()->remove($e); if ($flush) $this->getEntityManager()->flush(); } +} diff --git a/src/Rating/Repository/RateRepository.php b/src/Rating/Repository/RateRepository.php new file mode 100644 index 00000000..777adf95 --- /dev/null +++ b/src/Rating/Repository/RateRepository.php @@ -0,0 +1,28 @@ +findOneBy(['user' => $user, 'doctor' => $doctor]); } + + public function getAverageScore(Doctor $doctor): float + { + $result = $this->createQueryBuilder('r') + ->select('AVG(r.score) as avg, COUNT(r.id) as cnt') + ->where('r.doctor = :doctor') + ->setParameter('doctor', $doctor) + ->getQuery()->getSingleResult(); + return round((float)($result['avg'] ?? 0), 1); + } + + public function save(Rate $e, bool $flush = true): void { $this->getEntityManager()->persist($e); if ($flush) $this->getEntityManager()->flush(); } +} diff --git a/src/Repository/.gitignore b/src/Repository/.gitignore new file mode 100644 index 00000000..e69de29b diff --git a/src/Representation/Controller/RepresentationController.php b/src/Representation/Controller/RepresentationController.php new file mode 100644 index 00000000..9250aef7 --- /dev/null +++ b/src/Representation/Controller/RepresentationController.php @@ -0,0 +1,201 @@ +getContent(), true) ?? []; + $mobile = trim($data['mobile_number'] ?? ''); + $fullName = trim($data['full_name'] ?? ''); + + if (empty($mobile) || empty($fullName)) { + return $this->error(ErrorCodes::ERR_VALIDATION_002, 'mobile_number و full_name الزامی است', 422); + } + + $user = $this->userRepo->findByMobile($mobile); + if ($user === null) { + $user = new User($mobile); + $this->em->persist($user); + } + $user->addRole('ROLE_REPRESENTATION'); + $this->em->flush(); + + if ($this->representationRepo->findByUser($user) !== null) { + return $this->error(ErrorCodes::ERR_CONFLICT_001, 'این کاربر قبلاً نماینده است', 409); + } + + $rep = new Representation($user, $fullName); + if (!empty($data['city'])) $rep->setCity($data['city']); + if (!empty($data['commission_percent'])) $rep->setCommissionPercent((string)$data['commission_percent']); + if (!empty($data['bank_account'])) $rep->setBankAccount($data['bank_account']); + + $this->representationRepo->save($rep); + + return $this->success(['data' => $rep->toArray()], 201); + } + + #[Route('/api/v1/representation/{uuid}', methods: ['GET'])] + public function get(string $uuid, #[CurrentUser] User $user): JsonResponse + { + $rep = $this->representationRepo->findByUuid($uuid); + if ($rep === null) { + return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'نماینده یافت نشد', 404); + } + + if ($rep->getUser()->getId() !== $user->getId() && !$user->hasRole('ROLE_ADMIN')) { + return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403); + } + + return $this->success(['data' => $rep->toArray()]); + } + + #[Route('/api/v1/representation/{uuid}', methods: ['PATCH'])] + public function update(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse + { + $rep = $this->representationRepo->findByUuid($uuid); + if ($rep === null) { + return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'نماینده یافت نشد', 404); + } + + if ($rep->getUser()->getId() !== $user->getId() && !$user->hasRole('ROLE_ADMIN')) { + return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403); + } + + $data = json_decode($request->getContent(), true) ?? []; + if (array_key_exists('full_name', $data)) $rep->setFullName($data['full_name']); + if (array_key_exists('city', $data)) $rep->setCity($data['city']); + if (array_key_exists('bank_account', $data)) $rep->setBankAccount($data['bank_account']); + if (array_key_exists('commission_percent', $data)) $rep->setCommissionPercent((string)$data['commission_percent']); + if (array_key_exists('active', $data)) $rep->setActive((bool)$data['active']); + + $this->representationRepo->save($rep); + + return $this->success(['data' => $rep->toArray()]); + } + + #[IsGranted('ROLE_ADMIN')] + #[Route('/api/v1/representation/{uuid}', methods: ['DELETE'])] + public function delete(string $uuid): JsonResponse + { + $rep = $this->representationRepo->findByUuid($uuid); + if ($rep === null) { + return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'نماینده یافت نشد', 404); + } + + $this->representationRepo->remove($rep); + return $this->success(['message' => 'نماینده با موفقیت حذف شد']); + } + + // ── Dashboard: monthly stats ────────────────────────────────────────────── + + #[Route('/api/v1/representation/{uuid}/dashboard/monthly', methods: ['GET'])] + public function dashboardMonthly(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse + { + $rep = $this->representationRepo->findByUuid($uuid); + if ($rep === null) { + return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'نماینده یافت نشد', 404); + } + + if ($rep->getUser()->getId() !== $user->getId() && !$user->hasRole('ROLE_ADMIN')) { + return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403); + } + + $jYear = (int) ($request->query->get('year', $this->jalali->jalaliYear(time()))); + $jMonth = (int) ($request->query->get('month', $this->jalali->jalaliMonth(time()))); + + [$startTs, $endTs] = $this->jalali->jalaliMonthRange($jYear, $jMonth); + + return $this->success([ + 'period' => ['jalali_year' => $jYear, 'jalali_month' => $jMonth], + 'stats' => $this->buildStats($startTs, $endTs), + ]); + } + + #[Route('/api/v1/representation/{uuid}/dashboard/yearly', methods: ['GET'])] + public function dashboardYearly(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse + { + $rep = $this->representationRepo->findByUuid($uuid); + if ($rep === null) { + return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'نماینده یافت نشد', 404); + } + + if ($rep->getUser()->getId() !== $user->getId() && !$user->hasRole('ROLE_ADMIN')) { + return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403); + } + + $jYear = (int) ($request->query->get('year', $this->jalali->jalaliYear(time()))); + + $months = []; + for ($m = 1; $m <= 12; $m++) { + [$mStart, $mEnd] = $this->jalali->jalaliMonthRange($jYear, $m); + $months[] = [ + 'jalali_month' => $m, + 'stats' => $this->buildStats($mStart, $mEnd), + ]; + } + + [$startTs, $endTs] = $this->jalali->jalaliYearRange($jYear); + + return $this->success([ + 'period' => ['jalali_year' => $jYear], + 'months' => $months, + 'totals' => $this->buildStats($startTs, $endTs), + ]); + } + + // ── Private ─────────────────────────────────────────────────────────────── + + private function buildStats(int $startTs, int $endTs): array + { + $totalPayments = (int) $this->em->createQuery( + 'SELECT COUNT(p.id) FROM App\Payment\Entity\Payment p + WHERE p.status = :status AND p.createdAt BETWEEN :start AND :end' + )->setParameters(['status' => 'success', 'start' => $startTs, 'end' => $endTs]) + ->getSingleScalarResult(); + + $totalRevenue = (int) ($this->em->createQuery( + 'SELECT SUM(p.amountRials) FROM App\Payment\Entity\Payment p + WHERE p.status = :status AND p.createdAt BETWEEN :start AND :end' + )->setParameters(['status' => 'success', 'start' => $startTs, 'end' => $endTs]) + ->getSingleScalarResult() ?? 0); + + $totalAppointments = (int) $this->em->createQuery( + 'SELECT COUNT(a.id) FROM App\Appointment\Entity\Appointment a + WHERE a.createdAt BETWEEN :start AND :end' + )->setParameters(['start' => $startTs, 'end' => $endTs])->getSingleScalarResult(); + + return [ + 'total_payments' => $totalPayments, + 'total_revenue_rials' => $totalRevenue, + 'total_appointments' => $totalAppointments, + ]; + } +} diff --git a/src/Representation/Entity/Representation.php b/src/Representation/Entity/Representation.php new file mode 100644 index 00000000..72c2027a --- /dev/null +++ b/src/Representation/Entity/Representation.php @@ -0,0 +1,90 @@ +uuid = Uuid::v4()->toRfc4122(); + $this->user = $user; + $this->fullName = $fullName; + $this->createdAt = time(); + $this->updatedAt = time(); + } + + public function getId(): ?int { return $this->id; } + public function getUuid(): string { return $this->uuid; } + public function getUser(): User { return $this->user; } + public function getFullName(): string { return $this->fullName; } + public function getMobileNumber(): ?string { return $this->mobileNumber; } + public function getCity(): ?string { return $this->city; } + public function getCommissionPercent(): string { return $this->commissionPercent; } + public function getBankAccount(): ?array { return $this->bankAccount; } + public function isActive(): bool { return $this->active; } + + public function setFullName(string $v): self { $this->fullName = $v; $this->touch(); return $this; } + public function setMobileNumber(?string $v): self { $this->mobileNumber = $v; $this->touch(); return $this; } + public function setCity(?string $v): self { $this->city = $v; $this->touch(); return $this; } + public function setCommissionPercent(string $v): self { $this->commissionPercent = $v; $this->touch(); return $this; } + public function setBankAccount(?array $v): self { $this->bankAccount = $v; $this->touch(); return $this; } + public function setActive(bool $v): self { $this->active = $v; $this->touch(); return $this; } + + private function touch(): void { $this->updatedAt = time(); } + + public function toArray(): array + { + return [ + 'uuid' => $this->uuid, + 'full_name' => $this->fullName, + 'mobile_number' => $this->mobileNumber, + 'city' => $this->city, + 'commission_percent' => $this->commissionPercent, + 'bank_account' => $this->bankAccount, + 'active' => $this->active, + 'created_at' => $this->createdAt, + ]; + } +} diff --git a/src/Representation/Repository/RepresentationRepository.php b/src/Representation/Repository/RepresentationRepository.php new file mode 100644 index 00000000..dbacced1 --- /dev/null +++ b/src/Representation/Repository/RepresentationRepository.php @@ -0,0 +1,38 @@ +findOneBy(['uuid' => $uuid]); + } + + public function findByUser(User $user): ?Representation + { + return $this->findOneBy(['user' => $user]); + } + + public function save(Representation $entity, bool $flush = true): void + { + $this->getEntityManager()->persist($entity); + if ($flush) $this->getEntityManager()->flush(); + } + + public function remove(Representation $entity, bool $flush = true): void + { + $this->getEntityManager()->remove($entity); + if ($flush) $this->getEntityManager()->flush(); + } +} diff --git a/src/Representation/Service/JalaliDateService.php b/src/Representation/Service/JalaliDateService.php new file mode 100644 index 00000000..010699e9 --- /dev/null +++ b/src/Representation/Service/JalaliDateService.php @@ -0,0 +1,112 @@ +format('Y'), (int)$date->format('m'), (int)$date->format('d')]; + return $this->gregorianToJalali($gy, $gm, $gd); + } + + /** Returns [year, month, day] in Jalali */ + public function gregorianToJalali(int $gy, int $gm, int $gd): array + { + $g_d_no = 365 * $gy + (int)(($gy + 3) / 4) - (int)(($gy + 99) / 100) + (int)(($gy + 399) / 400); + $g_days = [0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334]; + for ($i = 1; $i < $gm; $i++) $g_d_no += $g_days[$i]; + if ($gm > 2 && (($gy % 4 === 0 && $gy % 100 !== 0) || ($gy % 400 === 0))) $g_d_no++; + + $j_d_no = $g_d_no - 79; + $j_np = (int)($j_d_no / 12053); + $j_d_no %= 12053; + $jy = 979 + 33 * $j_np + 4 * (int)($j_d_no / 1461); + $j_d_no %= 1461; + + if ($j_d_no >= 366) { + $jy += (int)(($j_d_no - 1) / 365); + $j_d_no = ($j_d_no - 1) % 365; + } + + $j_days = [0, 31, 31, 31, 31, 31, 31, 30, 30, 30, 30, 30, 29]; + $jm = 0; + for ($i = 1; $i <= 12; $i++) { + if ($j_d_no < $j_days[$i]) { $jm = $i; break; } + $j_d_no -= $j_days[$i]; + } + $jd = $j_d_no + 1; + + return [$jy, $jm, $jd]; + } + + public function jalaliYear(int $timestamp): int + { + return $this->gregorianToJalali( + (int)date('Y', $timestamp), + (int)date('m', $timestamp), + (int)date('d', $timestamp) + )[0]; + } + + public function jalaliMonth(int $timestamp): int + { + return $this->gregorianToJalali( + (int)date('Y', $timestamp), + (int)date('m', $timestamp), + (int)date('d', $timestamp) + )[1]; + } + + /** Returns [startTs, endTs] for a given Jalali month/year */ + public function jalaliMonthRange(int $jYear, int $jMonth): array + { + // Convert first day of Jalali month to Gregorian + $start = $this->jalaliToGregorian($jYear, $jMonth, 1); + $daysInMonth = $jMonth <= 6 ? 31 : ($jMonth <= 11 ? 30 : 29); + $end = $this->jalaliToGregorian($jYear, $jMonth, $daysInMonth); + + $startTs = mktime(0, 0, 0, $start[1], $start[2], $start[0]); + $endTs = mktime(23, 59, 59, $end[1], $end[2], $end[0]); + + return [$startTs, $endTs]; + } + + public function jalaliYearRange(int $jYear): array + { + $start = $this->jalaliToGregorian($jYear, 1, 1); + $end = $this->jalaliToGregorian($jYear, 12, 29); + + return [ + mktime(0, 0, 0, $start[1], $start[2], $start[0]), + mktime(23, 59, 59, $end[1], $end[2], $end[0]), + ]; + } + + public function jalaliToGregorian(int $jy, int $jm, int $jd): array + { + $jy += 1595; + $days = -355779 + 365 * $jy + (int)($jy / 33) * 8 + (int)((($jy % 33) + 3) / 4) + $jd; + $jm_days = [0, 31, 62, 93, 124, 155, 186, 216, 246, 276, 306, 336]; + $days += $jm_days[$jm - 1]; + + $gy = 400 * (int)($days / 146097); + $days %= 146097; + if ($days > 36524) { $gy += 100 * (int)(--$days / 36524); $days %= 36524; if ($days >= 365) $days++; } + $gy += 4 * (int)($days / 1461); + $days %= 1461; + if ($days > 365) { $gy += (int)(($days - 1) / 365); $days = ($days - 1) % 365; } + $gd = $days + 1; + $gm_days = [0, 31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; + $gm = 0; + for ($i = 1; $i <= 12; $i++) { + if ($gd <= $gm_days[$i]) { $gm = $i; break; } + $gd -= $gm_days[$i]; + } + + return [$gy, $gm, $gd]; + } +} diff --git a/src/Secretary/Controller/SecretaryController.php b/src/Secretary/Controller/SecretaryController.php new file mode 100644 index 00000000..4df3e338 --- /dev/null +++ b/src/Secretary/Controller/SecretaryController.php @@ -0,0 +1,180 @@ +getContent(), true) ?? []; + $doctorUuid = trim($data['doctor_uuid'] ?? ''); + $mobile = trim($data['mobile_number'] ?? ''); + + if (empty($doctorUuid) || empty($mobile)) { + return $this->error(ErrorCodes::ERR_VALIDATION_001, 'doctor_uuid و mobile_number الزامی است', 422); + } + + $doctor = $this->doctorRepo->findByUuid($doctorUuid); + if ($doctor === null) { + return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دکتر یافت نشد', 404); + } + + // Only the doctor owner or admin can create secretary + if ($doctor->getUser()->getId() !== $currentUser->getId() && !$currentUser->hasRole('ROLE_ADMIN')) { + return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403); + } + + // Check plan limit + $activeCount = $this->secretaryRepo->countActiveByDoctor($doctor); + if ($activeCount >= self::MAX_SECRETARIES) { + return $this->error(ErrorCodes::ERR_SECRETARY_001, ErrorCodes::message(ErrorCodes::ERR_SECRETARY_001), 422); + } + + // Find or create secretary user + $secretaryUser = $this->userRepo->findByMobile($mobile); + if ($secretaryUser === null) { + $secretaryUser = new User($mobile); + // Set a temporary password if provided + if (!empty($data['password'])) { + $hash = $this->hasher->hashPassword($secretaryUser, $data['password']); + $secretaryUser->setPasswordHash($hash); + } + } + + // Assign ROLE_SECRETARY + $roles = $secretaryUser->getRoles(); + if (!in_array('ROLE_SECRETARY', $roles, true)) { + $roles[] = 'ROLE_SECRETARY'; + $secretaryUser->setRoles(array_values(array_unique($roles))); + } + $this->userRepo->save($secretaryUser); + + // Check duplicate + $existing = $this->secretaryRepo->findOneBy(['doctor' => $doctor, 'secretary' => $secretaryUser]); + if ($existing !== null) { + return $this->error(ErrorCodes::ERR_VALIDATION_001, 'این منشی قبلاً اضافه شده است', 409); + } + + $secretary = new DoctorSecretary($doctor, $secretaryUser); + + // Apply custom permissions if provided + if (!empty($data['permissions'])) { + $secretary->mergePermissions($data['permissions']); + } + + $this->secretaryRepo->save($secretary); + + return $this->success(['data' => $secretary->toArray()], 201); + } + + #[Route('/api/v1/secretary/{uuid}', methods: ['GET'])] + public function show(string $uuid, #[CurrentUser] User $currentUser): JsonResponse + { + $secretary = $this->secretaryRepo->findByUuid($uuid); + if ($secretary === null) { + return $this->error(ErrorCodes::ERR_VALIDATION_002, 'منشی یافت نشد', 404); + } + + if (!$this->canManage($secretary, $currentUser)) { + return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403); + } + + return $this->success(['data' => $secretary->toArray()]); + } + + #[Route('/api/v1/secretary/{uuid}', methods: ['PATCH'])] + public function update(string $uuid, Request $request, #[CurrentUser] User $currentUser): JsonResponse + { + $secretary = $this->secretaryRepo->findByUuid($uuid); + if ($secretary === null) { + return $this->error(ErrorCodes::ERR_VALIDATION_002, 'منشی یافت نشد', 404); + } + + if (!$this->canManage($secretary, $currentUser)) { + return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403); + } + + $data = json_decode($request->getContent(), true) ?? []; + + if (array_key_exists('active', $data)) { + $secretary->setActive((bool) $data['active']); + } + + if (!empty($data['permissions'])) { + $secretary->mergePermissions($data['permissions']); + } + + $this->secretaryRepo->save($secretary); + + return $this->success(['data' => $secretary->toArray()]); + } + + #[Route('/api/v1/secretary/{uuid}', methods: ['DELETE'])] + public function delete(string $uuid, #[CurrentUser] User $currentUser): JsonResponse + { + $secretary = $this->secretaryRepo->findByUuid($uuid); + if ($secretary === null) { + return $this->error(ErrorCodes::ERR_VALIDATION_002, 'منشی یافت نشد', 404); + } + + if (!$this->canManage($secretary, $currentUser)) { + return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403); + } + + $this->secretaryRepo->remove($secretary); + + return $this->success(['message' => 'منشی با موفقیت حذف شد']); + } + + #[Route('/api/v1/secretaries/{doctorUuid}', methods: ['GET'])] + public function list(string $doctorUuid, #[CurrentUser] User $currentUser): JsonResponse + { + $doctor = $this->doctorRepo->findByUuid($doctorUuid); + if ($doctor === null) { + return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دکتر یافت نشد', 404); + } + + if ($doctor->getUser()->getId() !== $currentUser->getId() && !$currentUser->hasRole('ROLE_ADMIN')) { + return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403); + } + + $secretaries = array_map( + fn(DoctorSecretary $s) => $s->toArray(), + $this->secretaryRepo->findByDoctor($doctor) + ); + + return $this->success(['data' => $secretaries]); + } + + private function canManage(DoctorSecretary $secretary, User $user): bool + { + return $secretary->getDoctor()->getUser()->getId() === $user->getId() + || $user->hasRole('ROLE_ADMIN'); + } +} diff --git a/src/Secretary/Entity/DoctorSecretary.php b/src/Secretary/Entity/DoctorSecretary.php new file mode 100644 index 00000000..66250458 --- /dev/null +++ b/src/Secretary/Entity/DoctorSecretary.php @@ -0,0 +1,116 @@ + 1, + 'resources' => [ + 'appointments' => ['view' => true, 'create' => true, 'cancel' => false, 'update_status' => true], + 'addresses' => ['view' => true, 'create' => false, 'update' => false, 'delete' => false], + 'clinic_info' => ['view' => true, 'update' => false], + 'insurances' => ['view' => true, 'create' => false, 'update' => false, 'delete' => false], + ], + ]; + + #[ORM\Id] + #[ORM\GeneratedValue] + #[ORM\Column(type: 'integer')] + private ?int $id = null; + + #[ORM\Column(type: 'string', length: 36, unique: true)] + private string $uuid; + + #[ORM\ManyToOne(targetEntity: Doctor::class)] + #[ORM\JoinColumn(name: 'doctor_id', referencedColumnName: 'id', nullable: false, onDelete: 'CASCADE')] + private Doctor $doctor; + + #[ORM\ManyToOne(targetEntity: User::class)] + #[ORM\JoinColumn(name: 'secretary_id', referencedColumnName: 'id', nullable: false)] + private User $secretary; + + #[ORM\Column(name: 'permission', type: 'json', nullable: true)] + private ?array $permissions = null; + + #[ORM\Column(type: 'boolean')] + private bool $active = true; + + #[ORM\Column(name: 'created_at', type: 'integer')] + private int $createdAt; + + #[ORM\Column(name: 'updated_at', type: 'integer')] + private int $updatedAt; + + public function __construct(Doctor $doctor, User $secretary) + { + $this->uuid = Uuid::v4()->toRfc4122(); + $this->doctor = $doctor; + $this->secretary = $secretary; + $this->permissions = self::DEFAULT_PERMISSIONS; + $this->createdAt = time(); + $this->updatedAt = time(); + } + + public function getId(): ?int { return $this->id; } + public function getUuid(): string { return $this->uuid; } + public function getDoctor(): Doctor { return $this->doctor; } + public function getSecretary(): User { return $this->secretary; } + public function getPermissions(): array { return $this->permissions ?? self::DEFAULT_PERMISSIONS; } + public function isActive(): bool { return $this->active; } + public function getCreatedAt(): int { return $this->createdAt; } + + public function setActive(bool $v): self { $this->active = $v; $this->touch(); return $this; } + public function setPermissions(array $v): self { $this->permissions = $v; $this->touch(); return $this; } + + /** Deep merge: only provided resources/actions are updated */ + public function mergePermissions(array $patch): void + { + $current = $this->getPermissions(); + + if (isset($patch['resources']) && is_array($patch['resources'])) { + foreach ($patch['resources'] as $resource => $actions) { + if (!is_array($actions)) continue; + foreach ($actions as $action => $value) { + $current['resources'][$resource][$action] = (bool) $value; + } + } + } + if (isset($patch['version'])) { + $current['version'] = (int) $patch['version']; + } + + $this->permissions = $current; + $this->touch(); + } + + private function touch(): void { $this->updatedAt = time(); } + + public function toArray(): array + { + return [ + 'uuid' => $this->uuid, + 'user' => [ + 'uuid' => $this->secretary->getUuid(), + 'realname' => $this->secretary->getRealName(), + 'mobile' => $this->secretary->getMobileNumber(), + 'picture' => null, + ], + 'doctor' => [ + 'uuid' => $this->doctor->getUuid(), + 'name' => $this->doctor->getName(), + ], + 'active' => $this->active, + 'permissions' => $this->getPermissions(), + 'created_at' => $this->createdAt, + ]; + } +} diff --git a/src/Secretary/Repository/DoctorSecretaryRepository.php b/src/Secretary/Repository/DoctorSecretaryRepository.php new file mode 100644 index 00000000..76b3407c --- /dev/null +++ b/src/Secretary/Repository/DoctorSecretaryRepository.php @@ -0,0 +1,54 @@ +findOneBy(['uuid' => $uuid]); + } + + public function countActiveByDoctor(Doctor $doctor): int + { + return (int) $this->createQueryBuilder('s') + ->select('COUNT(s.id)') + ->where('s.doctor = :doctor') + ->andWhere('s.active = true') + ->setParameter('doctor', $doctor) + ->getQuery() + ->getSingleScalarResult(); + } + + /** @return DoctorSecretary[] */ + public function findByDoctor(Doctor $doctor): array + { + return $this->findBy(['doctor' => $doctor], ['createdAt' => 'DESC']); + } + + public function save(DoctorSecretary $entity, bool $flush = true): void + { + $this->getEntityManager()->persist($entity); + if ($flush) { + $this->getEntityManager()->flush(); + } + } + + public function remove(DoctorSecretary $entity, bool $flush = true): void + { + $this->getEntityManager()->remove($entity); + if ($flush) { + $this->getEntityManager()->flush(); + } + } +} diff --git a/src/Secretary/Security/SecretaryPermissionChecker.php b/src/Secretary/Security/SecretaryPermissionChecker.php new file mode 100644 index 00000000..f8679895 --- /dev/null +++ b/src/Secretary/Security/SecretaryPermissionChecker.php @@ -0,0 +1,28 @@ +isActive()) { + return false; + } + + $permissions = $secretary->getPermissions(); + + return (bool) ($permissions['resources'][$resource][$action] ?? false); + } + + public function canAll(DoctorSecretary $secretary, string $resource, array $actions): bool + { + return array_reduce( + $actions, + fn(bool $carry, string $action) => $carry && $this->can($secretary, $resource, $action), + true + ); + } +} diff --git a/src/Settlement/Controller/SettlementController.php b/src/Settlement/Controller/SettlementController.php new file mode 100644 index 00000000..56914466 --- /dev/null +++ b/src/Settlement/Controller/SettlementController.php @@ -0,0 +1,166 @@ +settlementRepo->getWalletBalance($user); + $transactions = array_map( + fn(WalletTransaction $t) => $t->toArray(), + $this->walletRepo->findByUser($user, 10) + ); + + return $this->success([ + 'balance_rials' => $balance, + 'recent_transactions' => $transactions, + ]); + } + + #[Route('/api/v1/wallet/transactions', methods: ['GET'])] + public function transactions(#[CurrentUser] User $user): JsonResponse + { + $transactions = array_map( + fn(WalletTransaction $t) => $t->toArray(), + $this->walletRepo->findByUser($user) + ); + + return $this->success(['data' => $transactions]); + } + + // ── Settlement Requests ─────────────────────────────────────────────────── + + #[Route('/api/v1/settlement', methods: ['POST'])] + public function request(Request $request, #[CurrentUser] User $user): JsonResponse + { + $data = json_decode($request->getContent(), true) ?? []; + $amountRials = (int) ($data['amount_rials'] ?? 0); + $bankAccount = $data['bank_account'] ?? null; + + if ($amountRials <= 0) { + return $this->error(ErrorCodes::ERR_VALIDATION_001, 'مبلغ برداشت باید بیشتر از صفر باشد', 422); + } + + $balance = $this->settlementRepo->getWalletBalance($user); + if ($amountRials > $balance) { + return $this->error(ErrorCodes::ERR_CONFLICT_001, 'موجودی کافی نیست', 422); + } + + $settlement = new Settlement($user, $amountRials, $bankAccount); + $this->settlementRepo->save($settlement); + + // Reserve amount by debit transaction + $tx = new WalletTransaction($user, $amountRials, WalletTransaction::TYPE_DEBIT, $balance - $amountRials); + $tx->setDescription('درخواست برداشت ' . $settlement->getUuid()); + $this->walletRepo->save($tx); + + return $this->success(['data' => $settlement->toArray()], 201); + } + + #[Route('/api/v1/settlement', methods: ['GET'])] + public function listMine(#[CurrentUser] User $user): JsonResponse + { + $settlements = array_map( + fn(Settlement $s) => $s->toArray(), + $this->settlementRepo->findByUser($user) + ); + + return $this->success(['data' => $settlements]); + } + + #[Route('/api/v1/settlement/{uuid}', methods: ['GET'])] + public function get(string $uuid, #[CurrentUser] User $user): JsonResponse + { + $settlement = $this->settlementRepo->findByUuid($uuid); + if ($settlement === null) { + return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'درخواست یافت نشد', 404); + } + + if ($settlement->getUser()->getId() !== $user->getId() && !$user->hasRole('ROLE_ADMIN')) { + return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403); + } + + return $this->success(['data' => $settlement->toArray()]); + } + + // ── Admin Actions ───────────────────────────────────────────────────────── + + #[IsGranted('ROLE_ADMIN')] + #[Route('/api/v1/settlement/{uuid}/approve', methods: ['POST'])] + public function approve(string $uuid, Request $request, #[CurrentUser] User $admin): JsonResponse + { + $settlement = $this->settlementRepo->findByUuid($uuid); + if ($settlement === null) { + return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'درخواست یافت نشد', 404); + } + + if ($settlement->getStatus() !== Settlement::STATUS_PENDING) { + return $this->error(ErrorCodes::ERR_VALIDATION_001, 'این درخواست قابل تأیید نیست', 422); + } + + $data = json_decode($request->getContent(), true) ?? []; + $settlement->approve($admin->getId(), $data['note'] ?? null); + $this->settlementRepo->save($settlement); + + return $this->success(['data' => $settlement->toArray()]); + } + + #[IsGranted('ROLE_ADMIN')] + #[Route('/api/v1/settlement/{uuid}/reject', methods: ['POST'])] + public function reject(string $uuid, Request $request, #[CurrentUser] User $admin): JsonResponse + { + $settlement = $this->settlementRepo->findByUuid($uuid); + if ($settlement === null) { + return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'درخواست یافت نشد', 404); + } + + if ($settlement->getStatus() !== Settlement::STATUS_PENDING) { + return $this->error(ErrorCodes::ERR_VALIDATION_001, 'این درخواست قابل رد نیست', 422); + } + + $data = json_decode($request->getContent(), true) ?? []; + $note = trim($data['note'] ?? ''); + if (empty($note)) { + return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دلیل رد الزامی است', 422); + } + + $settlement->reject($admin->getId(), $note); + $this->settlementRepo->save($settlement); + + // Refund the reserved amount back to wallet + $balance = $this->settlementRepo->getWalletBalance($settlement->getUser()); + $tx = new WalletTransaction( + $settlement->getUser(), + $settlement->getAmountRials(), + WalletTransaction::TYPE_CREDIT, + $balance + $settlement->getAmountRials() + ); + $tx->setDescription('برگشت برداشت رد شده ' . $settlement->getUuid()); + $this->walletRepo->save($tx); + + return $this->success(['data' => $settlement->toArray()]); + } +} diff --git a/src/Settlement/Entity/Settlement.php b/src/Settlement/Entity/Settlement.php new file mode 100644 index 00000000..9cba6f0e --- /dev/null +++ b/src/Settlement/Entity/Settlement.php @@ -0,0 +1,112 @@ +uuid = Uuid::v4()->toRfc4122(); + $this->user = $user; + $this->amountRials = $amountRials; + $this->bankAccount = $bankAccount; + $this->createdAt = time(); + $this->updatedAt = time(); + } + + public function getId(): ?int { return $this->id; } + public function getUuid(): string { return $this->uuid; } + public function getUser(): User { return $this->user; } + public function getAmountRials(): int { return $this->amountRials; } + public function getStatus(): string { return $this->status; } + public function getBankAccount(): ?array { return $this->bankAccount; } + public function getAdminNote(): ?string { return $this->adminNote; } + + public function approve(int $adminUserId, ?string $note = null): self + { + $this->status = self::STATUS_APPROVED; + $this->reviewedBy = $adminUserId; + $this->reviewedAt = time(); + $this->adminNote = $note; + $this->updatedAt = time(); + return $this; + } + + public function reject(int $adminUserId, string $note): self + { + $this->status = self::STATUS_REJECTED; + $this->reviewedBy = $adminUserId; + $this->reviewedAt = time(); + $this->adminNote = $note; + $this->updatedAt = time(); + return $this; + } + + public function markPaid(): self + { + $this->status = self::STATUS_PAID; + $this->updatedAt = time(); + return $this; + } + + public function toArray(): array + { + return [ + 'uuid' => $this->uuid, + 'amount_rials' => $this->amountRials, + 'status' => $this->status, + 'bank_account' => $this->bankAccount, + 'admin_note' => $this->adminNote, + 'reviewed_at' => $this->reviewedAt, + 'created_at' => $this->createdAt, + ]; + } +} diff --git a/src/Settlement/Entity/WalletTransaction.php b/src/Settlement/Entity/WalletTransaction.php new file mode 100644 index 00000000..ad828ab9 --- /dev/null +++ b/src/Settlement/Entity/WalletTransaction.php @@ -0,0 +1,82 @@ +uuid = Uuid::v4()->toRfc4122(); + $this->user = $user; + $this->amountRials = $amountRials; + $this->type = $type; + $this->balanceAfter = $balanceAfter; + $this->createdAt = time(); + } + + public function getId(): ?int { return $this->id; } + public function getUuid(): string { return $this->uuid; } + public function getUser(): User { return $this->user; } + public function getPayment(): ?Payment { return $this->payment; } + public function getAmountRials(): int { return $this->amountRials; } + public function getType(): string { return $this->type; } + public function getDescription(): ?string { return $this->description; } + public function getBalanceAfter(): int { return $this->balanceAfter; } + + public function setPayment(?Payment $p): self { $this->payment = $p; return $this; } + public function setDescription(?string $d): self { $this->description = $d; return $this; } + + public function toArray(): array + { + return [ + 'uuid' => $this->uuid, + 'amount_rials' => $this->amountRials, + 'type' => $this->type, + 'description' => $this->description, + 'balance_after' => $this->balanceAfter, + 'created_at' => $this->createdAt, + ]; + } +} diff --git a/src/Settlement/Repository/SettlementRepository.php b/src/Settlement/Repository/SettlementRepository.php new file mode 100644 index 00000000..adcdd8f7 --- /dev/null +++ b/src/Settlement/Repository/SettlementRepository.php @@ -0,0 +1,51 @@ +findOneBy(['uuid' => $uuid]); + } + + /** @return Settlement[] */ + public function findByUser(User $user): array + { + return $this->findBy(['user' => $user], ['createdAt' => 'DESC']); + } + + /** Balance = sum of credits - sum of debits from wallet_transactions */ + public function getWalletBalance(User $user): int + { + $em = $this->getEntityManager(); + + $credit = (int) ($em->createQuery( + 'SELECT SUM(w.amountRials) FROM App\Settlement\Entity\WalletTransaction w + WHERE w.user = :user AND w.type = :type' + )->setParameters(['user' => $user, 'type' => 'credit'])->getSingleScalarResult() ?? 0); + + $debit = (int) ($em->createQuery( + 'SELECT SUM(w.amountRials) FROM App\Settlement\Entity\WalletTransaction w + WHERE w.user = :user AND w.type = :type' + )->setParameters(['user' => $user, 'type' => 'debit'])->getSingleScalarResult() ?? 0); + + return $credit - $debit; + } + + public function save(Settlement $entity, bool $flush = true): void + { + $this->getEntityManager()->persist($entity); + if ($flush) $this->getEntityManager()->flush(); + } +} diff --git a/src/Settlement/Repository/WalletTransactionRepository.php b/src/Settlement/Repository/WalletTransactionRepository.php new file mode 100644 index 00000000..177e27c0 --- /dev/null +++ b/src/Settlement/Repository/WalletTransactionRepository.php @@ -0,0 +1,28 @@ +findBy(['user' => $user], ['createdAt' => 'DESC'], $limit); + } + + public function save(WalletTransaction $entity, bool $flush = true): void + { + $this->getEntityManager()->persist($entity); + if ($flush) $this->getEntityManager()->flush(); + } +} diff --git a/src/Shared/Constant/ErrorCodes.php b/src/Shared/Constant/ErrorCodes.php new file mode 100644 index 00000000..c639c987 --- /dev/null +++ b/src/Shared/Constant/ErrorCodes.php @@ -0,0 +1,81 @@ + 'توکن JWT منقضی شده یا نامعتبر است', + self::ERR_AUTH_002 => 'کد OTP نامعتبر است', + self::ERR_AUTH_003 => 'کد OTP منقضی شده است', + self::ERR_AUTH_004 => 'تعداد تلاش‌های OTP به حد مجاز رسیده است', + self::ERR_AUTH_005 => 'نام کاربری یا رمز عبور اشتباه است', + self::ERR_AUTH_006 => 'این نوع حساب فقط از طریق کد OTP وارد می‌شود', + self::ERR_VALIDATION_001 => 'ورودی نامعتبر است', + self::ERR_VALIDATION_002 => 'فیلد الزامی وارد نشده است', + self::ERR_NOT_FOUND_001 => 'منبع درخواستی یافت نشد', + self::ERR_FORBIDDEN_001 => 'دسترسی به این منبع مجاز نیست', + self::ERR_PAYMENT_001 => 'درگاه پرداخت در دسترس نیست', + self::ERR_PAYMENT_002 => 'مبلغ پرداخت نامعتبر است', + self::ERR_PAYMENT_003 => 'وضعیت نوبت برای پرداخت مناسب نیست', + self::ERR_APPOINTMENT_001 => 'اسلات انتخاب‌شده در دسترس نیست', + self::ERR_APPOINTMENT_002 => 'نوبت قابل لغو نیست', + self::ERR_FILE_001 => 'فرمت فایل مجاز نیست', + self::ERR_FILE_002 => 'حجم فایل بیش از حد مجاز است (حداکثر 5MB)', + self::ERR_SMS_001 => 'موجودی پیامک کافی نیست', + self::ERR_SMS_002 => 'متغیر نامعتبر در تمپلیت', + self::ERR_SMS_003 => 'تمپلیت قبلاً ارسال شده است', + self::ERR_SECRETARY_001 => 'پلن فعلی اجازه منشی بیشتر را نمی‌دهد', + self::ERR_CONFLICT_001 => 'تداخل: منبع در حال استفاده است یا قبلاً تغییر کرده است', + self::ERR_RATE_LIMIT_001 => 'درخواست‌های زیاد. لطفاً بعداً تلاش کنید', + default => 'خطای ناشناخته', + }; + } +} diff --git a/src/Shared/Controller/BaseController.php b/src/Shared/Controller/BaseController.php new file mode 100644 index 00000000..8e094afa --- /dev/null +++ b/src/Shared/Controller/BaseController.php @@ -0,0 +1,51 @@ + true, 'data' => $data]; + if (!empty($meta)) { + $response['meta'] = $meta; + } + return new JsonResponse($response, $status); + } + + protected function paginated(mixed $data, int $total, int $page, int $limit): JsonResponse + { + return $this->success($data, 200, [ + 'totalRecords' => $total, + 'totalPages' => (int) ceil($total / max($limit, 1)), + 'currentPage' => $page, + ]); + } + + protected function error(string $code, string $message, int $status = 400, ?string $field = null): JsonResponse + { + $err = ['code' => $code, 'message' => $message]; + if ($field !== null) { + $err['field'] = $field; + } + return new JsonResponse(['success' => false, 'data' => null, 'errors' => [$err]], $status); + } + + protected function validationError(array $violations): JsonResponse + { + $errors = []; + foreach ($violations as $field => $messages) { + foreach ((array) $messages as $message) { + $errors[] = [ + 'code' => 'ERR_VALIDATION_001', + 'field' => $field, + 'message' => $message, + ]; + } + } + return new JsonResponse(['success' => false, 'data' => null, 'errors' => $errors], 422); + } +} diff --git a/src/Shared/Controller/HealthController.php b/src/Shared/Controller/HealthController.php new file mode 100644 index 00000000..631fa931 --- /dev/null +++ b/src/Shared/Controller/HealthController.php @@ -0,0 +1,45 @@ +em->getConnection()->executeQuery('SELECT 1'); + $checks['database'] = 'ok'; + } catch (\Throwable) { + $checks['database'] = 'error'; + $status = 'degraded'; + } + + try { + $item = $this->cache->getItem('health_check'); + $checks['redis'] = 'ok'; + } catch (\Throwable) { + $checks['redis'] = 'error'; + $status = 'degraded'; + } + + return new JsonResponse([ + 'status' => $status, + 'checks' => $checks, + 'timestamp' => time(), + ], $status === 'ok' ? 200 : 503); + } +} diff --git a/src/Shared/EventSubscriber/ExceptionSubscriber.php b/src/Shared/EventSubscriber/ExceptionSubscriber.php new file mode 100644 index 00000000..15bb2757 --- /dev/null +++ b/src/Shared/EventSubscriber/ExceptionSubscriber.php @@ -0,0 +1,123 @@ +getThrowable(); + + if ($exception instanceof AppException) { + $err = ['code' => $exception->getErrorCode(), 'message' => $exception->getMessage()]; + if ($exception->getField()) { + $err['field'] = $exception->getField(); + } + $event->setResponse(new JsonResponse( + ['success' => false, 'data' => null, 'errors' => [$err]], + $exception->getHttpStatus() + )); + return; + } + + if ($exception instanceof TooManyRequestsHttpException) { + $headers = []; + $retryAfter = $exception->getHeaders()['Retry-After'] ?? null; + if ($retryAfter !== null) { + $headers['Retry-After'] = $retryAfter; + } + $event->setResponse(new JsonResponse( + ['success' => false, 'data' => null, 'errors' => [['code' => 'ERR_RATE_LIMIT_001', 'message' => 'درخواست‌های زیاد. لطفاً بعداً تلاش کنید']]], + 429, + $headers + )); + return; + } + + if ($exception instanceof NotFoundHttpException) { + $event->setResponse(new JsonResponse( + ['success' => false, 'data' => null, 'errors' => [['code' => 'ERR_NOT_FOUND_001', 'message' => 'منبع درخواستی یافت نشد']]], + 404 + )); + return; + } + + if ($exception instanceof AccessDeniedHttpException) { + $event->setResponse(new JsonResponse( + ['success' => false, 'data' => null, 'errors' => [['code' => 'ERR_FORBIDDEN_001', 'message' => 'دسترسی به این منبع مجاز نیست']]], + 403 + )); + return; + } + + if ($exception instanceof UnauthorizedHttpException) { + $event->setResponse(new JsonResponse( + ['success' => false, 'data' => null, 'errors' => [['code' => 'ERR_AUTH_001', 'message' => 'احراز هویت لازم است']]], + 401 + )); + return; + } + + // Security exceptions not yet wrapped into HttpException + if ($exception instanceof AccessDeniedException) { + $token = $this->tokenStorage->getToken(); + $isAuthenticated = $token !== null && $token->getUser() !== null; + + if (!$isAuthenticated) { + $event->setResponse(new JsonResponse( + ['success' => false, 'data' => null, 'errors' => [['code' => 'ERR_AUTH_001', 'message' => 'احراز هویت لازم است']]], + 401 + )); + } else { + $event->setResponse(new JsonResponse( + ['success' => false, 'data' => null, 'errors' => [['code' => 'ERR_FORBIDDEN_001', 'message' => 'دسترسی به این منبع مجاز نیست']]], + 403 + )); + } + return; + } + + if ($exception instanceof AuthenticationException) { + $event->setResponse(new JsonResponse( + ['success' => false, 'data' => null, 'errors' => [['code' => 'ERR_AUTH_001', 'message' => 'احراز هویت لازم است']]], + 401 + )); + return; + } + + // Generic fallback: never leak stack traces or internal details in API responses + $this->logger->error('Unhandled exception', [ + 'exception' => $exception, + 'path' => $event->getRequest()->getPathInfo(), + ]); + + $event->setResponse(new JsonResponse( + ['success' => false, 'data' => null, 'errors' => [['code' => 'ERR_INTERNAL_001', 'message' => 'خطای داخلی سرور']]], + 500 + )); + } + + public static function getSubscribedEvents(): array + { + return [KernelEvents::EXCEPTION => ['onKernelException', 10]]; + } +} diff --git a/src/Shared/EventSubscriber/SecurityHeadersSubscriber.php b/src/Shared/EventSubscriber/SecurityHeadersSubscriber.php new file mode 100644 index 00000000..d314b150 --- /dev/null +++ b/src/Shared/EventSubscriber/SecurityHeadersSubscriber.php @@ -0,0 +1,37 @@ +isMainRequest()) { + return; + } + + $response = $event->getResponse(); + $response->headers->set('X-Content-Type-Options', 'nosniff'); + $response->headers->set('X-Frame-Options', 'DENY'); + $response->headers->set('X-XSS-Protection', '1; mode=block'); + $response->headers->set('Referrer-Policy', 'strict-origin-when-cross-origin'); + $response->headers->set('Permissions-Policy', 'geolocation=(), microphone=(), camera=()'); + + if ($event->getRequest()->isSecure()) { + $response->headers->set('Strict-Transport-Security', 'max-age=31536000; includeSubDomains'); + } + + if (str_starts_with($event->getRequest()->getPathInfo(), '/api')) { + $response->headers->set('Content-Security-Policy', "default-src 'none'"); + } + } + + public static function getSubscribedEvents(): array + { + return [KernelEvents::RESPONSE => 'onKernelResponse']; + } +} diff --git a/src/Shared/Exception/AppException.php b/src/Shared/Exception/AppException.php new file mode 100644 index 00000000..5007b135 --- /dev/null +++ b/src/Shared/Exception/AppException.php @@ -0,0 +1,21 @@ +errorCode; } + public function getHttpStatus(): int { return $this->httpStatus; } + public function getField(): ?string { return $this->field; } +} diff --git a/src/Shared/Message/SendSmsMessage.php b/src/Shared/Message/SendSmsMessage.php new file mode 100644 index 00000000..57070d90 --- /dev/null +++ b/src/Shared/Message/SendSmsMessage.php @@ -0,0 +1,12 @@ + ["\xFF\xD8\xFF"], + 'image/png' => ["\x89\x50\x4E\x47\x0D\x0A\x1A\x0A"], + 'image/webp' => ["RIFF"], + ]; + + private const ALLOWED_EXTENSIONS = ['jpg', 'jpeg', 'png', 'webp']; + + public function __construct(private readonly int $maxSizeBytes = 5_242_880) {} + + public function validateUploadedFile(UploadedFile $file): string + { + if ($file->getSize() > $this->maxSizeBytes) { + throw new AppException(ErrorCodes::ERR_FILE_002, null, 422); + } + + $binaryContent = (string) file_get_contents($file->getPathname()); + return $this->validate($binaryContent, $file->getClientOriginalName()); + } + + public function validate(string $binaryContent, string $claimedFilename): string + { + if (strlen($binaryContent) > $this->maxSizeBytes) { + throw new AppException(ErrorCodes::ERR_FILE_002, null, 422); + } + + $detected = false; + foreach (self::ALLOWED_SIGNATURES as $signatures) { + foreach ($signatures as $sig) { + if (str_starts_with($binaryContent, $sig)) { + $detected = true; + break 2; + } + } + } + if (!$detected) { + throw new AppException(ErrorCodes::ERR_FILE_001, null, 422); + } + + return $this->sanitizeFilename($claimedFilename); + } + + public function sanitizeFilename(string $filename): string + { + $safeName = preg_replace('/[^a-zA-Z0-9._-]/', '', basename($filename)); + if (empty($safeName) || str_contains($safeName, '..')) { + throw new AppException(ErrorCodes::ERR_FILE_001, null, 422); + } + + $ext = strtolower(pathinfo($safeName, PATHINFO_EXTENSION)); + if (!in_array($ext, self::ALLOWED_EXTENSIONS, true)) { + throw new AppException(ErrorCodes::ERR_FILE_001, null, 422); + } + + return $safeName; + } + + public function detectMimeType(string $filePath): string + { + $handle = fopen($filePath, 'rb'); + $header = fread($handle, 12); + fclose($handle); + + foreach (self::ALLOWED_SIGNATURES as $mime => $signatures) { + foreach ($signatures as $sig) { + if (str_starts_with($header, $sig)) { + return $mime; + } + } + } + + throw new AppException(ErrorCodes::ERR_FILE_001, 'نوع فایل پشتیبانی نمی‌شود', 422); + } +} diff --git a/src/Sms/Controller/SmsController.php b/src/Sms/Controller/SmsController.php new file mode 100644 index 00000000..f04a8a0e --- /dev/null +++ b/src/Sms/Controller/SmsController.php @@ -0,0 +1,202 @@ +getContent(), true) ?? []; + $mobile = trim($data['mobile'] ?? ''); + $message = trim($data['message'] ?? ''); + $provider = $data['provider'] ?? 'kavenegar'; + + if (empty($mobile) || empty($message)) { + return $this->error(ErrorCodes::ERR_VALIDATION_002, 'mobile و message الزامی است', 422); + } + + $this->smsService->dispatchAsync($mobile, $message, $provider); + + return $this->success(['message' => 'پیامک در صف ارسال قرار گرفت']); + } + + // ── Templates ───────────────────────────────────────────────────────────── + + #[IsGranted('ROLE_ADMIN')] + #[Route('/api/v1/sms/template', methods: ['POST'])] + public function createTemplate(Request $request): JsonResponse + { + $data = json_decode($request->getContent(), true) ?? []; + $name = trim($data['name'] ?? ''); + $body = trim($data['body'] ?? ''); + $variables = $data['variables'] ?? []; + + if (empty($name) || empty($body)) { + return $this->error(ErrorCodes::ERR_VALIDATION_002, 'name و body الزامی است', 422); + } + + $template = new SmsTemplate($name, $body, $variables); + $this->templateRepo->save($template); + + return $this->success(['data' => $template->toArray()], 201); + } + + #[Route('/api/v1/sms/template/{uuid}', methods: ['GET'])] + public function getTemplate(string $uuid): JsonResponse + { + $template = $this->templateRepo->findByUuid($uuid); + if ($template === null) { + return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'تمپلیت یافت نشد', 404); + } + return $this->success(['data' => $template->toArray()]); + } + + #[IsGranted('ROLE_ADMIN')] + #[Route('/api/v1/sms/template/{uuid}', methods: ['PATCH'])] + public function updateTemplate(string $uuid, Request $request): JsonResponse + { + $template = $this->templateRepo->findByUuid($uuid); + if ($template === null) { + return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'تمپلیت یافت نشد', 404); + } + + if ($template->getStatus() === SmsTemplate::STATUS_APPROVED) { + return $this->error(ErrorCodes::ERR_SMS_003, ErrorCodes::message(ErrorCodes::ERR_SMS_003), 422); + } + + $data = json_decode($request->getContent(), true) ?? []; + if (array_key_exists('name', $data)) $template->setName($data['name']); + if (array_key_exists('body', $data)) $template->setBody($data['body']); + if (array_key_exists('variables', $data)) $template->setVariables($data['variables']); + + $this->templateRepo->save($template); + + return $this->success(['data' => $template->toArray()]); + } + + #[IsGranted('ROLE_ADMIN')] + #[Route('/api/v1/sms/template/{uuid}/submit', methods: ['POST'])] + public function submitTemplate(string $uuid): JsonResponse + { + $template = $this->templateRepo->findByUuid($uuid); + if ($template === null) { + return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'تمپلیت یافت نشد', 404); + } + + $template->submitForReview(); + $this->templateRepo->save($template); + + return $this->success(['data' => $template->toArray()]); + } + + #[IsGranted('ROLE_ADMIN')] + #[Route('/api/v1/sms/template/{uuid}', methods: ['DELETE'])] + public function deleteTemplate(string $uuid): JsonResponse + { + $template = $this->templateRepo->findByUuid($uuid); + if ($template === null) { + return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'تمپلیت یافت نشد', 404); + } + $this->templateRepo->remove($template); + return $this->success(['message' => 'تمپلیت حذف شد']); + } + + // ── Admin moderation ────────────────────────────────────────────────────── + + #[IsGranted('ROLE_ADMIN')] + #[Route('/api/v1/admin/sms/templates', methods: ['GET'])] + public function listTemplates(): JsonResponse + { + $templates = array_map(fn(SmsTemplate $t) => $t->toArray(), $this->templateRepo->findAll()); + return $this->success(['data' => $templates]); + } + + #[IsGranted('ROLE_ADMIN')] + #[Route('/api/v1/admin/sms/template/{uuid}/approve', methods: ['POST'])] + public function approveTemplate(string $uuid, Request $request): JsonResponse + { + $template = $this->templateRepo->findByUuid($uuid); + if ($template === null) { + return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'تمپلیت یافت نشد', 404); + } + + $data = json_decode($request->getContent(), true) ?? []; + $template->approve($data['note'] ?? null); + if (!empty($data['provider_code'])) $template->setProviderCode($data['provider_code']); + $this->templateRepo->save($template); + + return $this->success(['data' => $template->toArray()]); + } + + #[IsGranted('ROLE_ADMIN')] + #[Route('/api/v1/admin/sms/template/{uuid}/reject', methods: ['POST'])] + public function rejectTemplate(string $uuid, Request $request): JsonResponse + { + $template = $this->templateRepo->findByUuid($uuid); + if ($template === null) { + return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'تمپلیت یافت نشد', 404); + } + + $data = json_decode($request->getContent(), true) ?? []; + $note = trim($data['note'] ?? ''); + if (empty($note)) { + return $this->error(ErrorCodes::ERR_VALIDATION_002, 'دلیل رد الزامی است', 422); + } + $template->reject($note); + $this->templateRepo->save($template); + + return $this->success(['data' => $template->toArray()]); + } + + // ── Send via template ───────────────────────────────────────────────────── + + #[IsGranted('ROLE_ADMIN')] + #[Route('/api/v1/sms/send-template', methods: ['POST'])] + public function sendViaTemplate(Request $request): JsonResponse + { + $data = json_decode($request->getContent(), true) ?? []; + $mobile = trim($data['mobile'] ?? ''); + $templateUuid = trim($data['template_uuid'] ?? ''); + $vars = $data['vars'] ?? []; + $provider = $data['provider'] ?? 'kavenegar'; + + $template = $this->templateRepo->findByUuid($templateUuid); + if ($template === null) { + return $this->error(ErrorCodes::ERR_NOT_FOUND_001, 'تمپلیت یافت نشد', 404); + } + + if ($template->getStatus() !== SmsTemplate::STATUS_APPROVED) { + return $this->error(ErrorCodes::ERR_VALIDATION_001, 'تمپلیت هنوز تأیید نشده است', 422); + } + + $message = $template->renderBody($vars); + $this->smsService->dispatchAsync( + $mobile, $message, $provider, $template->getUuid(), + $vars, $template->getProviderCode() + ); + + return $this->success(['message' => 'پیامک در صف ارسال قرار گرفت']); + } +} diff --git a/src/Sms/Entity/SmsLog.php b/src/Sms/Entity/SmsLog.php new file mode 100644 index 00000000..82da764c --- /dev/null +++ b/src/Sms/Entity/SmsLog.php @@ -0,0 +1,63 @@ +uuid = Uuid::v4()->toRfc4122(); + $this->mobile = $mobile; + $this->message = $message; + $this->provider = $provider; + $this->success = $success; + $this->createdAt = time(); + } + + public function setTemplateUuid(?string $v): self { $this->templateUuid = $v; return $this; } + + public function toArray(): array + { + return [ + 'uuid' => $this->uuid, + 'mobile' => $this->mobile, + 'message' => $this->message, + 'provider' => $this->provider, + 'success' => $this->success, + 'template_uuid' => $this->templateUuid, + 'created_at' => $this->createdAt, + ]; + } +} diff --git a/src/Sms/Entity/SmsTemplate.php b/src/Sms/Entity/SmsTemplate.php new file mode 100644 index 00000000..f16a6fbe --- /dev/null +++ b/src/Sms/Entity/SmsTemplate.php @@ -0,0 +1,115 @@ +uuid = Uuid::v4()->toRfc4122(); + $this->name = $name; + $this->body = $body; + $this->variables = $variables; + $this->createdAt = time(); + $this->updatedAt = time(); + } + + public function getId(): ?int { return $this->id; } + public function getUuid(): string { return $this->uuid; } + public function getName(): string { return $this->name; } + public function getBody(): string { return $this->body; } + public function getProviderCode(): ?string { return $this->providerCode; } + public function getStatus(): string { return $this->status; } + public function getVariables(): array { return $this->variables; } + public function getAdminNote(): ?string { return $this->adminNote; } + + public function setName(string $v): self { $this->name = $v; $this->touch(); return $this; } + public function setBody(string $v): self { $this->body = $v; $this->touch(); return $this; } + public function setProviderCode(?string $v): self { $this->providerCode = $v; $this->touch(); return $this; } + public function setVariables(array $v): self { $this->variables = $v; $this->touch(); return $this; } + + public function submitForReview(): self { $this->status = self::STATUS_PENDING; $this->touch(); return $this; } + + public function approve(?string $note = null): self + { + $this->status = self::STATUS_APPROVED; + $this->adminNote = $note; + $this->touch(); + return $this; + } + + public function reject(string $note): self + { + $this->status = self::STATUS_REJECTED; + $this->adminNote = $note; + $this->touch(); + return $this; + } + + private function touch(): void { $this->updatedAt = time(); } + + public function renderBody(array $vars): string + { + $body = $this->body; + foreach ($vars as $key => $value) { + $body = str_replace('{{' . $key . '}}', $value, $body); + } + return $body; + } + + public function toArray(): array + { + return [ + 'uuid' => $this->uuid, + 'name' => $this->name, + 'body' => $this->body, + 'provider_code' => $this->providerCode, + 'variables' => $this->variables, + 'status' => $this->status, + 'admin_note' => $this->adminNote, + 'created_at' => $this->createdAt, + ]; + } +} diff --git a/src/Sms/Message/SendSmsMessage.php b/src/Sms/Message/SendSmsMessage.php new file mode 100644 index 00000000..1d2220f7 --- /dev/null +++ b/src/Sms/Message/SendSmsMessage.php @@ -0,0 +1,15 @@ +httpClient->request('POST', + self::BASE . '/' . $this->apiKey . '/sms/send.json', [ + 'body' => http_build_query([ + 'receptor' => $mobile, + 'message' => $message, + 'sender' => $this->sender, + ]), + 'timeout' => 10, + ] + ); + $data = $resp->toArray(); + return ($data['return']['status'] ?? 0) === 200; + } catch (\Throwable) { + return false; + } + } + + public function sendTemplate(string $mobile, string $templateCode, array $vars): bool + { + try { + $params = ['receptor' => $mobile, 'template' => $templateCode]; + foreach (array_values($vars) as $i => $v) { + $params['token' . ($i > 0 ? $i + 1 : '')] = $v; + } + $resp = $this->httpClient->request('POST', + self::BASE . '/' . $this->apiKey . '/verify/lookup.json', [ + 'body' => http_build_query($params), + 'timeout' => 10, + ] + ); + $data = $resp->toArray(); + return ($data['return']['status'] ?? 0) === 200; + } catch (\Throwable) { + return false; + } + } +} diff --git a/src/Sms/Provider/RanginehProvider.php b/src/Sms/Provider/RanginehProvider.php new file mode 100644 index 00000000..f294e3f1 --- /dev/null +++ b/src/Sms/Provider/RanginehProvider.php @@ -0,0 +1,50 @@ +httpClient->request('POST', self::BASE . '/send', [ + 'json' => ['from' => $this->sender, 'to' => [$mobile], 'text' => $message], + 'headers' => ['ApiKey' => $this->apiKey], + 'timeout' => 10, + ]); + return $resp->getStatusCode() === 200; + } catch (\Throwable) { + return false; + } + } + + public function sendTemplate(string $mobile, string $templateCode, array $vars): bool + { + try { + $resp = $this->httpClient->request('POST', self::BASE . '/send/verify', [ + 'json' => [ + 'mobile' => $mobile, + 'template' => $templateCode, + 'params' => $vars, + ], + 'headers' => ['ApiKey' => $this->apiKey], + 'timeout' => 10, + ]); + return $resp->getStatusCode() === 200; + } catch (\Throwable) { + return false; + } + } +} diff --git a/src/Sms/Provider/SmsProviderInterface.php b/src/Sms/Provider/SmsProviderInterface.php new file mode 100644 index 00000000..24262a9d --- /dev/null +++ b/src/Sms/Provider/SmsProviderInterface.php @@ -0,0 +1,14 @@ +getEntityManager()->persist($e); if ($flush) $this->getEntityManager()->flush(); } +} diff --git a/src/Sms/Repository/SmsTemplateRepository.php b/src/Sms/Repository/SmsTemplateRepository.php new file mode 100644 index 00000000..0ebd4084 --- /dev/null +++ b/src/Sms/Repository/SmsTemplateRepository.php @@ -0,0 +1,15 @@ +findOneBy(['uuid' => $uuid]); } + public function save(SmsTemplate $e, bool $flush = true): void { $this->getEntityManager()->persist($e); if ($flush) $this->getEntityManager()->flush(); } + public function remove(SmsTemplate $e, bool $flush = true): void { $this->getEntityManager()->remove($e); if ($flush) $this->getEntityManager()->flush(); } +} diff --git a/src/Sms/Service/SendSmsHandler.php b/src/Sms/Service/SendSmsHandler.php new file mode 100644 index 00000000..c35f1628 --- /dev/null +++ b/src/Sms/Service/SendSmsHandler.php @@ -0,0 +1,17 @@ +smsService->sendNow($message); + } +} diff --git a/src/Sms/Service/SmsService.php b/src/Sms/Service/SmsService.php new file mode 100644 index 00000000..dd65a2e9 --- /dev/null +++ b/src/Sms/Service/SmsService.php @@ -0,0 +1,61 @@ +providers = [ + 'kavenegar' => $kavenegar, + 'rangineh' => $rangineh, + ]; + } + + public function dispatchAsync( + string $mobile, + string $message, + string $provider = 'kavenegar', + ?string $templateUuid = null, + array $templateVars = [], + ?string $templateCode = null, + ): void { + $this->bus->dispatch(new SendSmsMessage( + $mobile, $message, $provider, $templateUuid, $templateVars, $templateCode + )); + } + + public function sendNow(SendSmsMessage $msg): bool + { + $provider = $this->resolveProvider($msg->provider); + + $success = ($msg->templateCode !== null) + ? $provider->sendTemplate($msg->mobile, $msg->templateCode, $msg->templateVars) + : $provider->send($msg->mobile, $msg->message); + + $log = new SmsLog($msg->mobile, $msg->message, $provider->getName(), $success); + if ($msg->templateUuid) $log->setTemplateUuid($msg->templateUuid); + $this->logRepo->save($log); + + return $success; + } + + private function resolveProvider(string $name): SmsProviderInterface + { + return $this->providers[$name] ?? $this->kavenegar; + } +} diff --git a/src/UserProfile/Controller/UserProfileController.php b/src/UserProfile/Controller/UserProfileController.php new file mode 100644 index 00000000..cbfc9b66 --- /dev/null +++ b/src/UserProfile/Controller/UserProfileController.php @@ -0,0 +1,144 @@ +repository->findByUser($user) !== null) { + return $this->error(ErrorCodes::ERR_VALIDATION_001, 'پروفایل قبلاً ایجاد شده است', 409); + } + + $data = json_decode($request->getContent(), true) ?? []; + $profile = new UserProfile($user); + $this->hydrate($profile, $data); + $this->repository->save($profile); + + return $this->success(['data' => $profile->toArray()], 201); + } + + #[Route('/api/v1/user-profile/{uuid}', methods: ['GET'])] + public function show(string $uuid, #[CurrentUser] User $user): JsonResponse + { + $profile = $this->repository->findByUuid($uuid); + + if ($profile === null) { + return $this->error(ErrorCodes::ERR_VALIDATION_002, 'پروفایل یافت نشد', 404); + } + + if (!$this->canAccess($profile, $user)) { + return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403); + } + + return $this->success(['data' => $profile->toArray()]); + } + + #[Route('/api/v1/user-profile/{uuid}', methods: ['PATCH'])] + public function update(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse + { + $profile = $this->repository->findByUuid($uuid); + + if ($profile === null) { + return $this->error(ErrorCodes::ERR_VALIDATION_002, 'پروفایل یافت نشد', 404); + } + + if (!$this->canAccess($profile, $user)) { + return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403); + } + + $data = json_decode($request->getContent(), true) ?? []; + $this->hydrate($profile, $data); + $this->repository->save($profile); + + return $this->success(['data' => $profile->toArray()]); + } + + #[Route('/api/v1/user-profile/{uuid}', methods: ['DELETE'])] + #[IsGranted('ROLE_ADMIN')] + public function delete(string $uuid): JsonResponse + { + $profile = $this->repository->findByUuid($uuid); + + if ($profile === null) { + return $this->error(ErrorCodes::ERR_VALIDATION_002, 'پروفایل یافت نشد', 404); + } + + $this->repository->remove($profile); + + return $this->success(['message' => 'پروفایل با موفقیت حذف شد']); + } + + private function canAccess(UserProfile $profile, User $currentUser): bool + { + return $profile->getUser()->getId() === $currentUser->getId() + || $currentUser->hasRole('ROLE_ADMIN'); + } + + private function hydrate(UserProfile $profile, array $data): void + { + if (array_key_exists('name', $data)) $profile->setLabel($data['name']); + if (array_key_exists('label', $data)) $profile->setLabel($data['label']); + if (array_key_exists('family', $data)) $profile->setFamily($data['family']); + if (array_key_exists('fathers_name', $data)) $profile->setFathersName($data['fathers_name']); + if (array_key_exists('national_code', $data)) $profile->setNationalCode($data['national_code']); + if (array_key_exists('gender', $data)) $profile->setGender($data['gender']); + if (array_key_exists('blood_type', $data)) $profile->setBloodType($data['blood_type']); + if (array_key_exists('marital_status', $data)) $profile->setMaritalStatus($data['marital_status']); + if (array_key_exists('education', $data)) $profile->setEducation($data['education']); + if (array_key_exists('job', $data)) $profile->setJob($data['job']); + if (array_key_exists('address', $data)) $profile->setAddress($data['address']); + if (array_key_exists('home_phone', $data)) $profile->setHomePhone($data['home_phone']); + if (array_key_exists('work_phone', $data)) $profile->setWorkPhone($data['work_phone']); + if (array_key_exists('insurance_id', $data)) $profile->setInsuranceId($data['insurance_id']); + if (array_key_exists('description', $data)) $profile->setDescription( + is_array($data['description']) ? ($data['description'][0]['value'] ?? null) : $data['description'] + ); + if (array_key_exists('sharing_with_user', $data)) $profile->setSharingWithUser((bool) $data['sharing_with_user']); + + // birthday: accept Jalali string "1370-05-15" stored as-is converted to Unix + if (array_key_exists('birthday', $data) && $data['birthday'] !== null) { + // Store as string-encoded Unix; for now keep as null if conversion unavailable + // Will be replaced with JalaliDateService in Task 16 + $profile->setDateOfBirth(null); + } + if (array_key_exists('date_of_birth', $data)) $profile->setDateOfBirth($data['date_of_birth']); + + // Insurance references (category IDs) + if (array_key_exists('basic_insurance', $data)) { + $id = is_array($data['basic_insurance']) ? ($data['basic_insurance'][0] ?? null) : $data['basic_insurance']; + $profile->setBasicInsuranceId($id !== null ? (int) $id : null); + } + if (array_key_exists('supplementary_insurance', $data)) { + $id = is_array($data['supplementary_insurance']) + ? ($data['supplementary_insurance'][0] ?? null) + : $data['supplementary_insurance']; + $profile->setSupplementaryInsuranceId($id !== null ? (int) $id : null); + } + + // Medical history JSON + if (array_key_exists('other', $data)) { + $other = is_array($data['other']) && isset($data['other'][0]) + ? $data['other'][0] + : $data['other']; + $profile->setOther($other); + } + } +} diff --git a/src/UserProfile/Entity/UserProfile.php b/src/UserProfile/Entity/UserProfile.php new file mode 100644 index 00000000..1ef8c4d9 --- /dev/null +++ b/src/UserProfile/Entity/UserProfile.php @@ -0,0 +1,181 @@ +uuid = Uuid::v4()->toRfc4122(); + $this->user = $user; + $this->createdAt = time(); + $this->updatedAt = time(); + } + + public function getId(): ?int { return $this->id; } + public function getUuid(): string { return $this->uuid; } + public function getUser(): User { return $this->user; } + + public function getLabel(): ?string { return $this->label; } + public function getFamily(): ?string { return $this->family; } + public function getFathersName(): ?string { return $this->fathersName; } + public function getNationalCode(): ?string { return $this->nationalCode; } + public function isNationalCodeApproved(): bool { return $this->nationalCodeApproved; } + public function getGender(): ?string { return $this->gender; } + public function getDateOfBirth(): ?int { return $this->dateOfBirth; } + public function getBloodType(): ?string { return $this->bloodType; } + public function getMaritalStatus(): ?string { return $this->maritalStatus; } + public function getEducation(): ?string { return $this->education; } + public function getJob(): ?string { return $this->job; } + public function getAddress(): ?string { return $this->address; } + public function getHomePhone(): ?string { return $this->homePhone; } + public function getWorkPhone(): ?string { return $this->workPhone; } + public function getInsuranceId(): ?string { return $this->insuranceId; } + public function getBasicInsuranceId(): ?int { return $this->basicInsuranceId; } + public function getSupplementaryInsuranceId(): ?int { return $this->supplementaryInsuranceId; } + public function getOther(): ?array { return $this->other; } + public function isSharingWithUser(): bool { return $this->sharingWithUser; } + public function getDescription(): ?string { return $this->description; } + public function getCreatedAt(): int { return $this->createdAt; } + public function getUpdatedAt(): int { return $this->updatedAt; } + + public function setLabel(?string $v): self { $this->label = $v; return $this; } + public function setFamily(?string $v): self { $this->family = $v; $this->touch(); return $this; } + public function setFathersName(?string $v): self { $this->fathersName = $v; $this->touch(); return $this; } + public function setNationalCode(?string $v): self { $this->nationalCode = $v; $this->touch(); return $this; } + public function setNationalCodeApproved(bool $v): self { $this->nationalCodeApproved = $v; $this->touch(); return $this; } + public function setGender(?string $v): self { $this->gender = $v; $this->touch(); return $this; } + public function setDateOfBirth(?int $v): self { $this->dateOfBirth = $v; $this->touch(); return $this; } + public function setBloodType(?string $v): self { $this->bloodType = $v; $this->touch(); return $this; } + public function setMaritalStatus(?string $v): self { $this->maritalStatus = $v; $this->touch(); return $this; } + public function setEducation(?string $v): self { $this->education = $v; $this->touch(); return $this; } + public function setJob(?string $v): self { $this->job = $v; $this->touch(); return $this; } + public function setAddress(?string $v): self { $this->address = $v; $this->touch(); return $this; } + public function setHomePhone(?string $v): self { $this->homePhone = $v; $this->touch(); return $this; } + public function setWorkPhone(?string $v): self { $this->workPhone = $v; $this->touch(); return $this; } + public function setInsuranceId(?string $v): self { $this->insuranceId = $v; $this->touch(); return $this; } + public function setBasicInsuranceId(?int $v): self { $this->basicInsuranceId = $v; $this->touch(); return $this; } + public function setSupplementaryInsuranceId(?int $v): self { $this->supplementaryInsuranceId = $v; $this->touch(); return $this; } + public function setOther(?array $v): self { $this->other = $v; $this->touch(); return $this; } + public function setSharingWithUser(bool $v): self { $this->sharingWithUser = $v; $this->touch(); return $this; } + public function setDescription(?string $v): self { $this->description = $v; $this->touch(); return $this; } + + private function touch(): void { $this->updatedAt = time(); } + + public function toArray(): array + { + return [ + 'id' => $this->id, + 'uuid' => $this->uuid, + 'user_uuid' => $this->user->getUuid(), + 'label' => $this->label, + 'family' => $this->family, + 'fathers_name' => $this->fathersName, + 'national_code' => $this->nationalCode, + 'national_code_approved' => $this->nationalCodeApproved, + 'gender' => $this->gender, + 'date_of_birth' => $this->dateOfBirth, + 'blood_type' => $this->bloodType, + 'marital_status' => $this->maritalStatus, + 'education' => $this->education, + 'job' => $this->job, + 'address' => $this->address, + 'home_phone' => $this->homePhone, + 'work_phone' => $this->workPhone, + 'insurance_id' => $this->insuranceId, + 'basic_insurance_id' => $this->basicInsuranceId, + 'supplementary_insurance_id' => $this->supplementaryInsuranceId, + 'other' => $this->other, + 'sharing_with_user' => $this->sharingWithUser, + 'description' => $this->description, + 'created_at' => $this->createdAt, + 'updated_at' => $this->updatedAt, + ]; + } +} diff --git a/src/UserProfile/Repository/UserProfileRepository.php b/src/UserProfile/Repository/UserProfileRepository.php new file mode 100644 index 00000000..1ef8d7be --- /dev/null +++ b/src/UserProfile/Repository/UserProfileRepository.php @@ -0,0 +1,42 @@ +findOneBy(['user' => $user]); + } + + public function findByUuid(string $uuid): ?UserProfile + { + return $this->findOneBy(['uuid' => $uuid]); + } + + public function save(UserProfile $profile, bool $flush = true): void + { + $this->getEntityManager()->persist($profile); + if ($flush) { + $this->getEntityManager()->flush(); + } + } + + public function remove(UserProfile $profile, bool $flush = true): void + { + $this->getEntityManager()->remove($profile); + if ($flush) { + $this->getEntityManager()->flush(); + } + } +} diff --git a/symfony.lock b/symfony.lock new file mode 100644 index 00000000..cf2e045a --- /dev/null +++ b/symfony.lock @@ -0,0 +1,220 @@ +{ + "doctrine/deprecations": { + "version": "1.1", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "main", + "version": "1.0", + "ref": "fdd756167454623e21f1d769c5b814b243782a67" + } + }, + "doctrine/doctrine-bundle": { + "version": "2.18", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "main", + "version": "2.13", + "ref": "620b57f496f2e599a6015a9fa222c2ee0a32adcb" + }, + "files": [ + "config/packages/doctrine.yaml", + "src/Entity/.gitignore", + "src/Repository/.gitignore" + ] + }, + "doctrine/doctrine-migrations-bundle": { + "version": "3.7", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "main", + "version": "3.1", + "ref": "1d01ec03c6ecbd67c3375c5478c9a423ae5d6a33" + }, + "files": [ + "config/packages/doctrine_migrations.yaml", + "migrations/.gitignore" + ] + }, + "lexik/jwt-authentication-bundle": { + "version": "3.2", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "main", + "version": "2.5", + "ref": "e9481b233a11ef7e15fe055a2b21fd3ac1aa2bb7" + }, + "files": [ + "config/packages/lexik_jwt_authentication.yaml" + ] + }, + "nelmio/api-doc-bundle": { + "version": "v5.10.3" + }, + "nelmio/cors-bundle": { + "version": "2.6", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "main", + "version": "1.5", + "ref": "6bea22e6c564fba3a1391615cada1437d0bde39c" + }, + "files": [ + "config/packages/nelmio_cors.yaml" + ] + }, + "phpstan/phpstan": { + "version": "2.2.2" + }, + "phpunit/phpunit": { + "version": "12.5", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "main", + "version": "11.1", + "ref": "ca0bc067abfb40a8de1b2561b96cbfc2b833c314" + }, + "files": [ + ".env.test", + "phpunit.dist.xml", + "tests/bootstrap.php", + "bin/phpunit" + ] + }, + "symfony/console": { + "version": "7.4", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "main", + "version": "5.3", + "ref": "1781ff40d8a17d87cf53f8d4cf0c8346ed2bb461" + }, + "files": [ + "bin/console" + ] + }, + "symfony/debug-bundle": { + "version": "7.4", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "main", + "version": "5.3", + "ref": "5aa8aa48234c8eb6dbdd7b3cd5d791485d2cec4b" + }, + "files": [ + "config/packages/debug.yaml" + ] + }, + "symfony/flex": { + "version": "2.11", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "main", + "version": "2.4", + "ref": "52e9754527a15e2b79d9a610f98185a1fe46622a" + }, + "files": [ + ".env", + ".env.dev" + ] + }, + "symfony/framework-bundle": { + "version": "7.4", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "main", + "version": "7.4", + "ref": "d5dcd308c8becd725c9d8b91e31aab1ff0bbc30b" + }, + "files": [ + "config/packages/cache.yaml", + "config/packages/framework.yaml", + "config/preload.php", + "config/routes/framework.yaml", + "config/services.yaml", + "public/index.php", + "src/Controller/.gitignore", + "src/Kernel.php", + ".editorconfig" + ] + }, + "symfony/maker-bundle": { + "version": "1.67", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "main", + "version": "1.0", + "ref": "fadbfe33303a76e25cb63401050439aa9b1a9c7f" + } + }, + "symfony/messenger": { + "version": "7.4", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "main", + "version": "6.0", + "ref": "d8936e2e2230637ef97e5eecc0eea074eecae58b" + }, + "files": [ + "config/packages/messenger.yaml" + ] + }, + "symfony/property-info": { + "version": "7.4", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "main", + "version": "7.3", + "ref": "dae70df71978ae9226ae915ffd5fad817f5ca1f7" + }, + "files": [ + "config/packages/property_info.yaml" + ] + }, + "symfony/routing": { + "version": "7.4", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "main", + "version": "7.4", + "ref": "bc94c4fd86f393f3ab3947c18b830ea343e51ded" + }, + "files": [ + "config/packages/routing.yaml", + "config/routes.yaml" + ] + }, + "symfony/security-bundle": { + "version": "7.4", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "main", + "version": "7.4", + "ref": "c42fee7802181cdd50f61b8622715829f5d2335c" + }, + "files": [ + "config/packages/security.yaml", + "config/routes/security.yaml" + ] + }, + "symfony/uid": { + "version": "7.4", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "main", + "version": "7.0", + "ref": "0df5844274d871b37fc3816c57a768ffc60a43a5" + } + }, + "symfony/validator": { + "version": "7.4", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "main", + "version": "7.0", + "ref": "8c1c4e28d26a124b0bb273f537ca8ce443472bfd" + }, + "files": [ + "config/packages/validator.yaml" + ] + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php new file mode 100644 index 00000000..47a58557 --- /dev/null +++ b/tests/bootstrap.php @@ -0,0 +1,13 @@ +bootEnv(dirname(__DIR__).'/.env'); +} + +if ($_SERVER['APP_DEBUG']) { + umask(0000); +}