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:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user