feat(profile): enforce uniqueness of national_code across user profiles and update related error handling

This commit is contained in:
hamed
2026-06-24 11:52:13 +03:30
parent 3eb82ffa7d
commit 650e36bce2
9 changed files with 247 additions and 7 deletions
@@ -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 ثبت کن تا کلاینت‌ها وصل شوند.
+2
View File
@@ -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 |
---
+5
View File
@@ -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 |
+31
View File
@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260624030339 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->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)');
}
}
@@ -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 === '') {
+4
View File
@@ -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 => 'برای ثبت نظر یا امتیاز باید در یک ماه گذشته نوبت تایید‌شده نزد این پزشک داشته باشید',
@@ -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']);
+1 -1
View File
@@ -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]
@@ -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);