feat(apps): a sales expert can work inside their customer's account (D29)

Reported missing by the owner: in the reference an expert could open any
of their referred customers and use the app AS them — browse, see
balance and debt, place orders — «یکجورایی انگار با اکانت اون ها لاگین
کرده». D21 had deliberately left it out as a session-hijack primitive.
The owner asked for the capability back with the implementation left to
judgement, so the scope is the reference's and the mechanism is not.

Scope: everything, as the reference had it. Its axios interceptor
swapped the bearer token, so every request went as the customer,
document upload included. No route is closed in act-as mode.

Mechanism: the reference minted and handed over the customer's own
permanent api_token. Here the expert keeps their own token and sends
X-Act-As-Customer, resolved in one place — CustomerJwtStrategy
.validateExpert. That single point is why all 17 customer routes work
unchanged: validate() already returns a principal whose `id` is a
Customer id and every customer route reads it. It also fixes four real
defects of the original:

  - ownership is re-checked on EVERY request, with referredByAgentId in
    the same where clause as the id, so another expert's customer and a
    non-existent id both answer 403, and a customer moved to another
    expert loses access at once rather than at token expiry;
  - nothing durable is handed out — the reference's token sat in
    localStorage with no expiry and kept working after the expert left;
  - adminUserId survives, so an order placed this way is attributable;
    in the reference the request was literally the customer's;
  - 403 rather than 401, so the app clears the act-as state instead of
    logging a perfectly valid session out.

Performance: one findFirst on the primary key, only when the header is
present, and it replaces the trading-account lookup rather than adding
to it.

Three things the reference never needed, because it swapped the whole
session. queryCache's `me:` keys, favouritesStore and wageQuoteStore all
dropped their data on a TOKEN change, which act-as does not cause — so
an expert would have seen their own profile, orders, balance, ledger and
favourites inside the customer's account. The wage-quote one is the
money case: making charge is per customer, so the figure on screen would
not have been the one the order was priced at. And uploadDocument builds
its own headers, so an expert's upload would have been filed against
their own account, silently.

Added with no reference equivalent: a permanent «در حال مشاهده به‌عنوان
…» bar. There, impersonation was invisible once the token had been
swapped, and placing an order on the wrong customer's gold debt was one
tap away. It is also the exit — the reference's only way out was a
logout that ended the expert's own session too. The cart is cleared in
both directions.

CustomerJwtStrategy had no test coverage at all and is now where this
feature's whole authorisation boundary lives: act-as-customer.spec.ts
covers 11 cases, including the header being ignored on a customer token,
the no-header path asserting the extra query does not run, and a revoked
session short-circuiting before the header is read.

api: 308/308 tests, 28 suites; both apps vue-tsc clean.

اپ: کارشناس فروش می‌تواند وارد حساب مشتریان خود شود، وضعیتشان را ببیند و از طرفشان سفارش ثبت کند.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
s.esmaeili
2026-10-12 11:48:59 +03:30
co-authored by Claude Opus 5
parent c518f9995b
commit ddcc8c5649
28 changed files with 1337 additions and 46 deletions
+188
View File
@@ -0,0 +1,188 @@
# طرح — بازگرداندن «ورود کارشناس به حساب مشتری» (act-as)
تاریخ: ۱۴۰۵/۰۷/۲۰ (2026-10-12)
## وضعیت: ✅ **انجام شد** — تصمیم مالک ۲۰۲۶/۱۰/۱۲
> «نه دقیقا همون چیزی که بود در ساختار مرجع همون رو بیار ولی با ساختار درستی که
> خودت میدونی»
یعنی: **دامنه کامل مرجع**، با سازوکار امنِ بند ۳. پس **بند ۴ (مرزها) حذف شد** —
هیچ مسیری در حالت act-as بسته نیست، آپلود مدارک و logout هم باز است، چون مرجع
هم interceptor سراسری داشت و همه‌چیز از طرف مشتری می‌رفت.
بقیه طرح عیناً اجرا شد. سه چیز که در طرح نبود و در حین کار پیدا شد (هر سه رفع
شد): پاک‌سازی `queryCache`/علاقه‌مندی/اجرت کش‌شده در جابجایی، و هدر در مسیر
آپلود که `request()` را دور می‌زند. جزئیات: `ISSUES.md` EXP-01/02/03 و D29.
---
### متن اصلی طرح (برای سابقه)
> **خواسته مالک:** «کارشناس فروش میتونه از طریق اپ خودش وارد صفحه مشتری هاش بشه و
> از جانب اون ها ثبت سفارش کنه و وضعیت اون هارو ببینه و یکجورایی انگار با اکانت
> اون ها لاگین کرده همه چیز رو از دید اون مشتریش میدید… همان قابلیت هارو میخوام،
> حالا هرجور که فکر میکنی از لحاظ امنیت و پرفورمنس بهتره.»
**همان قابلیت، بدون سه عیبِ روش قدیم.** تصمیم‌های فنی زیر با من است؛ هرجا چیزی
از مرجع **کم** شده باشد، صریح نوشته‌ام.
---
## ۱. مرجع چه می‌کرد (بررسی‌شده، نه حدس)
| مرحله | کد مرجع |
|---|---|
| لیست مشتریان | `POST /my-child` → `ApiMobileController.php:185-196`، فیلتر `reagent_id = auth()->id()` |
| **توکن مشتری ساخته می‌شد** | `:197-201` — اگر `api_token` مشتری خالی بود، `Str::random(60)` می‌ساخت و **ذخیره** می‌کرد |
| کلیک روی کارت | `customerChoice.vue:155` → `changeUser()` → `myStore.toggleActiveUser(user)` |
| ذخیره توکن | `myDataStore.ts:163` → `localStorage.setItem('activeChildToken', value.api_token)` |
| اعمال روی همه درخواست‌ها | `tokenManage.ts:8` `getUserToken()` = توکن مشتری **یا** توکن خودش → `plugins/axios.ts:18` روی **هر** درخواست |
| خروج از حساب مشتری | `Dashboard.vue:784` → پاک کردن `activeChildToken` |
یعنی دامنه مرجع: **هر کاری که مشتری می‌توانست**، چون interceptor سراسری بود.
## ۲. سه عیبی که تکرار نمی‌کنم
۱. **توکن دائمی مشتری در دست کارشناس.** `api_token` انقضا نداشت و در
`localStorage` می‌ماند. کارشناسی که فردا قطع همکاری می‌کرد، توکن مشتریانش را
داشت و همچنان کار می‌کرد.
۲. **هیچ ردی نمی‌ماند.** درخواست عیناً از طرف مشتری می‌آمد، پس در سرور معلوم
نبود این سفارش را مشتری ثبت کرده یا کارشناس. برای مالی، این یعنی غیرقابل
حسابرسی.
۳. **بررسی مالکیت فقط یک‌بار.** توکن در لحظه گرفتن صادر می‌شد؛ بعد از آن اگر
مشتری به کارشناس دیگری منتقل می‌شد، توکن قدیمی همچنان کار می‌کرد.
## ۳. طراحی پیشنهادی
**هسته: توکنِ مشتری صادر نمی‌شود. کارشناس با توکن خودش درخواست می‌دهد و
می‌گوید «از طرف مشتری X».**
### چرا این انتخاب (و نه گزینه‌های دیگر)
`CustomerJwtStrategy.validate()` در تارگت همیشه یک `AuthenticatedCustomer`
می‌سازد که `id` آن **یک Customer id** است، و **هر ۱۷ مسیر مشتری** از همان
استفاده می‌کنند. پس اگر جابجایی را همان‌جا اعمال کنم، همه آن مسیرها **بدون یک خط
تغییر** از دید مشتری کار می‌کنند — همان «همه‌چیز را از دید او می‌بیند» که خواستی،
با کمترین سطح تماس با کد.
| گزینه | نتیجه | رأی |
|---|---|---|
| صدور توکن مشتری (روش مرجع) | ساده، ولی هر سه عیب بالا | ❌ |
| پارامتر `customerId` روی هر مسیر | باید ۱۷ مسیر و همه سرویس‌ها عوض شوند؛ فراموش کردن یکی = حفره | ❌ |
| **هدر `X-Act-As-Customer` + اعمال در strategy** | یک نقطه تغییر، هر ۱۷ مسیر خودکار، قابل حسابرسی | ✅ |
### سمت سرور
**۱. هدر درخواست.** کارشناس توکن خودش را می‌فرستد به‌علاوه
`X-Act-As-Customer: <customerId>`.
**۲. اعمال در `validateExpert`** (تنها نقطه تغییر منطق دسترسی):
```
اگر هدر بود و principal کارشناس بود:
مشتری را بخوان: id = هدر، referredByAgentId = admin.id، isActive، !isArchived
اگر نبود → 403 (مشتری دیگری، یا مشتریِ کارشناس دیگر)
اگر بود → { id: customer.id, ← از دید مشتری
actingAsCustomerId: customer.id,
adminUserId: admin.id, ← هویت واقعی حفظ می‌شود
type: 'sales-expert' }
```
نکات مهم این طراحی:
- **بررسی مالکیت در هر درخواست** انجام می‌شود، نه یک‌بار. مشتری که به کارشناس
دیگری منتقل شود، دسترسی **در همان لحظه** قطع می‌شود. (عیب ۳ رفع شد.)
- کارشناس غیرفعال/تنزل‌یافته همان‌جا ۴۰۱ می‌گیرد، چون `validateExpert` از قبل
`isActive` و `role` را هر بار می‌خواند. (عیب ۱ رفع شد.)
- `adminUserId` حفظ می‌شود ⇒ هویت واقعی هرگز گم نمی‌شود. (عیب ۲ رفع شد.)
- توکن **عوض نمی‌شود**؛ پس چیزی در `localStorage` نمی‌نشیند که بعداً سوءاستفاده
شود. بستن اپ = پایان حالت act-as.
**۳. هزینه پرفورمنس (خواسته‌ات).** یک `findUnique` اضافه روی `Customer` با
`where: { id }` — کلید اصلی، پس index-only. **فقط** وقتی هدر وجود دارد؛ یعنی
درخواست‌های مشتری عادی و کارشناس در حالت معمولی **صفر هزینه اضافه** دارند. در
حالت act-as هم جایگزین کوئریِ `tradingCustomer` می‌شود، نه اضافه بر آن — پس
تعداد کوئری ثابت می‌ماند.
**۴. ⛔ این بند اجرا نشد.** مالک دامنه کامل مرجع را خواست، پس هیچ‌کدام از این
مرزها گذاشته نشد — همه مسیرها در حالت act-as باز است:
| مسیر | در حالت act-as | چرا |
|---|---|---|
| `POST /orders` | ✅ مجاز | خواسته اصلی |
| مشاهده: `me`، `me/orders`، `me/ledger-entries`، `me/report*`، `accounting-balance`، `wage-quote` | ✅ مجاز | «وضعیت آن‌ها را ببیند» |
| علاقه‌مندی‌ها | ✅ مجاز | بی‌خطر، و بخشی از «از دید او» |
| `POST /customer/me/documents` و `complete-profile` | ❌ **۴۰۳** | آپلود مدارک هویتی به‌جای مشتری. کارشناس مدرکِ مشتری را ندارد و این کار جعل هویت است، نه خدمت. در پنل ادمین انجام می‌شود. |
| `POST /auth/logout` | ❌ **۴۰۳** | نشست مشتری را نباید کارشناس ببندد |
| مسیرهای `expert/*` | ❌ **۴۰۳** | در حالت act-as بی‌معناست؛ از ابهام جلوگیری می‌کند |
**۵. ثبت در سفارش.** `OrdersService.createOrder` از قبل `placedBy` دارد. سفارشِ
act-as با `placedBy: 'operator'` ثبت می‌شود — یعنی قیمت را سرور تحمیل نمی‌کند و
عدد کارشناس می‌ماند؟ **نه**: برای این حالت `placedBy: 'customer'` می‌گذارم تا
قیمت‌گذاری سرور (MON-08) اعمال شود، چون این سفارشِ مشتری است نه اپراتورِ پنل.
علاوه بر آن `salesAgentId` از قبل خودکار از `customer.referredByAgentId` پر
می‌شود — که همان کارشناس است. **ردّ حسابرسی** هم در لاگ ثبت می‌شود:
`expert <adminUserId> acting as customer <id>`.
**۶. پیامک.** مشتری پیامک «سفارش شما ثبت شد» را **می‌گیرد** (مثل مرجع). دلیل:
روی حساب و بدهی طلای او نشسته و باید بداند. کارشناس هم پیامک ارجاع‌دهنده را
می‌گیرد، چون `referredByAgentId` خودش است — رفتار موجود، تغییری لازم نیست.
### سمت اپ (هر دو اپ)
**۱. وضعیت act-as** در `sessionStore` (نه `localStorage`): `actingAs = {id, name}`.
چون در `sessionStorage`/حافظه است، بستن تب پایانش می‌دهد — برعکس مرجع.
**۲. کلاینت API**: اگر `actingAs` ست بود، هدر `X-Act-As-Customer` به همه
درخواست‌ها اضافه شود (یک نقطه، در `request()` کلاینت).
**۳. `CustomerChoiceView`**: زدن روی کارت ⇒ `changeUser` ⇒ ست کردن `actingAs` و
رفتن به خانه، **عیناً مثل مرجع** (`customerChoice.vue:155-167`).
**۴. ⚠️ نوار هشدار همیشگی** — چیزی که مرجع نداشت و از نظر من ضروری است: یک نوار
ثابت بالای صفحه «در حال مشاهده به‌عنوان **فلانی** — خروج». بدون این، کارشناس
نمی‌فهمد در حساب کیست و ممکن است سفارش را روی حساب اشتباهی ثبت کند. این ریسک
واقعیِ روش مرجع بود.
**۵. پاک کردن سبد در جابجایی.** سبد در `localStorage` است؛ اگر پاک نشود، کالاهای
سبدِ کارشناس وارد سفارش مشتری می‌شود. در جابجایی (ورود و خروج) سبد خالی می‌شود.
## ۴. تست‌ها
| تست | می‌سنجد |
|---|---|
| کارشناس + هدر مشتریِ خودش | داده‌های آن مشتری برمی‌گردد |
| کارشناس + هدر **مشتریِ کارشناس دیگر** | ۴۰۳ |
| کارشناس + هدر مشتری غیرفعال/آرشیو | ۴۰۳ |
| **مشتری عادی** + هدر | هدر **نادیده** گرفته می‌شود (نه ۴۰۳ خاموش؛ اصلاً اثر ندارد) |
| کارشناس غیرفعال‌شده + هدر معتبر | ۴۰۱ فوری |
| سفارش در حالت act-as | `customerId` = مشتری، `salesAgentId` = کارشناس، قیمت از سرور |
| مسیرهای ممنوع (مدارک، logout) | ۴۰۳ |
| بدون هدر | رفتار امروز، بدون تغییر (regression) |
## ۵. چه چیزی عمداً مثل مرجع نیست
| مرجع | اینجا | دلیل |
|---|---|---|
| توکن دائمی مشتری به کارشناس داده می‌شد | هیچ توکنی صادر نمی‌شود | سرقت نشست؛ بعد از قطع همکاری هم کار می‌کرد |
| `api_token` در `localStorage` | وضعیت در حافظه نشست | بستن اپ = پایان |
| مالکیت یک‌بار بررسی می‌شد | هر درخواست | انتقال مشتری فوراً اثر می‌کند |
| ~~کارشناس همه‌کاره بود~~ | ~~مدارک/logout ممنوع~~ | **اجرا نشد** — مالک دامنه کامل مرجع را خواست؛ کارشناس همه‌کاره است |
| هیچ ردی نبود | لاگ + `salesAgentId` | حسابرسی مالی |
| هیچ نشانه‌ای در UI نبود | نوار همیشگی | سفارش روی حساب اشتباه |
## ۶. برآورد
| بخش | کار |
|---|---|
| سرور | `app-principal.ts` (+`actingAsCustomerId`)، `customer-jwt.strategy.ts` (منطق)، یک گارد برای مسیرهای ممنوع، لاگ در `orders.service` |
| اپ‌ها (×۲) | `sessionStore`، `api/client.ts` (هدر)، `CustomerChoiceView` (changeUser)، نوار act-as، پاک کردن سبد |
| تست | ۸ مورد بالا در `apps/api` |
ترتیب اجرا: سرور + تست‌ها اول (قابل اثبات)، بعد اپ‌ها.
---
**منتظر تأیید.** اگر با مرزهای بند ۴ (ممنوع بودن آپلود مدارک و logout) موافق
نیستی بگو — بقیه طرح تصمیم فنی است و خودم جلو می‌برم.
+94
View File
@@ -991,3 +991,97 @@ apply in production. Full api suite **295/295, 27 suites**; both apps `vue-tsc`
clean.
_No data was deleted in this entry._
---
## 2026-10-12 — Phase 2 — «ورود به حساب مشتری» restored (EXP-01/02/03, D29)
- **What:** A sales expert can again open one of their referred customers'
accounts and use the whole app as them — browse, see balance, debt and
reports, and place orders that land on the customer's account. Entered from
the customer's card on «لیست مشتریان»; a permanent bar names whose account it
is and exits.
- **Why:** owner report — the reference had it («یکجورایی انگار با اکانت اون ها
لاگین کرده … و این خیلی کاربردی بود ولی الان نیست») and D21 had deliberately
left it out as a session-hijack primitive. The owner asked for the capability
back, with the implementation left to judgement: «همان قابلیت هارو میخوام حالا
هرجور که فکر میکنی از لحاظ امنیت و پرفورمنس بهتره».
- **Reference:** `customerChoice.vue:155-167` → `myDataStore.ts:159-169` →
`tokenManage.ts:8` → `plugins/axios.ts:18`; server
`ApiMobileController.php:197-201`.
- **Target:** `customer-auth/app-principal.ts`, `customer-jwt.strategy.ts`,
`orders.controller.ts`, `orders.service.ts`, `sales-expert-app.controller.ts`;
both apps' `stores/session.ts`, `api/client.ts`, `stores/queryCache.ts`,
`stores/favoritesStore.ts`, `stores/wageQuoteStore.ts`,
`views/expert/CustomerChoiceView.vue`, `components/expert/ActingAsBar.vue`,
`App.vue`.
- **Matrix row:** EXP-01, EXP-02, EXP-03 in `ISSUES.md`; D29 in `DECISIONS.md`;
D21's "deliberately not ported" paragraph marked superseded.
- **Commit:** see below.
**Scope is the reference's, in full.** Its axios interceptor swapped the bearer
token, so *every* request went as the customer — including document upload. No
route is closed in act-as mode. (The plan had proposed closing documents and
logout; the owner asked for the reference's behaviour, so that was dropped.)
**The mechanism is not the reference's.** It minted a permanent `api_token` per
customer and handed it over. Instead the expert keeps their own token and sends
`X-Act-As-Customer`, resolved in one place — `CustomerJwtStrategy.validateExpert`.
That single point is why all 17 customer routes work unchanged: `validate()`
already returns a principal whose `id` is a Customer id, and every customer route
reads it. Four consequences, each fixing a real defect of the original:
1. **Ownership is re-checked on every request**, with `referredByAgentId` in the
same `where` as the id — so another expert's customer and a non-existent id
give the same answer (403), and a customer moved to another expert loses
access at once rather than at token expiry.
2. **Nothing durable is handed out.** The reference's token sat in
`localStorage` with no expiry and kept working after the expert left. Here
the target is in `sessionStorage` and is only a hint; the authority is the
expert's own token plus the per-request check.
3. **The real actor survives.** `adminUserId` is kept, so an order placed this
way is logged as `sales expert <id> acting as them`. In the reference the
request was literally the customer's and nothing could be attributed
afterwards.
4. **403, not 401**, for a customer that is not theirs: the expert's own session
is valid, so the app clears the act-as state instead of logging them out.
**Performance.** One `findFirst` on the primary key, and only when the header is
present — it *replaces* the `tradingCustomer` lookup rather than adding to it, so
the query count is unchanged in act-as mode and untouched for everyone else.
**Three things the reference did not need, because it swapped the whole session.**
Found by reading the stores rather than assuming:
- `queryCache` (`me:` keys), `favoritesStore` and `wageQuoteStore` each dropped
their data on a **token** change. Act-as changes whose data they hold without
changing the token, so without this an expert would have seen their own
profile, orders, balance, ledger and favourites inside the customer's account.
The wage-quote one is the money case: making charge is per customer, so the
figure on screen would not have been the one the order was priced at. Each
watcher's own comment already stated the rule it was about to break.
- `uploadDocument` bypasses `request()` and builds its own headers, so an
expert's upload would have been filed against their own account, silently.
`grep Authorization` on the client returning two call sites is what caught it.
- The cart is in `localStorage`, so it is cleared in **both** directions —
otherwise the expert's own basket follows them in and ends up on the
customer's order.
**Added on purpose, with no reference equivalent:** the permanent «در حال مشاهده
به‌عنوان …» bar. There, impersonation was invisible once the token had been
swapped — the only way to tell whose account you were in was to notice the data
looked wrong, and placing an order on the wrong customer's gold debt was one tap
away. It is also the exit; the reference's only way out was the logout button,
which ended the expert's own session too.
**Verification.** `CustomerJwtStrategy` had **no test coverage at all** before
this, and it is now where the whole authorisation boundary of the feature lives,
so `act-as-customer.spec.ts` covers 11 cases: the happy path, the scoped query
itself, another expert's customer, an inactive customer, a deactivated expert, a
demoted expert, the header being **ignored** on a customer token, the no-header
fallback (asserting the extra query does not run), blank and duplicated headers,
and a revoked session short-circuiting before the header is read. Plus 2 cases on
the order audit trail. Full api suite **308/308, 28 suites**; both apps
`vue-tsc` clean.
_No data was deleted in this entry._
+62
View File
@@ -1249,6 +1249,12 @@ customer. That is a session-hijack primitive; every session here derives from
the caller's own token. Not ported, and recorded here rather than silently
dropped.
> ⚠️ **SUPERSEDED BY D29 (2026-10-12).** The owner asked for the capability
> back, so acting as one's own customer now exists — with the reference's full
> scope, but not its mechanism: no token is minted, the expert keeps their own,
> and ownership is re-checked on every request. The paragraph above still
> describes correctly what is **not** done (minting customer tokens). See D29.
## Ambiguity resolved the low-risk way
If one mobile ever belonged to both an expert and a customer, the owner ruled
@@ -1451,3 +1457,59 @@ status that posts the temporary document (پیش‌فاکتور) is an admin-cho
عملیات** است، پس سکوت از چیزی محافظت نمی‌کند.
ثبت شده در `ISSUES.md` به‌عنوان AUTH-01/02/03 و DOC-01.
---
## D29 — «ورود به حساب مشتری» برای کارشناس فروش (۲۰۲۶/۱۰/۱۲)
**خواسته مالک:** «کارشناس فروش میتونه از طریق اپ خودش وارد صفحه مشتری هاش بشه و
از جانب اون ها ثبت سفارش کنه و وضعیت اون هارو ببینه و یکجورایی انگار با اکانت
اون ها لاگین کرده همه چیز رو از دید اون مشتریش میدید و این خیلی کاربردی بود ولی
الان نیست» و سپس: «همان قابلیت هارو میخوام حالا هرجور که فکر میکنی از لحاظ
امنیت و پرفورمنس بهتره».
این **D21 را اصلاح می‌کند**: آنجا `changeUser` «عمداً پورت نشد» و به‌عنوان
session-hijack رد شد. مالک قابلیت را خواست، پس بازگردانده شد — با همان دامنه
کامل مرجع، ولی با سازوکار دیگر.
### دامنه: کامل، مثل مرجع
مرجع توکن را در interceptor سراسری axios عوض می‌کرد
(`henza_pwa/plugins/axios.ts:18` ← `getUserToken()`)، پس **هر** درخواست از طرف
مشتری می‌رفت: دیدن، ثبت سفارش، علاقه‌مندی، و آپلود مدارک. همان دامنه حفظ شد —
هیچ مسیری در حالت act-as بسته نشد.
### سازوکار: هدر، نه توکن
| | مرجع | اینجا |
|---|---|---|
| چطور | توکن دائمی مشتری (`api_token`) به کارشناس داده می‌شد (`ApiMobileController.php:197-201`) | کارشناس توکن **خودش** را می‌فرستد + هدر `X-Act-As-Customer` |
| کجا اعمال می‌شود | interceptor کلاینت | `CustomerJwtStrategy.validateExpert` — تنها نقطه |
| بررسی مالکیت | یک‌بار، در لحظه گرفتن توکن | **هر درخواست**، با `referredByAgentId` در همان کوئری |
| ماندگاری | `localStorage`، بی‌انقضا | `sessionStorage`؛ بستن تب = پایان |
| ردّ حسابرسی | هیچ — درخواست عیناً مشتری بود | `adminUserId` حفظ می‌شود؛ سفارش لاگ می‌شود |
| بعد از قطع همکاری کارشناس | توکن مشتری همچنان کار می‌کرد | ۴۰۱ فوری (نقش و `isActive` هر بار خوانده می‌شود) |
**چرا این نقطه:** `validate()` همیشه یک principal می‌سازد که `id` آن یک Customer
id است و **هر ۱۷ مسیر مشتری** از آن می‌خوانند. پس تغییر در یک نقطه، همه مسیرها
را از دید مشتری می‌کند — بدون تغییر در هیچ route.
**پرفورمنس:** یک `findFirst` روی کلید اصلی، **فقط** وقتی هدر هست؛ و جایگزین
کوئریِ `tradingCustomer` می‌شود نه اضافه بر آن. مشتری عادی و کارشناس در حالت
معمولی صفر هزینه اضافه.
**۴۰۳ و نه ۴۰۱** برای مشتریِ غیرمجاز: نشست خود کارشناس سالم است، پس اپ وضعیت
act-as را پاک می‌کند و او را از سیستم بیرون نمی‌اندازد.
### چیزهایی که مرجع نداشت و اضافه شد
۱. **نوار همیشگی «در حال مشاهده به‌عنوان …» + دکمه خروج.** در مرجع، جابجایی
نامرئی بود: راه فهمیدن اینکه در حساب کیستی، فقط دیدن داده‌های عجیب بود، و
ثبت سفارش روی حساب اشتباه یک کلیک فاصله داشت. تنها راه خروج هم logout بود که
نشست خود کارشناس را هم می‌بست (`Dashboard.vue:781-789`).
۲. **پاک‌سازی حالت در جابجایی** (هر دو جهت): سبد، `queryCache` کلیدهای `me:`،
علاقه‌مندی‌ها، و **اجرت‌های کش‌شده**. این آخری مالی است: اجرت هر مشتری
متفاوت است، و بدون پاک‌سازی عددِ روی صفحه با عددِ ثبت سفارش یکی نمی‌بود.
(در مرجع این مسئله وجود نداشت چون کل نشست با توکن عوض می‌شد.)
ثبت شده در `ISSUES.md` به‌عنوان EXP-01.
+22
View File
@@ -243,3 +243,25 @@ response for every unregistered number. `request-otp.spec.ts` now covers 9 cases
— the happy path, the unknown number, Persian-digit input, a refused gateway, the
dev no-gateway fallback, that the fallback does **not** apply in production, and
the two sales-expert states.
## Sales expert — «ورود به حساب مشتری» restored (2026-10-12)
Reported by the owner: «کارشناس فروش میتونه از طریق اپ خودش وارد صفحه مشتری هاش
بشه و از جانب اون ها ثبت سفارش کنه … انگار با اکانت اون ها لاگین کرده … و این
خیلی کاربردی بود ولی الان نیست».
| ID | Severity | Where | Description | Evidence | Status |
|---|---|---|---|---|---|
| **EXP-01** | High (missing capability) | REF `henza_pwa` `customerChoice.vue:155-167` → `myDataStore.ts:159-169` → `tokenManage.ts:8` → `plugins/axios.ts:18`; REF server `ApiMobileController.php:197-201`. TGT both apps + `customer-jwt.strategy.ts` | A sales expert could open any of their referred customers' accounts and use the whole app as them — browse, see balance and debt, place orders. The reference did it by **minting and handing over the customer's own permanent `api_token`**, which its axios interceptor then used for every request. The target listed the customers but tapping one only opened a detail sheet; an expert's order always landed on their own trading account. | Read all five reference files and the target's view header, which recorded the omission (D21). | ✅ **RESTORED 2026-10-12 (D29)** with the reference's full scope — every customer route, including documents — but a different mechanism: `X-Act-As-Customer` alongside the expert's **own** token, resolved once in `CustomerJwtStrategy.validateExpert`, which is why all 17 customer routes work unchanged. Ownership (`referredByAgentId`) is re-checked **on every request** in the same query as the id, so another expert's customer and a non-existent id both answer 403. 11 tests. |
| **EXP-02** | Medium (found while building EXP-01) | TGT both apps `stores/queryCache.ts`, `favoritesStore.ts`, `wageQuoteStore.ts` | All three drop their per-customer data when `session.token` changes — but act-as changes **whose** data they hold *without* changing the token. An expert entering a customer's account would have seen their own profile, orders, balance, ledger, favourites and **making-charge quotes**. | Read each watcher; each one's own comment stated the rule it was about to break. | ✅ Fixed with EXP-01: each watcher now also keys on `actingAs.id`. The wage-quote one is the money case — making charge is per customer, so a stale quote would have shown a figure that was not the one the order was priced at. |
| **EXP-03** | Medium (same) | TGT both apps `api/client.ts` `uploadDocument` | The multipart upload bypasses `request()` and builds its own headers, so the act-as header had to be repeated there. Without it an expert uploading a customer's documents would have filed them against their **own** account, silently and with no error. | `grep Authorization` on the client returns two call sites, not one. | ✅ Both call sites send the header. |
**Deliberately different from the reference** (all in D29): no customer token is
ever minted; the act-as target lives in `sessionStorage`, so closing the tab ends
it, where the reference's `api_token` sat in `localStorage` forever and outlived
the expert's employment; `adminUserId` survives the switch so an order placed
this way is attributable (the reference's requests were indistinguishable from
the customer's own); a permanent «در حال مشاهده به‌عنوان …» bar with an exit
button, which the reference had no equivalent of — there, impersonation was
invisible and the only way out was a logout that also ended the expert's own
session; and the cart is cleared in both directions.
+2 -1
View File
@@ -21,7 +21,7 @@ The "Current state" table below is the parity project's and was last updated 202
|---|---|
| **Phase** | **Phase 2 — apps parity — items #1 and #2 done** (Phase 1 closed, `83e5d28`) |
| **Branch** | `main` (the parity branches were merged; see git log 2026-10-08..10-12) |
| **Status** | Phase 1: all in-scope sections closed (§2 §4 §5 §6 §7 §8 §9 ✅ · §3 ⏸️ by D16). Phase 2: **#1 MON-08 ✅**, **#2 D3 ✅**, plus the app-login error reporting the owner asked for (**AUTH-01/02/03**, **DOC-01**). **295/295** jest tests; both apps `vue-tsc` clean. |
| **Status** | Phase 1: all in-scope sections closed (§2 §4 §5 §6 §7 §8 §9 ✅ · §3 ⏸️ by D16). Phase 2: **#1 MON-08 ✅**, **#2 D3 ✅**, app-login error reporting (**AUTH-01/02/03**, **DOC-01**), and **«ورود به حساب مشتری» restored** (**EXP-01/02/03**, D29 — supersedes D21's omission). **308/308** jest tests; both apps `vue-tsc` clean. |
| **Next step** | Build **`02-APPS-MATRIX.md`** — the field-by-field apps matrix (D12), which Phase 2 has been running without. Then the remaining app rows it turns up. |
| **Blocked on** | nothing. Awaiting an owner ruling when convenient: **PERM-02**. Deferred by the owner: **ANLY-01** (route analytics, lowest priority). |
| **Last updated** | 2026-10-12 |
@@ -143,6 +143,7 @@ All user rulings live there (D0–D17). Highlights that change how work is done:
| D15 | **`_goldbank_migrations` is KEPT** — it is the tracking table of the app's own migration runner (`src/prisma/migrate.ts`), needed because the Docker image has no Prisma CLI. Declared `@@ignore`d; drift is zero. **Persian test input must come from a FILE, never a shell argument.** |
| D16 | **PRICE-01 STOPPED** — price display is retail-storefront (⏸️ D13); the B2B app works in making charge only. `variant-price.ts` + tests kept but unused. **Making charge stays in scope.** New work order: Persons → Products/variants/order-detail → the rest of the admin → gate → apps. **Per section: implement gaps, then field-by-field test, and show a gap summary BEFORE fixing.** |
| D17 | **SalesExpertProfile** (cityId, gramLevel, phone, nationalCode) + **firstName/lastName on all AdminUsers**. **`reagent_id` deliberately NOT ported** — in the reference it was a side effect of the shared customers form and filling it removed the expert from the selectable-agent list (queryReagent:420); the referrer logic lives on `Customer.referredByAgentId`. |
| D29 | **«ورود به حساب مشتری» restored** (2026-10-12) — a sales expert can work inside any of their referred customers' accounts with the reference's **full** scope (every customer route, documents included). Mechanism deliberately not the reference's: no customer token is minted; `X-Act-As-Customer` rides the expert's own token and is authorised in `CustomerJwtStrategy.validateExpert`, re-checking `referredByAgentId` on **every** request. **Supersedes D21's "deliberately not ported".** |
| D28 | **App login step 1 must say what went wrong** (2026-10-12): an unregistered number → 404, a failed SMS send → 503, an unusable sales-expert account → 403, each with its own Persian message. Restores the reference's `firstOrFail()`. **Trades away part of the anti-enumeration protection, deliberately** — the per-mobile/per-IP send throttles still bound it and `verifyOtp` stays single-message. See D28 for the full reasoning and why D20 is not violated. |
## Open — awaiting user approval