fix(user-profile): don't lazy-create when an admin views another user
resolveProfile created an empty profile for any caller with create-intent, including an admin merely viewing someone else's profile. Restrict lazy-create to the user's own profile; an admin reading another user's missing profile now gets 404 with no side-effect record. Doc updated. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,113 @@
|
|||||||
|
# نمایش پروفایل کاربر در صفحهی جزئیات کاربرِ پنل ادمین
|
||||||
|
|
||||||
|
## پروژه
|
||||||
|
|
||||||
|
`clinicpro` (Admin React frontend). تغییر عمدتاً frontend است؛ endpoint پروفایل از قبل موجود است و به ادمین دسترسی میدهد. (یک تصمیم دربارهی side-effect لازم است — به بخش «نکات» نگاه کن.)
|
||||||
|
|
||||||
|
## زمینه
|
||||||
|
|
||||||
|
صفحهی `/admin/users/{uuid}` (`assets/admin/pages/UserDetailPage.tsx`) فقط اطلاعات **حساب کاربری** را از `GET /api/v1/admin/users/{uuid}` نشان میدهد: نام، موبایل، ایمیل، نقشها، وضعیت، UUID. اما **پروفایل بیمار** (کد ملی، جنسیت، تاریخ تولد، گروه خونی، وضعیت تأهل، تحصیلات، شغل، آدرس، تلفنها، بیمه، سوابق پزشکی) را نشان نمیدهد. ادمین باید بتواند پروفایل کامل کاربر را همینجا ببیند.
|
||||||
|
|
||||||
|
پروفایل از `GET /api/v1/user-profile/{uuid}` میآید که **هم با user-uuid کار میکند** (resolve شده) و **به `ROLE_ADMIN` اجازهی دیدن هر پروفایلی را میدهد** (`canAccess` → admin مجاز). پاسخ دوبار تودرتو است: `{ success, data: { data: {...profile...} } }`.
|
||||||
|
|
||||||
|
## مشکل / هدف
|
||||||
|
|
||||||
|
به `UserDetailPage.tsx` یک بخش «پروفایل کاربر» اضافه کن که با `GET /api/v1/user-profile/{uuid}` پروفایل را میگیرد و فیلدها را بهصورت **فقط-خواندنی و خوانا** (کارتهای اطلاعات + بخش سوابق پزشکی) نمایش میدهد. اگر کاربر پروفایل پر نکرده، حالت «ثبت نشده» نشان داده شود (نه خطا).
|
||||||
|
|
||||||
|
## فایلهای مرتبط
|
||||||
|
|
||||||
|
| فایل | نقش |
|
||||||
|
|------|-----|
|
||||||
|
| `assets/admin/pages/UserDetailPage.tsx` | صفحهی جزئیات کاربر — محل افزودن بخش پروفایل |
|
||||||
|
| `assets/admin/lib/api.ts` | `api.get`, `ApiResponse`, JWT از `localStorage['clinicpro-auth']` |
|
||||||
|
| `assets/admin/lib/utils.ts` | `formatDate`, `formatDateTime` (و در صورت نیاز `formatNumber`) |
|
||||||
|
| `src/UserProfile/Controller/UserProfileController.php` | `show()` — مرجع قرارداد (تغییر نمیکند، مگر تصمیم read-only گرفته شود) |
|
||||||
|
| `src/UserProfile/Entity/UserProfile.php` | `toArray()` — فیلدهای پروفایل |
|
||||||
|
|
||||||
|
## وضعیت فعلی (کد واقعی)
|
||||||
|
|
||||||
|
### `UserDetailPage.tsx` — فقط user query
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const { data, isLoading, isError } = useQuery({
|
||||||
|
queryKey: ['admin-user', uuid],
|
||||||
|
queryFn: () => api.get<ApiResponse<AdminUserDetail>>(`/api/v1/admin/users/${uuid}`),
|
||||||
|
enabled: !!uuid,
|
||||||
|
});
|
||||||
|
const user: AdminUserDetail | undefined = (data?.data as any)?.data ?? data?.data;
|
||||||
|
// ... فقط نام/موبایل/ایمیل/نقش/وضعیت/UUID رندر میشود؛ پروفایل نیست
|
||||||
|
```
|
||||||
|
|
||||||
|
### قرارداد پروفایل (`UserProfile.toArray`)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"uuid": "...", "user_uuid": "...",
|
||||||
|
"label": null, "family": null, "fathers_name": null,
|
||||||
|
"national_code": null, "national_code_approved": false,
|
||||||
|
"gender": null, "date_of_birth": null, "blood_type": null,
|
||||||
|
"marital_status": null, "education": null, "job": null,
|
||||||
|
"address": null, "home_phone": null, "work_phone": null,
|
||||||
|
"insurance_id": null, "basic_insurance_id": null, "supplementary_insurance_id": null,
|
||||||
|
"other": { "disease": [...], "allergies": [...], "medications": [...], "surgeries": [...], "family_history": [...], "relatives": [...] },
|
||||||
|
"sharing_with_user": false, "description": null,
|
||||||
|
"created_at": ..., "updated_at": ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
> پاسخ: `{ success, data: { data: {...} } }` → استخراج با `res?.data?.data ?? res?.data`.
|
||||||
|
|
||||||
|
## وظایف
|
||||||
|
|
||||||
|
### ۱. کوئری پروفایل در `UserDetailPage.tsx`
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
interface UserProfileData {
|
||||||
|
uuid: string; user_uuid: string;
|
||||||
|
label: string | null; family: string | null; fathers_name: string | null;
|
||||||
|
national_code: string | null; gender: string | null; date_of_birth: number | null;
|
||||||
|
blood_type: string | null; marital_status: string | null; education: string | null;
|
||||||
|
job: string | null; address: string | null; home_phone: string | null; work_phone: string | null;
|
||||||
|
basic_insurance_id: number | null; supplementary_insurance_id: number | null;
|
||||||
|
other: Record<string, any> | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const profileQ = useQuery({
|
||||||
|
queryKey: ['admin-user-profile', uuid],
|
||||||
|
queryFn: () => api.get<ApiResponse<any>>(`/api/v1/user-profile/${uuid}`),
|
||||||
|
enabled: !!uuid,
|
||||||
|
retry: false,
|
||||||
|
});
|
||||||
|
const profile: UserProfileData | undefined =
|
||||||
|
(profileQ.data as any)?.data?.data ?? (profileQ.data as any)?.data;
|
||||||
|
```
|
||||||
|
|
||||||
|
### ۲. بخش «پروفایل کاربر» (read-only) در رندر
|
||||||
|
|
||||||
|
- یک کارت `cp-card` جدید بعد از گرید اطلاعات موجود اضافه کن با عنوان «پروفایل کاربر».
|
||||||
|
- از همان کامپوننت `InfoCard` موجود برای فیلدها استفاده کن (آیکن + label + value؛ مقدار null → «ثبت نشده» خودش هندل میشود).
|
||||||
|
- فیلدها با برچسب فارسی: نام، نام خانوادگی، نام پدر، کد ملی، جنسیت (`male`→مرد/`female`→زن)، تاریخ تولد (شمسی)، گروه خونی، وضعیت تأهل، تحصیلات، شغل، آدرس، تلفن منزل، تلفن محل کار، شماره بیمه پایه/تکمیلی.
|
||||||
|
- بخش **سوابق پزشکی** (از `other`): آلرژیها، داروها، جراحیها، سابقهی خانوادگی، بیماریها — هرکدام لیست؛ اگر خالی، «موردی ثبت نشده». ساختار آیتمهای `other.*` را از دادهی واقعی استنتاج کن (با یک `curl` نمونه)، توهمسازی نکن.
|
||||||
|
- تاریخ تولد: اگر `date_of_birth` عدد (Unix) است با `formatDate` شمسی؛ اگر رشتهی شمسی ذخیره شده، همان را نشان بده (شکل واقعی را با curl چک کن).
|
||||||
|
|
||||||
|
### ۳. حالتهای loading / خالی
|
||||||
|
|
||||||
|
- وقتی `profileQ.isLoading` → skeleton کوچک.
|
||||||
|
- وقتی پروفایل همهی فیلدهایش null است (کاربر چیزی پر نکرده) → پیام «این کاربر هنوز پروفایلی تکمیل نکرده است» بهجای کارتهای خالی، یا کارتها با «ثبت نشده».
|
||||||
|
- خطا (۴۰۳/۴۰۴ نامحتمل چون ادمین است) → یک پیام کوچک، نه شکستن کل صفحه.
|
||||||
|
|
||||||
|
## نکات مهم
|
||||||
|
|
||||||
|
- **side-effect مهم — تصمیم لازم:** `GET /api/v1/user-profile/{uuid}` در حال حاضر برای کاربری که پروفایل ندارد، **یک پروفایل خالی lazy-create میکند** (در `resolveProfile`). برای «دیدن» توسط ادمین، ساختِ رکورد هنگام GET نامطلوب است. **یکی را انتخاب کن:**
|
||||||
|
1. (ساده) همین رفتار را بپذیر — یک پروفایل خالی برای کاربر ساخته میشود؛ بیضرر ولی side-effect دارد.
|
||||||
|
2. (تمیزتر، توصیهشده) در `UserProfileController::resolveProfile`، lazy-create را فقط وقتی انجام بده که درخواستدهنده **خودِ کاربر** است، نه ادمینی که پروفایل دیگری را میبیند؛ یعنی برای ادمینِ بیننده، اگر پروفایل نبود `null` برگردان (→ ۴۰۴ یا «پروفایل ندارد») بدون ساخت. این یک تغییر کوچک backend است (`docs/api/user-profile.md` را هم بهروز کن).
|
||||||
|
- **اگر گزینه ۲ را انتخاب کردی، این بخش backend است؛ آن را اول انجام بده و سند را بهروز کن.**
|
||||||
|
- **double-nesting:** پروفایل در `res.data.data` است (مثل بقیهی `success(['data'=>...])`).
|
||||||
|
- **فقط-خواندنی:** ادمین اینجا فقط پروفایل را میبیند؛ ویرایش پروفایل بیمار توسط ادمین خارج از این پرامپت است (اگر خواستی پیشنهاد بده ولی پیاده نکن).
|
||||||
|
- از کلاسهای CSS و کامپوننتهای موجود (`cp-card`, `InfoCard`, `skeleton`) استفاده کن؛ کتابخانهی جدید نیار؛ RTL.
|
||||||
|
- تاریخها شمسی با `formatDate`/`formatDateTime` موجود.
|
||||||
|
- **تست:**
|
||||||
|
- `ddev exec npx tsc --noEmit --project tsconfig.json 2>&1 | head -30`
|
||||||
|
- `ddev exec yarn dev` (build admin) — خطای CSS lightningcss از قبل هست و بلوک نمیکند؛ فقط خطای TS مهم است.
|
||||||
|
- یک `curl` واقعی روی `GET /api/v1/user-profile/7a63a8d2-6483-4ae7-9c38-54c399cde699` با توکن ادمین برای دیدن شکل دقیق `other`/`date_of_birth`.
|
||||||
|
- در مرورگر: `/admin/users/{uuid}` بخش پروفایل را با دادهی واقعی یا «ثبت نشده» نشان دهد.
|
||||||
|
- اگر گزینه ۲ را انتخاب کردی: `ddev exec php -l` + بهروزرسانی `docs/api/user-profile.md`.
|
||||||
@@ -97,7 +97,7 @@ Get a user profile.
|
|||||||
|-------|------|-------------|
|
|-------|------|-------------|
|
||||||
| `uuid` | string (UUID) | **Profile UUID or the owning User UUID** — both are accepted |
|
| `uuid` | string (UUID) | **Profile UUID or the owning User UUID** — both are accepted |
|
||||||
|
|
||||||
> **Auto-resolve & lazy-create:** `{uuid}` is first looked up as a profile uuid, then as a user uuid. If it is the **current user's** (or, for admins, any user's) uuid and that user has **no profile yet**, an empty profile is created and returned (HTTP 200) — so a freshly registered user always gets an editable profile instead of a 404. The response includes both the profile `uuid` and `user_uuid`; keep the profile `uuid` for subsequent PATCHes.
|
> **Auto-resolve & lazy-create:** `{uuid}` is first looked up as a profile uuid, then as a user uuid. If it is the **current user's own** uuid and they have **no profile yet**, an empty profile is created and returned (HTTP 200) — so a freshly registered user always gets an editable profile instead of a 404. An **admin** reading **another** user's missing profile gets `404` (no profile is created as a side effect of reading). The response includes both the profile `uuid` and `user_uuid`; keep the profile `uuid` for subsequent PATCHes.
|
||||||
|
|
||||||
### Response `200`
|
### Response `200`
|
||||||
Full profile object including all fields (all `null` for a newly created empty profile). Response is double-nested: extract with `data.data`.
|
Full profile object including all fields (all `null` for a newly created empty profile). Response is double-nested: extract with `data.data`.
|
||||||
|
|||||||
@@ -95,8 +95,9 @@ class UserProfileController extends BaseController
|
|||||||
/**
|
/**
|
||||||
* Resolve a profile from a uuid that may be the profile's own uuid or the
|
* 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
|
* 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
|
* the **current user** without a profile, an empty profile is created and
|
||||||
* empty profile is created and persisted.
|
* persisted. An admin viewing another user's missing profile gets null (no
|
||||||
|
* record is created as a side effect of reading).
|
||||||
*/
|
*/
|
||||||
private function resolveProfile(string $uuid, User $currentUser, bool $createIfMissing): ?UserProfile
|
private function resolveProfile(string $uuid, User $currentUser, bool $createIfMissing): ?UserProfile
|
||||||
{
|
{
|
||||||
@@ -115,11 +116,9 @@ class UserProfileController extends BaseController
|
|||||||
return $profile;
|
return $profile;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!$createIfMissing) {
|
// Only lazy-create for the user's own profile, never as a side effect of
|
||||||
return null;
|
// an admin reading someone else's.
|
||||||
}
|
if (!$createIfMissing || $targetUser->getId() !== $currentUser->getId()) {
|
||||||
|
|
||||||
if ($targetUser->getId() !== $currentUser->getId() && !$currentUser->hasRole('ROLE_ADMIN')) {
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user