feat(profile): enforce uniqueness of national_code across user profiles and update related error handling
This commit is contained in:
@@ -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 ثبت کن تا کلاینتها وصل شوند.
|
||||
@@ -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 |
|
||||
|
||||
---
|
||||
|
||||
@@ -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 |
|
||||
|
||||
|
||||
@@ -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 === '') {
|
||||
|
||||
@@ -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']);
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user