diff --git a/.claude/prompt/national-code-unique-profile.md b/.claude/prompt/national-code-unique-profile.md new file mode 100644 index 00000000..5dafa64f --- /dev/null +++ b/.claude/prompt/national-code-unique-profile.md @@ -0,0 +1,149 @@ +# یکتا بودن کد ملی در پروفایل بیمار (هر کد ملی فقط یک پروفایل) + +## پروژه + +`clinicpro` (Backend — منبع واحد داده و constraint). + +> این تغییر cross-repo اثر دارد: سایت عمومی `nobat724_front` و پنل ادمین React (داخل همین پروژه) فقط باید خطای `409`/`422` جدید را نمایش دهند. قرارداد پاسخ خطا در «نکات مهم» آمده تا کلاینت‌ها مصرف کنند. تغییر منطق فقط backend است. + +## زمینه + +هر کاربر (`User`) دقیقاً یک پروفایل (`UserProfile`, جدول `profiles`, رابطه `OneToOne` با کلید یکتای `idx_profiles_user`) دارد. کد ملی هم روی `User` و هم روی `UserProfile` ذخیره می‌شود ولی **در هیچ‌کدام یکتا نیست** — فقط یک ایندکس غیر-یکتا روی `profiles.national_code` هست: + +```php +// src/UserProfile/Entity/UserProfile.php:12 +#[ORM\Index(columns: ['national_code'], name: 'idx_profiles_national_code')] +// src/UserProfile/Entity/UserProfile.php:36 +#[ORM\Column(name: 'national_code', type: 'string', length: 10, nullable: true)] +private ?string $nationalCode = null; + +// src/Auth/Entity/User.php:35 +#[ORM\Column(name: 'national_code', type: 'string', length: 10, nullable: true)] +private ?string $nationalCode = null; +``` + +پرامپت قبلی `fix-server-side-mobile-nationalcode-validation.md` فقط **فرمت** کد ملی (رقم کنترلی ایرانی) را سمت سرور اضافه کرد، نه **یکتایی** را. در نتیجه دو پروفایل مختلف می‌توانند یک کد ملی داشته باشند — چه از مسیر پروفایل عمومی، چه نوبت‌دهی توسط منشی. + +## مشکل / هدف + +کد ملی باید در سطح بیمار یکتا باشد: نباید دو پروفایل جدا با یک کد ملی وجود داشته باشد. وقتی کاربر/منشی/هر مسیرِ نوبت‌دهی کد ملی‌ای ثبت می‌کند که قبلاً به پروفایل **کاربر دیگری** تعلق دارد، باید با خطای `409` رد شود (نه ذخیره). + +دامنه‌ی یکتایی: **`UserProfile.national_code`** (منبع نهاییِ هویت بیمار، رابطه‌ی ۱:۱ با کاربر). `User.nationalCode` در حد همگام‌سازی است و قید یکتایی روی پروفایل کافی است. + +## فایل‌های مرتبط + +| فایل | نقش | +|------|-----| +| `src/UserProfile/Entity/UserProfile.php` | افزودن قید یکتای partial روی `national_code` | +| `src/UserProfile/Repository/UserProfileRepository.php` | متد `findOneByNationalCode()` جدید | +| `src/UserProfile/Controller/UserProfileController.php` | `hydrate()` / `update()` — چک یکتایی قبل از set | +| `src/Shared/Constant/ErrorCodes.php` | کد خطای جدید برای کد ملی تکراری | +| `migrations/VersionXXXX.php` (جدید) | unique index روی `profiles.national_code` | +| `docs/api/user-profile.md` | مستندسازی پاسخ `409` | + +## وضعیت فعلی (کد واقعی) + +`UserProfileController::hydrate` — بدون چک یکتایی ذخیره می‌شود: + +```php +// src/UserProfile/Controller/UserProfileController.php:211 +if (array_key_exists('national_code', $data)) $profile->setNationalCode($data['national_code']); +``` + +`update()` فقط فرمت را چک می‌کند (از پرامپت قبلی)، یکتایی نه: + +```php +// src/UserProfile/Controller/UserProfileController.php:134 +if (array_key_exists('national_code', $data) && $data['national_code'] !== null && $data['national_code'] !== '') { + $code = InputValidator::toEnglishDigits((string) $data['national_code']); + if (!InputValidator::isValidIranNationalCode($code)) { + return $this->error(ErrorCodes::ERR_VALIDATION_001, 'کد ملی نامعتبر است', 422, 'national_code'); + } + $data['national_code'] = $code; +} +``` + +## وظایف + +### ۱. قید یکتای دیتابیس (partial — فقط مقادیر non-null) + +در `UserProfile.php` ایندکس فعلی را به unique تبدیل کن. چون `national_code` nullable است و چند پروفایلِ بدون کد ملی باید مجاز بمانند، در MariaDB رفتار پیش‌فرضِ unique index این است که چندین `NULL` مجاز است (دقیقاً همان partial-unique موردنظر) — پس کافی است index را unique کنیم: + +```php +// به‌جای خط 12 +#[ORM\UniqueConstraint(name: 'uniq_profiles_national_code', columns: ['national_code'])] +``` + +سپس migration بساز: + +```bash +ddev exec php bin/console doctrine:migrations:diff --no-interaction +``` + +**هشدار داده‌ی موجود:** قبل از migrate، تکراری‌های فعلی را پیدا کن وگرنه migration روی `ALTER TABLE` می‌شکند: + +```sql +SELECT national_code, COUNT(*) c FROM profiles +WHERE national_code IS NOT NULL AND national_code <> '' +GROUP BY national_code HAVING c > 1; +``` + +اگر تکراری بود، در توضیحات خروجی به کاربر گزارش بده (پاک‌سازی دستی لازم است؛ خودسرانه merge/حذف نکن). + +### ۲. متد repository + +در `UserProfileRepository.php`: + +```php +public function findOneByNationalCode(string $nationalCode): ?UserProfile +{ + return $this->findOneBy(['nationalCode' => $nationalCode]); +} +``` + +### ۳. چک یکتایی در سطح اپلیکیشن (قبل از flush) + +قید DB لایه‌ی آخر است؛ برای پیام تمیز و جلوگیری از `UniqueConstraintViolationException`، در `UserProfileController::update()` بعد از validation فرمت و قبل از `hydrate`، یکتایی را چک کن. اگر کد ملی به پروفایلی با `uuid` **متفاوت** تعلق دارد → `409`: + +```php +// داخل update()، بعد از بلوک normalize/validate فرمت (خط ~140) +if (isset($data['national_code']) && $data['national_code'] !== '') { + $existing = $this->repository->findOneByNationalCode($data['national_code']); + if ($existing !== null && $existing->getUuid() !== $profile->getUuid()) { + return $this->error( + ErrorCodes::ERR_PROFILE_NATIONAL_CODE_TAKEN, + 'این کد ملی قبلاً برای کاربر دیگری ثبت شده است', + 409, + 'national_code' + ); + } +} +``` + +### ۴. کد خطا + +در `src/Shared/Constant/ErrorCodes.php` کنار سایر `ERR_VALIDATION_*` اضافه کن: + +```php +public const ERR_PROFILE_NATIONAL_CODE_TAKEN = 'ERR_PROFILE_001'; +``` + +و پیام فارسی متناظرش را در همان نگاشتِ پیام‌ها (همان‌جا که بقیه کدها پیام دارند) قرار بده: «این کد ملی قبلاً برای کاربر دیگری ثبت شده است». + +### ۵. مسیرهای نوبت‌دهی منشی / سایر + +هر نقطه‌ای که از مسیرِ غیر-پروفایل کد ملی روی پروفایل یا کاربر می‌نشاند، باید همین چک `findOneByNationalCode` را قبل از set اجرا کند. بررسی کن این نقاط را و در صورت وجود set کد ملی روی `UserProfile`/`User`، چک را اضافه کن: + +- `src/Patient/Controller/PatientController.php` (`createPatient` — خطوط ~133–160، set روی `User`) +- `src/Staff/Controller/StaffController.php` (خطوط 64, 91 — `ClinicStaff.nationalCode`؛ این **پرسنل** است نه بیمار، فقط در صورتی که staff به پروفایل بیمار map می‌شود چک لازم است — اگر نه، دست نزن) + +برای مسیر منشی/پنل که از `UserProfileController::update` می‌گذرد، وظیفه‌ی ۳ کافی است و نیازی به تکرار نیست. + +## نکات مهم + +- **partial-unique با NULL:** عمداً `NULL` را آزاد می‌گذاریم تا پروفایل‌های ناقصِ بدون کد ملی (که سیستم به‌صورت lazy می‌سازد — `resolveProfile`، خط 193) نشکنند. هرگز رشته‌ی خالی `''` ذخیره نکن؛ همیشه `null`. اگر جایی `''` می‌نشیند، به `null` نرمالایز کن وگرنه دو پروفایلِ `''` قید را می‌شکنند. +- **دامنه‌ی یکتایی = global** (نه per-clinic). یک کد ملی در کل سیستم = یک بیمار. +- چک اپلیکیشنی **race-condition** دارد (دو request همزمان)؛ قید DB لایه‌ی نهایی است. اگر خواستی، `UniqueConstraintViolationException` را در یک try/catch دور `save()` بگیر و همان `409` را برگردان تا حالت رقابتی هم پیام تمیز بدهد. +- همه‌ی پاسخ‌ها از `BaseController`؛ خطا با `$this->error($code, $msg, 409, 'national_code')`. +- بعد از تغییر `UserProfileController`، طبق قانون استاندارد پروژه `docs/api/user-profile.md` را در همین session به‌روز کن (افزودن پاسخ `409`). +- **مصرف‌کنندگان (cross-repo):** سایت عمومی و پنل ادمین باید کد `ERR_PROFILE_001` / status `409` را روی فیلد `national_code` نمایش دهند. این در همین پرامپت پیاده نمی‌شود؛ فقط قرارداد را در doc ثبت کن تا کلاینت‌ها وصل شوند. diff --git a/docs/api/patient.md b/docs/api/patient.md index 00717b08..4d40bc9d 100644 --- a/docs/api/patient.md +++ b/docs/api/patient.md @@ -86,6 +86,7 @@ Creates a patient record for a user under the current entity. If the record alre - اگر `user_uuid` و `mobile` هر دو خالی باشند → خطا. - `national_code` فقط وقتی روی کاربر ست می‌شود که کاربر کد ملی نداشته باشد. - موبایل تکراری duplicate نمی‌سازد؛ همان کاربر استفاده می‌شود. +- **یکتایی کد ملی:** اگر `national_code` ارسالی قبلاً به پروفایل کاربر دیگری تعلق داشته باشد → `409` با کد `ERR_PROFILE_001` (`field: national_code`). یک کد ملی = یک بیمار در کل سیستم (هم‌راستا با قید یکتای `profiles.national_code`). **Response 201:** @@ -109,6 +110,7 @@ Creates a patient record for a user under the current entity. If the record alre | Code | HTTP | Description | |------|------|-------------| | `ERR_VALIDATION_001` | 422 | `user_uuid`/`mobile` خالی، یا موبایل/کد ملی نامعتبر، یا نام برای بیمار جدید خالی | +| `ERR_PROFILE_001` | 409 | کد ملی قبلاً برای پروفایل کاربر دیگری ثبت شده (`field: national_code`) | | `ERR_SUBSCRIPTION_REQUIRED` | 403 | No `patient_records` feature | --- diff --git a/docs/api/user-profile.md b/docs/api/user-profile.md index 2e6890f5..56a5ba61 100644 --- a/docs/api/user-profile.md +++ b/docs/api/user-profile.md @@ -82,6 +82,8 @@ Create a medical profile for the authenticated user. | Code | HTTP | Description | |------|------|-------------| | `ERR_AUTH_001` | 401 | Missing token | +| `ERR_VALIDATION_001` | 422 | `national_code` نامعتبر (رقم کنترلی/طول غلط) (`field: national_code`) | +| `ERR_PROFILE_001` | 409 | کد ملی قبلاً برای پروفایل کاربر دیگری ثبت شده (`field: national_code`) | | `ERR_CONFLICT_001` | 409 | Profile already exists for this user | --- @@ -126,6 +128,8 @@ Update a user profile. Same fields as POST — all optional. > **`national_code` server-side validation:** اگر `national_code` ارسال شود و **غیرخالی** باشد، با الگوریتم رقم کنترلیِ کد ملی ایران اعتبارسنجی می‌شود (ارقام فارسی/عربی به انگلیسی نرمال و به‌صورت لاتین ذخیره می‌شوند). مقدارِ نامعتبر با `422` رد می‌شود. ارسال `null` یا رشته‌ی خالی مجاز است (کد ملی اختیاری) و فیلد را پاک می‌کند. +> +> **یکتایی کد ملی:** کد ملی در کل سیستم یکتاست (یک کد ملی = یک بیمار). اگر کد ملی ارسالی قبلاً به پروفایل **کاربر دیگری** تعلق داشته باشد، با `409` و کد `ERR_PROFILE_001` رد می‌شود (`field: national_code`). در سطح دیتابیس هم با `UNIQUE INDEX uniq_profiles_national_code` تضمین شده (مقادیر `NULL` آزادند). همین قید روی مسیر ساخت بیمار توسط منشی (`POST /api/v1/patient`) نیز اعمال می‌شود. ### Response `200` Updated profile object. @@ -135,6 +139,7 @@ Updated profile object. |------|------|-------------| | `ERR_AUTH_001` | 401 | Missing token | | `ERR_VALIDATION_001` | 422 | `national_code` نامعتبر (رقم کنترلی/طول غلط) (`field: national_code`) | +| `ERR_PROFILE_001` | 409 | کد ملی قبلاً برای پروفایل کاربر دیگری ثبت شده (`field: national_code`) | | `ERR_FORBIDDEN_001` | 403 | Not the profile owner | | `ERR_NOT_FOUND_001` | 404 | Profile not found | diff --git a/migrations/Version20260624030339.php b/migrations/Version20260624030339.php new file mode 100644 index 00000000..de97cc74 --- /dev/null +++ b/migrations/Version20260624030339.php @@ -0,0 +1,31 @@ +addSql('ALTER TABLE profiles DROP INDEX idx_profiles_national_code, ADD UNIQUE INDEX uniq_profiles_national_code (national_code)'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('ALTER TABLE profiles DROP INDEX uniq_profiles_national_code, ADD INDEX idx_profiles_national_code (national_code)'); + } +} diff --git a/src/Patient/Controller/PatientController.php b/src/Patient/Controller/PatientController.php index f2b5bfa6..b5ce2737 100644 --- a/src/Patient/Controller/PatientController.php +++ b/src/Patient/Controller/PatientController.php @@ -145,6 +145,19 @@ class PatientController extends BaseController $patient = $this->userRepo->findByMobile($mobile); } + // کد ملی باید در سطح بیمار یکتا باشد: اگر به پروفایلِ کاربر دیگری تعلق دارد، رد کن + if ($nationalCode !== '') { + $owner = $this->profileRepo->findOneByNationalCode($nationalCode); + if ($owner !== null && ($patient === null || $owner->getUser()->getId() !== $patient->getId())) { + return $this->error( + ErrorCodes::ERR_PROFILE_NATIONAL_CODE_TAKEN, + 'این کد ملی قبلاً برای کاربر دیگری ثبت شده است', + 409, + 'national_code' + ); + } + } + // بیمار جدید بدون ثبت‌نام قبلی: موبایل + نام آمده ولی کاربری وجود ندارد if ($patient === null) { if ($mobile === '' || $name === '') { diff --git a/src/Shared/Constant/ErrorCodes.php b/src/Shared/Constant/ErrorCodes.php index 87496690..b6c8880a 100644 --- a/src/Shared/Constant/ErrorCodes.php +++ b/src/Shared/Constant/ErrorCodes.php @@ -63,6 +63,9 @@ class ErrorCodes public const ERR_PATIENT_NOT_FOUND = 'ERR_PATIENT_NOT_FOUND'; public const ERR_SESSION_NOT_FOUND = 'ERR_SESSION_NOT_FOUND'; + // Profile + public const ERR_PROFILE_NATIONAL_CODE_TAKEN = 'ERR_PROFILE_001'; + // SMS Wallet public const ERR_SMS_WALLET_INSUFFICIENT = 'ERR_SMS_WALLET_INSUFFICIENT'; @@ -106,6 +109,7 @@ class ErrorCodes self::ERR_SERVICE_ITEM_IN_USE => 'این سرویس در پرونده بیمار ثبت شده است', self::ERR_SERVICE_NOT_FOUND => 'سرویس یافت نشد', self::ERR_PATIENT_NOT_FOUND => 'پرونده بیمار یافت نشد', + self::ERR_PROFILE_NATIONAL_CODE_TAKEN => 'این کد ملی قبلاً برای کاربر دیگری ثبت شده است', self::ERR_SESSION_NOT_FOUND => 'مراجعه یافت نشد', self::ERR_SMS_WALLET_INSUFFICIENT => 'موجودی کیف پیامک کافی نیست', self::ERR_RATING_NOT_ELIGIBLE => 'برای ثبت نظر یا امتیاز باید در یک ماه گذشته نوبت تایید‌شده نزد این پزشک داشته باشید', diff --git a/src/UserProfile/Controller/UserProfileController.php b/src/UserProfile/Controller/UserProfileController.php index b4b1f244..4f6a1024 100644 --- a/src/UserProfile/Controller/UserProfileController.php +++ b/src/UserProfile/Controller/UserProfileController.php @@ -91,6 +91,11 @@ class UserProfileController extends BaseController } $data = json_decode($request->getContent(), true) ?? []; + + if ($error = $this->guardNationalCode($data, null)) { + return $error; + } + $profile = new UserProfile($user); $this->hydrate($profile, $data); $this->repository->save($profile); @@ -131,12 +136,8 @@ class UserProfileController extends BaseController $data = json_decode($request->getContent(), true) ?? []; - if (array_key_exists('national_code', $data) && $data['national_code'] !== null && $data['national_code'] !== '') { - $code = InputValidator::toEnglishDigits((string) $data['national_code']); - if (!InputValidator::isValidIranNationalCode($code)) { - return $this->error(ErrorCodes::ERR_VALIDATION_001, 'کد ملی نامعتبر است', 422, 'national_code'); - } - $data['national_code'] = $code; + if ($error = $this->guardNationalCode($data, $profile)) { + return $error; } $this->hydrate($profile, $data); @@ -202,6 +203,36 @@ class UserProfileController extends BaseController || $currentUser->hasRole('ROLE_ADMIN'); } + /** + * Normalize, format-validate and uniqueness-check national_code in $data. + * Mutates $data['national_code'] to the latin-digit form. Returns an error + * response if invalid or already taken by another profile, otherwise null. + */ + private function guardNationalCode(array &$data, ?UserProfile $current): ?JsonResponse + { + if (!array_key_exists('national_code', $data) || $data['national_code'] === null || $data['national_code'] === '') { + return null; + } + + $code = InputValidator::toEnglishDigits((string) $data['national_code']); + if (!InputValidator::isValidIranNationalCode($code)) { + return $this->error(ErrorCodes::ERR_VALIDATION_001, 'کد ملی نامعتبر است', 422, 'national_code'); + } + $data['national_code'] = $code; + + $existing = $this->repository->findOneByNationalCode($code); + if ($existing !== null && ($current === null || $existing->getUuid() !== $current->getUuid())) { + return $this->error( + ErrorCodes::ERR_PROFILE_NATIONAL_CODE_TAKEN, + 'این کد ملی قبلاً برای کاربر دیگری ثبت شده است', + 409, + 'national_code' + ); + } + + return null; + } + private function hydrate(UserProfile $profile, array $data): void { if (array_key_exists('name', $data)) $profile->setLabel($data['name']); diff --git a/src/UserProfile/Entity/UserProfile.php b/src/UserProfile/Entity/UserProfile.php index 90091b65..539e924d 100644 --- a/src/UserProfile/Entity/UserProfile.php +++ b/src/UserProfile/Entity/UserProfile.php @@ -9,7 +9,7 @@ use Symfony\Component\Uid\Uuid; #[ORM\Entity] #[ORM\Table(name: 'profiles')] #[ORM\UniqueConstraint(name: 'idx_profiles_user', columns: ['user_id'])] -#[ORM\Index(columns: ['national_code'], name: 'idx_profiles_national_code')] +#[ORM\UniqueConstraint(name: 'uniq_profiles_national_code', columns: ['national_code'])] class UserProfile { #[ORM\Id] diff --git a/src/UserProfile/Repository/UserProfileRepository.php b/src/UserProfile/Repository/UserProfileRepository.php index 1ef8d7be..86a4b3ae 100644 --- a/src/UserProfile/Repository/UserProfileRepository.php +++ b/src/UserProfile/Repository/UserProfileRepository.php @@ -24,6 +24,11 @@ class UserProfileRepository extends ServiceEntityRepository return $this->findOneBy(['uuid' => $uuid]); } + public function findOneByNationalCode(string $nationalCode): ?UserProfile + { + return $this->findOneBy(['nationalCode' => $nationalCode]); + } + public function save(UserProfile $profile, bool $flush = true): void { $this->getEntityManager()->persist($profile);