From da57ac9c5b7fd301b2ed893a879ebcb7534e5d85 Mon Sep 17 00:00:00 2001 From: hamed <15238-genius.ha@users.noreply.drupalcode.org> Date: Mon, 15 Jun 2026 19:01:27 +0330 Subject: [PATCH] fix(user-profile): resolve profile by user uuid and auto-create when missing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET/PATCH /api/v1/user-profile/{uuid} treated {uuid} as the profile's own uuid, but clients pass the user's uuid — and a freshly OTP-registered user has no profile row, so the call always 404'd. Add resolveProfile(): try profile uuid, then user uuid → that user's profile, and (for the current user or an admin) lazy-create an empty profile so the client always gets an editable one. Foreign/unknown uuids still 404 with no leak. Co-Authored-By: Claude Opus 4.8 --- .../user-profile-resolve-by-user-uuid.md | 147 ++++++++++++++++++ .../Controller/UserProfileController.php | 45 +++++- 2 files changed, 190 insertions(+), 2 deletions(-) create mode 100644 .claude/prompt/user-profile-resolve-by-user-uuid.md diff --git a/.claude/prompt/user-profile-resolve-by-user-uuid.md b/.claude/prompt/user-profile-resolve-by-user-uuid.md new file mode 100644 index 00000000..3e427519 --- /dev/null +++ b/.claude/prompt/user-profile-resolve-by-user-uuid.md @@ -0,0 +1,147 @@ +# رفع 404 پروفایل کاربر: resolve با user-uuid و بازگرداندن پروفایل خالی برای کاربر تازه + +## پروژه + +`clinicpro` (Backend). **این پرامپت اول اجرا شود.** + +> **Cross-repo:** قرارداد `GET /api/v1/user-profile/{uuid}` توسط سایت عمومی (`nobat724_front`) مصرف می‌شود. پرامپت همتای frontend: +> `nobat724_front/.claude/prompt/user-profile-no-404.md` + +## زمینه + +سایت عمومی بعد از لاگین، پروفایل کاربر را با `GET /api/v1/user-profile/{uuid}` می‌گیرد و `uuid` را از کوکی auth (یعنی **uuid کاربر**) می‌فرستد. اما این endpoint پروفایل را با `findByUuid($uuid)` پیدا می‌کند که `{uuid}` را **uuid خودِ پروفایل** فرض می‌کند — نه uuid کاربر. `UserProfile` یک `uuid` مستقل دارد (هنگام ساخت پروفایل تولید می‌شود) که با uuid کاربر فرق دارد. + +علاوه بر این، کاربری که تازه با OTP ثبت‌نام کرده **اصلاً ردیف پروفایل ندارد** (فلوی OTP فقط `User` می‌سازد؛ پروفایل با `POST /user-profile` ساخته می‌شود). + +نتیجه: برای کاربر `09210651788` (تأییدشده در DB: `user_uuid=4d19830c-842a-4a79-b56c-198110cd73e2`, `profile_uuid=NULL`)، فراخوانی `GET /user-profile/4d19830c-...` همیشه **404 «پروفایل یافت نشد»** می‌دهد — هم به‌خاطر mismatch و هم به‌خاطر نبودِ پروفایل. این داشبورد و مرحله‌ی تکمیل اطلاعاتِ نوبت‌گیری را برای هر کاربر جدید می‌شکند. + +## مشکل / هدف + +`GET /api/v1/user-profile/{uuid}` باید: +1. `{uuid}` را **هم به‌عنوان uuid پروفایل و هم uuid کاربر** قبول کند (مثل الگوی weekly-schedule که هر دو را امتحان می‌کند). +2. اگر کاربرِ احرازشده پروفایل ندارد، به‌جای 404 یک **پروفایل خالی برای همان کاربر بسازد و برگرداند** (یا یک شیء پروفایل پیش‌فرض با مقادیر null) — تا frontend همیشه یک پروفایل قابل‌نمایش/ویرایش بگیرد. +3. کنترل دسترسی حفظ شود: کاربر فقط پروفایل خودش (یا ادمین هر پروفایلی). + +## فایل‌های مرتبط + +| فایل | نقش | +|------|-----| +| `src/UserProfile/Controller/UserProfileController.php` | `show()` (GET) — محل اصلی رفع؛ `canAccess`, `hydrate` | +| `src/UserProfile/Repository/UserProfileRepository.php` | `findByUuid`, `findByUser` | +| `src/Auth/Repository/UserRepository.php` | `findByUuid` (برای resolve با user-uuid) | +| `src/UserProfile/Entity/UserProfile.php` | `uuid` مستقل + `getUser()`؛ `toArray()` شامل `uuid` و `user_uuid` | +| `docs/api/user-profile.md` | مستندسازی رفتار جدید GET | + +## وضعیت فعلی (کد واقعی) + +### `UserProfileController::show()` — فقط با profile-uuid + +```php +#[Route('/api/v1/user-profile/{uuid}', methods: ['GET'])] +public function show(string $uuid, #[CurrentUser] User $user): JsonResponse +{ + $profile = $this->repository->findByUuid($uuid); // ❌ فقط profile 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()]); +} +``` + +### `UserProfile` — uuid مستقل از کاربر + +```php +#[ORM\Column(type: 'string', length: 36, unique: true)] +private string $uuid; // پروفایل، تولیدشده هنگام ساخت + +#[ORM\OneToOne(targetEntity: User::class)] +private User $user; // کاربر مالک +// toArray(): 'uuid' => profile uuid, 'user_uuid' => $this->user->getUuid() +``` + +### repositoryها + +```php +// UserProfileRepository +public function findByUser(User $user): ?UserProfile { ... } +public function findByUuid(string $uuid): ?UserProfile { ... } +// UserRepository +public function findByUuid(string $uuid): ?User { ... } +``` + +## وظایف + +### ۱. تزریق `UserRepository` به کنترلر + +```php +public function __construct( + private readonly UserProfileRepository $repository, + private readonly UserRepository $userRepository, +) {} +``` + +### ۲. بازنویسی `show()` — resolve با profile-uuid یا user-uuid + پروفایل خالی برای کاربر جدید + +```php +#[Route('/api/v1/user-profile/{uuid}', methods: ['GET'])] +public function show(string $uuid, #[CurrentUser] User $user): JsonResponse +{ + // 1) تلاش با profile uuid + $profile = $this->repository->findByUuid($uuid); + + // 2) fallback: uuid را به‌عنوان user uuid تفسیر کن + if ($profile === null) { + $targetUser = $this->userRepository->findByUuid($uuid); + if ($targetUser !== null) { + $profile = $this->repository->findByUser($targetUser); + + // 3) کاربر وجود دارد ولی پروفایل ندارد → برای خودِ کاربر، پروفایل خالی بساز/برگردان + if ($profile === null) { + if ($targetUser->getId() !== $user->getId() && !$user->hasRole('ROLE_ADMIN')) { + return $this->error(ErrorCodes::ERR_AUTH_006, 'دسترسی ممنوع', 403); + } + $profile = new UserProfile($targetUser); + $this->repository->save($profile); // lazy-create تا frontend پروفایل قابل‌ویرایش بگیرد + } + } + } + + 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()]); +} +``` + +> **تصمیم lazy-create:** ساخت پروفایل خالی هنگام اولین GET، ساده‌ترین راه است تا frontend همیشه یک شیء با `uuid`/`user_uuid` بگیرد و PATCH بعدی کار کند. اگر ترجیح می‌دهی **بدون ساخت در DB** فقط یک پروفایل پیش‌فرضِ in-memory برگردانی (بدون persist)، آن هم قابل‌قبول است — ولی آنگاه PATCH با profile-uuid کار نمی‌کند تا اول POST شود. **یکی را انتخاب کن و در گزارش ذکر کن؛ lazy-create توصیه می‌شود.** + +### ۳. (اختیاری) PATCH هم با user-uuid کار کند + +- اگر می‌خواهی فلوی frontend بدون نیاز به دانستن profile-uuid کامل شود، در `update()` (PATCH) هم همان resolve دو-مرحله‌ای را اعمال کن (profile uuid → user uuid → پروفایلِ کاربر). اگر پروفایل نبود، یا بساز یا 404. این کار `POST` و `PATCH` جداگانه در frontend را ساده می‌کند. +- اگر این را انجام دادی، در مستندات ذکر کن. + +### ۴. مستندسازی + +در `docs/api/user-profile.md` بخش GET: توضیح بده `{uuid}` می‌تواند **profile uuid یا user uuid** باشد، و برای کاربری که هنوز پروفایل ندارد یک پروفایل خالی (با فیلدهای null) برگردانده می‌شود (و در صورت lazy-create، ساخته می‌شود). همان برای PATCH اگر تغییر دادی. + +## نکات مهم + +- **هیچ تغییر Entity یا migration لازم نیست** (فقط منطق کنترلر). +- **دسترسی:** کاربر فقط پروفایل خودش؛ ادمین همه. هنگام resolve با user-uuid، قبل از lazy-create حتماً چک کن `targetUser` همان کاربر احرازشده است (یا ادمین) — وگرنه 403. سپس `canAccess` نهایی هم اعمال شود. +- **idempotency:** `POST /user-profile` از قبل اگر پروفایل وجود داشته باشد 409 می‌دهد؛ بعد از lazy-create در GET، فراخوانی POST بعدیِ frontend ممکن است 409 بگیرد — frontend باید با این سازگار شود (در پرامپت frontend ذکر شده). به همین دلیل بهتر است frontend به‌جای POST از PATCH استفاده کند. +- پاسخ‌ها از `BaseController` (`success`/`error`)؛ خطاها با `ErrorCodes`. +- `toArray()` هم `uuid` (پروفایل) و هم `user_uuid` را برمی‌گرداند — frontend می‌تواند بعد از اولین GET، `uuid` پروفایل را برای PATCHهای بعدی نگه دارد. +- تست: + - `ddev exec php -l src/UserProfile/Controller/UserProfileController.php` + - با توکن کاربر `09210651788` (که `profile_uuid=NULL` است): `GET /api/v1/user-profile/4d19830c-842a-4a79-b56c-198110cd73e2` باید **200** با پروفایل خالی برگرداند (نه 404). + - GET با profile-uuid واقعی هم باید کار کند (عدم رگرسیون). + - GET با user-uuidِ کاربر دیگر (نه ادمین) → 403. + - بعد از تغییر، `docs/api/user-profile.md` را به‌روز کن. diff --git a/src/UserProfile/Controller/UserProfileController.php b/src/UserProfile/Controller/UserProfileController.php index 24c74c43..4047d91a 100644 --- a/src/UserProfile/Controller/UserProfileController.php +++ b/src/UserProfile/Controller/UserProfileController.php @@ -3,6 +3,7 @@ namespace App\UserProfile\Controller; use App\Auth\Entity\User; +use App\Auth\Repository\UserRepository; use App\Shared\Constant\ErrorCodes; use App\Shared\Controller\BaseController; use App\UserProfile\Entity\UserProfile; @@ -20,6 +21,7 @@ class UserProfileController extends BaseController { public function __construct( private readonly UserProfileRepository $repository, + private readonly UserRepository $userRepository, ) {} #[Route('/api/v1/user-profile', methods: ['POST'])] @@ -40,7 +42,9 @@ class UserProfileController extends BaseController #[Route('/api/v1/user-profile/{uuid}', methods: ['GET'])] public function show(string $uuid, #[CurrentUser] User $user): JsonResponse { - $profile = $this->repository->findByUuid($uuid); + // {uuid} may be a profile uuid or a user uuid; a user with no profile yet + // gets an empty one created so the client always has an editable profile. + $profile = $this->resolveProfile($uuid, $user, true); if ($profile === null) { return $this->error(ErrorCodes::ERR_VALIDATION_002, 'پروفایل یافت نشد', 404); @@ -56,7 +60,7 @@ class UserProfileController extends BaseController #[Route('/api/v1/user-profile/{uuid}', methods: ['PATCH'])] public function update(string $uuid, Request $request, #[CurrentUser] User $user): JsonResponse { - $profile = $this->repository->findByUuid($uuid); + $profile = $this->resolveProfile($uuid, $user, true); if ($profile === null) { return $this->error(ErrorCodes::ERR_VALIDATION_002, 'پروفایل یافت نشد', 404); @@ -88,6 +92,43 @@ class UserProfileController extends BaseController return $this->success(['message' => 'پروفایل با موفقیت حذف شد']); } + /** + * Resolve a profile from a uuid that may be the profile's own uuid or the + * owning user's uuid. When $createIfMissing is true and the uuid belongs to + * a user (the current user or, for admins, anyone) without a profile, an + * empty profile is created and persisted. + */ + private function resolveProfile(string $uuid, User $currentUser, bool $createIfMissing): ?UserProfile + { + $profile = $this->repository->findByUuid($uuid); + if ($profile !== null) { + return $profile; + } + + $targetUser = $this->userRepository->findByUuid($uuid); + if ($targetUser === null) { + return null; + } + + $profile = $this->repository->findByUser($targetUser); + if ($profile !== null) { + return $profile; + } + + if (!$createIfMissing) { + return null; + } + + if ($targetUser->getId() !== $currentUser->getId() && !$currentUser->hasRole('ROLE_ADMIN')) { + return null; + } + + $profile = new UserProfile($targetUser); + $this->repository->save($profile); + + return $profile; + } + private function canAccess(UserProfile $profile, User $currentUser): bool { return $profile->getUser()->getId() === $currentUser->getId()