diff --git a/docs/audit-backlog.md b/docs/audit-backlog.md index 75df2981..3db9c518 100644 --- a/docs/audit-backlog.md +++ b/docs/audit-backlog.md @@ -74,7 +74,7 @@ _None outstanding._ | ✅M18 | `DateOverride` no UNIQUE `(doctor_id, date)` → ambiguous schedule | src/Appointment/Entity/DateOverride.php:12 | db-unique | **DONE** — UNIQUE(doctor_id,date). UniqueConstraintsTest | | ✅M19 | `FinancialBreakdown` no unique `(payment_id, source)` → double-accounting | src/Settlement/Entity/FinancialBreakdown.php:28-33 | db-unique | **DONE** — UNIQUE(payment_id,source). UniqueConstraintsTest | | M20 | Soft-ref FKs orphan (Billing/ClinicService): `ClaimItem.invoice_item_id`, `Claim.insurance_id`, `Tariff.service_item_id`, `TenantServiceCoverage.service_item_id`, `DoctorAddress.clinicId`, polymorphic `SmsWallet`, `ClinicStaff` | src/Billing/Entity/ClaimItem.php:22 · Claim.php:48 · ClinicService/Entity/Tariff.php:23 · Insurance/Entity/TenantServiceCoverage.php:26 · Doctor/Entity/DoctorAddress.php:32 · Sms/Entity/SmsWallet.php:18-22 · Staff/Entity/ClinicStaff.php:22-26 | db-ondelete | Delete parent → child handled (cascade/restrict/set-null), no orphan | -| M21 | ~30 ad-hoc raw error codes not in ErrorCodes.php (USER_NOT_FOUND, INVALID_ROLE, SLOT_TAKEN, DUPLICATE_REQUEST, ERR_GONE, ERR_ACCESS_DENIED, …) → `message()` returns "خطای ناشناخته" | src/Admin/Controller/AdminApiController.php (many) · MyAppointmentsController.php:41-80 · PreRegistrationController.php:66 · CategoryController.php:36-40 · ClinicInvitationController.php:203 | quality-errorcodes | Trigger each → code present in ErrorCodes.php | +| ✅M21 | ~30 ad-hoc raw error codes not in ErrorCodes.php (USER_NOT_FOUND, INVALID_ROLE, SLOT_TAKEN, DUPLICATE_REQUEST, ERR_GONE, ERR_ACCESS_DENIED, …) → `message()` returns "خطای ناشناخته" | src/Admin/Controller/AdminApiController.php (many) · MyAppointmentsController.php:41-80 · PreRegistrationController.php:66 · CategoryController.php:36-40 · ClinicInvitationController.php:203 | quality-errorcodes | **DONE (wire-safe)** — registered 14 legacy codes as constants in ErrorCodes.php (values unchanged so clients unaffected) + messages; replaced raw strings across Admin/MyAppointments/Category/PreRegistration/ClinicInvitation. `tests/Shared/ErrorCodesTest`. | --- diff --git a/src/Admin/Controller/AdminApiController.php b/src/Admin/Controller/AdminApiController.php index b98159de..0fe13377 100644 --- a/src/Admin/Controller/AdminApiController.php +++ b/src/Admin/Controller/AdminApiController.php @@ -3,6 +3,7 @@ namespace App\Admin\Controller; use App\Appointment\Entity\Appointment; +use App\Shared\Constant\ErrorCodes; use App\Appointment\Repository\SlotTakenException; use App\Auth\Entity\User; use App\Shared\Service\InputValidator; @@ -103,7 +104,7 @@ class AdminApiController extends BaseController public function toggleUserStatus(string $uuid): JsonResponse { $user = $this->em->getRepository(User::class)->findOneBy(['uuid' => $uuid]); - if (!$user) return $this->error('USER_NOT_FOUND', 'کاربر یافت نشد', 404); + if (!$user) return $this->error(ErrorCodes::USER_NOT_FOUND, 'کاربر یافت نشد', 404); $user->setStatus($user->getStatus() === 1 ? 0 : 1); $this->em->flush(); @@ -115,7 +116,7 @@ class AdminApiController extends BaseController public function updateUserRole(string $uuid, Request $request): JsonResponse { $user = $this->em->getRepository(User::class)->findOneBy(['uuid' => $uuid]); - if (!$user) return $this->error('USER_NOT_FOUND', 'کاربر یافت نشد', 404); + if (!$user) return $this->error(ErrorCodes::USER_NOT_FOUND, 'کاربر یافت نشد', 404); $data = json_decode($request->getContent(), true) ?? []; $role = (string) ($data['role'] ?? ''); @@ -129,7 +130,7 @@ class AdminApiController extends BaseController ]; if (!isset($roleMap[$role])) { - return $this->error('INVALID_ROLE', 'نقش نامعتبر است', 422); + return $this->error(ErrorCodes::INVALID_ROLE, 'نقش نامعتبر است', 422); } $user->setRoles($roleMap[$role]); @@ -142,7 +143,7 @@ class AdminApiController extends BaseController public function userDetail(string $uuid): JsonResponse { $user = $this->em->getRepository(User::class)->findOneBy(['uuid' => $uuid]); - if (!$user) return $this->error('USER_NOT_FOUND', 'کاربر یافت نشد', 404); + if (!$user) return $this->error(ErrorCodes::USER_NOT_FOUND, 'کاربر یافت نشد', 404); return $this->success([ 'uuid' => $user->getUuid(), @@ -162,7 +163,7 @@ class AdminApiController extends BaseController public function updateUser(string $uuid, Request $request): JsonResponse { $user = $this->em->getRepository(User::class)->findOneBy(['uuid' => $uuid]); - if (!$user) return $this->error('USER_NOT_FOUND', 'کاربر یافت نشد', 404); + if (!$user) return $this->error(ErrorCodes::USER_NOT_FOUND, 'کاربر یافت نشد', 404); $data = json_decode($request->getContent(), true) ?? []; @@ -190,7 +191,7 @@ class AdminApiController extends BaseController public function deleteUser(string $uuid): JsonResponse { $user = $this->em->getRepository(User::class)->findOneBy(['uuid' => $uuid]); - if (!$user) return $this->error('USER_NOT_FOUND', 'کاربر یافت نشد', 404); + if (!$user) return $this->error(ErrorCodes::USER_NOT_FOUND, 'کاربر یافت نشد', 404); $this->em->remove($user); $this->em->flush(); @@ -284,7 +285,7 @@ class AdminApiController extends BaseController public function toggleDoctorStatus(string $uuid): JsonResponse { $doctor = $this->em->getRepository(Doctor::class)->findOneBy(['uuid' => $uuid]); - if (!$doctor) return $this->error('DOCTOR_NOT_FOUND', 'پزشک یافت نشد', 404); + if (!$doctor) return $this->error(ErrorCodes::DOCTOR_NOT_FOUND, 'پزشک یافت نشد', 404); $doctor->setActiveDoctorAppointment(!$doctor->isActiveDoctorAppointment()); $this->em->flush(); @@ -390,10 +391,10 @@ class AdminApiController extends BaseController $name = trim((string) ($data['name'] ?? '')); if ($mobile === '' || $name === '') { - return $this->error('VALIDATION', 'موبایل و نام الزامی هستند', 422); + return $this->error(ErrorCodes::VALIDATION, 'موبایل و نام الزامی هستند', 422); } if (!InputValidator::isValidIranMobile($mobile)) { - return $this->error('VALIDATION', 'شماره موبایل نامعتبر است', 422, 'mobile'); + return $this->error(ErrorCodes::VALIDATION, 'شماره موبایل نامعتبر است', 422, 'mobile'); } $user = $this->em->getRepository(User::class)->findOneBy(['mobileNumber' => $mobile]); @@ -406,7 +407,7 @@ class AdminApiController extends BaseController $existing = $this->em->getRepository(Doctor::class)->findOneBy(['user' => $user]); if ($existing) { - return $this->error('DOCTOR_EXISTS', 'این کاربر قبلاً پروفایل پزشک دارد', 409); + return $this->error(ErrorCodes::DOCTOR_EXISTS, 'این کاربر قبلاً پروفایل پزشک دارد', 409); } $doctor = new Doctor($user, $name); @@ -503,13 +504,13 @@ class AdminApiController extends BaseController $name = trim((string) ($data['name'] ?? '')); if ($mobile === '') { - return $this->error('VALIDATION', 'شماره موبایل الزامی است', 422); + return $this->error(ErrorCodes::VALIDATION, 'شماره موبایل الزامی است', 422); } if (!InputValidator::isValidIranMobile($mobile)) { - return $this->error('VALIDATION', 'شماره موبایل نامعتبر است', 422, 'owner_mobile'); + return $this->error(ErrorCodes::VALIDATION, 'شماره موبایل نامعتبر است', 422, 'owner_mobile'); } if ($name === '') { - return $this->error('VALIDATION', 'نام کلینیک الزامی است', 422); + return $this->error(ErrorCodes::VALIDATION, 'نام کلینیک الزامی است', 422); } $user = $this->em->getRepository(User::class)->findOneBy(['mobileNumber' => $mobile]); @@ -540,7 +541,7 @@ class AdminApiController extends BaseController public function toggleClinicStatus(string $uuid): JsonResponse { $clinic = $this->em->getRepository(Clinic::class)->findOneBy(['uuid' => $uuid]); - if (!$clinic) return $this->error('CLINIC_NOT_FOUND', 'کلینیک یافت نشد', 404); + if (!$clinic) return $this->error(ErrorCodes::CLINIC_NOT_FOUND, 'کلینیک یافت نشد', 404); $clinic->setIsActive(!$clinic->isActive()); $this->em->flush(); @@ -552,7 +553,7 @@ class AdminApiController extends BaseController public function deleteClinic(string $uuid): JsonResponse { $clinic = $this->em->getRepository(Clinic::class)->findOneBy(['uuid' => $uuid]); - if (!$clinic) return $this->error('CLINIC_NOT_FOUND', 'کلینیک یافت نشد', 404); + if (!$clinic) return $this->error(ErrorCodes::CLINIC_NOT_FOUND, 'کلینیک یافت نشد', 404); $this->insuranceCleanup->purgeForEntity(\App\Insurance\Entity\TenantInsurance::TYPE_CLINIC, $clinic->getId()); $this->em->remove($clinic); @@ -794,11 +795,11 @@ class AdminApiController extends BaseController $patientName = trim($data['patient_name'] ?? ''); if (empty($doctorUuid) || $slotStart <= 0 || $slotEnd <= $slotStart || empty($mobile) || empty($patientName)) { - return $this->error('VALIDATION', 'doctor_uuid، slot_start، slot_end، patient_mobile و patient_name الزامی است', 422); + return $this->error(ErrorCodes::VALIDATION, 'doctor_uuid، slot_start، slot_end، patient_mobile و patient_name الزامی است', 422); } $doctor = $this->em->getRepository(Doctor::class)->findOneBy(['uuid' => $doctorUuid]); - if (!$doctor) return $this->error('DOCTOR_NOT_FOUND', 'پزشک یافت نشد', 404); + if (!$doctor) return $this->error(ErrorCodes::DOCTOR_NOT_FOUND, 'پزشک یافت نشد', 404); $patient = $this->em->getRepository(User::class)->findOneBy(['mobileNumber' => $mobile]); if (!$patient) { @@ -816,7 +817,7 @@ class AdminApiController extends BaseController try { $this->em->getRepository(Appointment::class)->bookAtomically($appointment); } catch (SlotTakenException) { - return $this->error('SLOT_TAKEN', 'این نوبت قبلاً رزرو شده است', 409); + return $this->error(ErrorCodes::SLOT_TAKEN, 'این نوبت قبلاً رزرو شده است', 409); } return $this->success([ @@ -1515,7 +1516,7 @@ class AdminApiController extends BaseController ->getQuery()->getArrayResult(); if (empty($row)) { - return $this->error('NOT_FOUND', 'درخواست تسویه یافت نشد', 404); + return $this->error(ErrorCodes::NOT_FOUND, 'درخواست تسویه یافت نشد', 404); } $s = $row[0]; diff --git a/src/Appointment/Controller/MyAppointmentsController.php b/src/Appointment/Controller/MyAppointmentsController.php index 958e6ee8..5b33fcc7 100644 --- a/src/Appointment/Controller/MyAppointmentsController.php +++ b/src/Appointment/Controller/MyAppointmentsController.php @@ -3,6 +3,7 @@ namespace App\Appointment\Controller; use App\Appointment\Entity\Appointment; +use App\Shared\Constant\ErrorCodes; use App\Appointment\Repository\AppointmentRepository; use App\Appointment\Repository\SlotTakenException; use App\Appointment\Service\SlotCalculatorService; @@ -42,7 +43,7 @@ class MyAppointmentsController extends BaseController $roles = $user->getRoles(); $allowed = ['ROLE_DOCTOR', 'ROLE_CLINIC', 'ROLE_SECRETARY', 'ROLE_ADMIN']; if (!array_intersect($allowed, $roles)) { - return $this->error('FORBIDDEN', 'دسترسی ندارید', 403); + return $this->error(ErrorCodes::FORBIDDEN, 'دسترسی ندارید', 403); } $data = json_decode($request->getContent(), true) ?? []; @@ -53,18 +54,18 @@ class MyAppointmentsController extends BaseController $patientName = trim($data['patient_name'] ?? ''); if (empty($doctorUuid) || $slotStart <= 0 || $slotEnd <= $slotStart || empty($mobile) || empty($patientName)) { - return $this->error('VALIDATION', 'همه فیلدها الزامی است', 422); + return $this->error(ErrorCodes::VALIDATION, 'همه فیلدها الزامی است', 422); } if ($slotStart < time()) { - return $this->error('SLOT_PAST', 'زمان این اسلات گذشته است', 422); + return $this->error(ErrorCodes::SLOT_PAST, 'زمان این اسلات گذشته است', 422); } $doctor = $this->doctorRepo->findByUuid($doctorUuid); - if (!$doctor) return $this->error('DOCTOR_NOT_FOUND', 'پزشک یافت نشد', 404); + if (!$doctor) return $this->error(ErrorCodes::DOCTOR_NOT_FOUND, 'پزشک یافت نشد', 404); if (!$this->canBookForDoctor($user, $doctor)) { - return $this->error('FORBIDDEN', 'برای این پزشک مجاز به ثبت نوبت نیستید', 403); + return $this->error(ErrorCodes::FORBIDDEN, 'برای این پزشک مجاز به ثبت نوبت نیستید', 403); } $patient = $this->em->getRepository(User::class)->findOneBy(['mobileNumber' => $mobile]); @@ -83,7 +84,7 @@ class MyAppointmentsController extends BaseController try { $this->appointmentRepo->bookAtomically($appointment); } catch (SlotTakenException) { - return $this->error('SLOT_TAKEN', 'این نوبت قبلاً رزرو شده است', 409); + return $this->error(ErrorCodes::SLOT_TAKEN, 'این نوبت قبلاً رزرو شده است', 409); } return $this->success([ diff --git a/src/Auth/Controller/PreRegistrationController.php b/src/Auth/Controller/PreRegistrationController.php index 9ba09bd7..720dfd12 100644 --- a/src/Auth/Controller/PreRegistrationController.php +++ b/src/Auth/Controller/PreRegistrationController.php @@ -63,7 +63,7 @@ class PreRegistrationController extends BaseController } if ($this->preRegRepo->hasPendingForMobile($mobile)) { - return $this->error('DUPLICATE_REQUEST', 'درخواست ثبت‌نام شما در حال بررسی است', 409); + return $this->error(ErrorCodes::DUPLICATE_REQUEST, 'درخواست ثبت‌نام شما در حال بررسی است', 409); } $preReg = new PreRegistration($type, $name, $mobile, $info); diff --git a/src/Category/Controller/CategoryController.php b/src/Category/Controller/CategoryController.php index a3529da2..af4d61ec 100644 --- a/src/Category/Controller/CategoryController.php +++ b/src/Category/Controller/CategoryController.php @@ -3,6 +3,7 @@ namespace App\Category\Controller; use App\Shared\Controller\BaseController; +use App\Shared\Constant\ErrorCodes; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\Routing\Attribute\Route; use OpenApi\Attributes as OA; @@ -33,7 +34,7 @@ class CategoryController extends BaseController $newUrl = $map[$bundle] ?? null; if ($newUrl === null) { - return $this->error('ERR_GONE', 'این endpoint حذف شده است', 410); + return $this->error(ErrorCodes::ERR_GONE, 'این endpoint حذف شده است', 410); } return $this->error( diff --git a/src/ClinicInvitation/Controller/ClinicInvitationController.php b/src/ClinicInvitation/Controller/ClinicInvitationController.php index 2a7093f0..baea1908 100644 --- a/src/ClinicInvitation/Controller/ClinicInvitationController.php +++ b/src/ClinicInvitation/Controller/ClinicInvitationController.php @@ -3,6 +3,7 @@ namespace App\ClinicInvitation\Controller; use App\Auth\Entity\User; +use App\Shared\Constant\ErrorCodes; use App\ClinicInvitation\Entity\ClinicDoctorInvitation; use App\ClinicInvitation\Repository\ClinicDoctorInvitationRepository; use App\ClinicInvitation\Service\ClinicInvitationService; @@ -200,7 +201,7 @@ class ClinicInvitationController extends BaseController if ($user->hasRole('ROLE_CLINIC') && $clinic->getUser()->getId() === $user->getId()) { return; } - throw new AppException('ERR_ACCESS_DENIED', 'دسترسی ندارید', 403); + throw new AppException(ErrorCodes::ERR_ACCESS_DENIED, 'دسترسی ندارید', 403); } // ── Public endpoints ────────────────────────────────────────────────────── diff --git a/src/Shared/Constant/ErrorCodes.php b/src/Shared/Constant/ErrorCodes.php index 369fce6c..aeb41a6b 100644 --- a/src/Shared/Constant/ErrorCodes.php +++ b/src/Shared/Constant/ErrorCodes.php @@ -83,6 +83,23 @@ class ErrorCodes public const ERR_IBAN_LIMIT = 'ERR_IDENTITY_003'; public const ERR_NATIONAL_CODE_UNVERIFIED = 'ERR_IDENTITY_004'; + // Legacy domain string codes — centralised here so message() resolves them. + // Wire values are kept identical (e.g. 'SLOT_TAKEN') for client compatibility. + public const VALIDATION = 'VALIDATION'; + public const FORBIDDEN = 'FORBIDDEN'; + public const NOT_FOUND = 'NOT_FOUND'; + public const USER_NOT_FOUND = 'USER_NOT_FOUND'; + public const DOCTOR_NOT_FOUND = 'DOCTOR_NOT_FOUND'; + public const DOCTOR_EXISTS = 'DOCTOR_EXISTS'; + public const CLINIC_NOT_FOUND = 'CLINIC_NOT_FOUND'; + public const SLOT_TAKEN = 'SLOT_TAKEN'; + public const SLOT_PAST = 'SLOT_PAST'; + public const INVALID_ROLE = 'INVALID_ROLE'; + public const DUPLICATE_REQUEST = 'DUPLICATE_REQUEST'; + public const ERR_GONE = 'ERR_GONE'; + public const ERR_MOVED = 'ERR_MOVED'; + public const ERR_ACCESS_DENIED = 'ERR_ACCESS_DENIED'; + public static function message(string $code): string { return match ($code) { @@ -127,6 +144,20 @@ class ErrorCodes self::ERR_IBAN_MISMATCH => 'شماره شبا متعلق به شما نیست', self::ERR_IBAN_LIMIT => 'حداکثر دو شماره شبا می‌توانید ثبت کنید', self::ERR_NATIONAL_CODE_UNVERIFIED => 'ابتدا کد ملی خود را تأیید کنید', + self::VALIDATION => 'ورودی نامعتبر است', + self::FORBIDDEN => 'دسترسی مجاز نیست', + self::NOT_FOUND => 'منبع یافت نشد', + self::USER_NOT_FOUND => 'کاربر یافت نشد', + self::DOCTOR_NOT_FOUND => 'پزشک یافت نشد', + self::DOCTOR_EXISTS => 'این پزشک قبلاً ثبت شده است', + self::CLINIC_NOT_FOUND => 'کلینیک یافت نشد', + self::SLOT_TAKEN => 'این نوبت قبلاً رزرو شده است', + self::SLOT_PAST => 'زمان این اسلات گذشته است', + self::INVALID_ROLE => 'نقش نامعتبر است', + self::DUPLICATE_REQUEST => 'درخواست تکراری است', + self::ERR_GONE => 'این منبع دیگر در دسترس نیست', + self::ERR_MOVED => 'این منبع منتقل شده است', + self::ERR_ACCESS_DENIED => 'دسترسی مجاز نیست', default => 'خطای ناشناخته', }; } diff --git a/tests/Shared/ErrorCodesTest.php b/tests/Shared/ErrorCodesTest.php new file mode 100644 index 00000000..4bf85996 --- /dev/null +++ b/tests/Shared/ErrorCodesTest.php @@ -0,0 +1,38 @@ +assertSame('SLOT_TAKEN', ErrorCodes::SLOT_TAKEN); + $this->assertSame('VALIDATION', ErrorCodes::VALIDATION); + $this->assertSame('USER_NOT_FOUND', ErrorCodes::USER_NOT_FOUND); + $this->assertSame('DOCTOR_NOT_FOUND', ErrorCodes::DOCTOR_NOT_FOUND); + $this->assertSame('FORBIDDEN', ErrorCodes::FORBIDDEN); + $this->assertSame('ERR_ACCESS_DENIED', ErrorCodes::ERR_ACCESS_DENIED); + } + + public function testMessagesResolve(): void + { + $codes = [ + ErrorCodes::VALIDATION, ErrorCodes::FORBIDDEN, ErrorCodes::NOT_FOUND, + ErrorCodes::USER_NOT_FOUND, ErrorCodes::DOCTOR_NOT_FOUND, ErrorCodes::DOCTOR_EXISTS, + ErrorCodes::CLINIC_NOT_FOUND, ErrorCodes::SLOT_TAKEN, ErrorCodes::SLOT_PAST, + ErrorCodes::INVALID_ROLE, ErrorCodes::DUPLICATE_REQUEST, ErrorCodes::ERR_GONE, + ErrorCodes::ERR_MOVED, ErrorCodes::ERR_ACCESS_DENIED, + ]; + foreach ($codes as $code) { + $this->assertNotSame('خطای ناشناخته', ErrorCodes::message($code), "message() did not resolve $code"); + } + } +}