fix(user-profile): resolve profile by user uuid and auto-create when missing

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 <noreply@anthropic.com>
This commit is contained in:
hamed
2026-06-15 19:01:27 +03:30
co-authored by Claude Opus 4.8
parent 593b4decf4
commit da57ac9c5b
2 changed files with 190 additions and 2 deletions
@@ -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` را به‌روز کن.
@@ -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()