feat(api): sales-expert login to the customer apps (D21)

A sales expert can now sign into the apps with their AdminUser account, without
reversing D17 — the expert remains an AdminUser and is never turned back into a
Customer.

The app token carries a `typ` of 'customer' or 'sales-expert'; the strategy
resolves both to one principal whose `id` is always a Customer id, so every
route already written for a customer works unchanged for an expert. That is what
the reference gave them, where an agent was a customer with extra screens.

Each expert gets ONE linked trading Customer row (additive migration, dry-run
clean and rolled back before applying) created at first sign-in, seeded from the
two numbers the reference read off the agent's own row: SalesExpertProfile
.gramLevel -> goldCreditLimitGrams and AdminUser.wagePercent ->
makingChargeOffsetPercent. Orders the expert places land there, exactly as the
reference's single row did.

New expert-only routes scope on the AdminUser id from the token:
POST expert/child-orders (legacy /order/child, ApiMobileController:759, keeping
its sts=5 filter as DELIVERED_TO_AGENT), POST expert/my-customers (legacy
/my-child, :184) and GET expert/whoami so the apps can hide the entries.

Security: jwt.strategy now rejects any token carrying an app `typ`. An expert's
app token holds their AdminUser id in `sub`, and JWT_CUSTOMER_SECRET falls back
to JWT_SECRET when unset — in that configuration the token would otherwise have
validated as an admin with full panel permissions.

NOT ported: myChild minted an api_token per listed customer (:197-201), letting
an agent act AS that customer. Recorded in D21 rather than silently dropped.

Tests: new test-sales-expert-app-login.cjs 36/36 — expert sign-in, trading
account seeding, own-customers-only (with both negatives), child orders, the
expert ordering for themselves, an ordinary customer refused both screens with
their own app unaffected, and all four token boundaries including immediate
lockout on deactivation. jest 45/45, admin smoke 43/43, apps smoke 43/43,
B2B scan 34/34, B2B writes 25/25, auth-smoke 32/32.

[ref: henza_server/app/Http/Controllers/Api/ApiMobileController.php:184-195,534-535,759-781]

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
s.esmaeili
2026-09-23 01:05:36 +03:30
co-authored by Claude Opus 5
parent 53436ec855
commit 69f4ba2b29
13 changed files with 1124 additions and 32 deletions
+90
View File
@@ -1165,3 +1165,93 @@ rejected; the real code still works.
**Before the real release: remove `OTP_MASTER_CODE` from the environment and
configure the SMS credentials.** Recorded in `DELIVERY.md` and as a Critical
entry in `ISSUES.md`.
---
# D21 — Sales-expert login to the customer apps (owner decision, 2026-09-23)
The owner required that a sales expert be able to sign into the customer apps,
**without** reversing D17 (the expert stays an AdminUser, never a Customer).
## The problem
In the reference a sales agent WAS a Customer row (`customers.type = 1`), so:
* they logged in through the ordinary customer flow and got the whole app;
* `setOrder` did `Customer::findOrFail(auth()->id())` and read that row's
`gram_level` (`ApiMobileController.php:534-535`) — the agent's own order, own
credit limit, own ledger;
* `getChildOrder` filtered `where('reagent_id', auth()->id())`
(`:767`) and `myChild` the same (`:188`) — the logged-in customer's id.
Here the expert is an AdminUser, which has **no ledger and no credit limit** and
cannot be an `Order`'s subject (`Order.customerId` is a required FK).
## The design
**One token family, two principals.** The app token's `typ` claim is either
`'customer'` or `'sales-expert'`; `customer-jwt.strategy.ts` resolves both to
one shape whose `id` is **always a Customer id**. Every route already written
for a customer therefore works unchanged for an expert — which is exactly what
the reference gave them.
**A trading account.** Each expert gets **one** linked Customer row
(`SalesExpertProfile.tradingCustomerId`, additive migration
`20260923120000_sales_expert_trading_customer`), created lazily at their first
`request-otp`. It carries only their own trading position.
### Answering the owner's question — whose name, and from where
| Question | Answer | Source |
|---|---|---|
| An order the expert places is **whose**? | Their **own trading account** — not a customer's | Reference: the agent's order was their own row |
| Its **making charge**? | `AdminUser.wagePercent` → `makingChargeOffsetPercent` | Reference: the agent's `customers.percent` |
| Its **credit limit**? | `SalesExpertProfile.gramLevel` → `goldCreditLimitGrams` | Reference: `$customer->gram_level` (`:535`) |
Both numbers are the same two the reference read off the agent's own Customer
row, so the behaviour matches; only the storage location differs. Seeded at
creation and not resynchronised afterwards — an operator editing the expert's
commission changes it for their referred customers, and changing an existing
trading account's terms silently is a money change nobody asked for.
## Scope rules
* `Customer.referredByAgentId` points at the **AdminUser**, so "my customers"
and "my customers' orders" scope on `me.adminUserId`, taken from the token.
An expert can never see another expert's customers (tested, both directions).
* The trading account has `referredByAgentId = null` — an expert is not their
own referred customer.
* Child orders keep the reference's `sts = 5` filter as
`status = DELIVERED_TO_AGENT` — the same «تحویل مسئول فروش» point.
* Both expert routes 403 for an ordinary customer, and `expert/whoami` lets the
apps hide the menu entries. The server does not rely on the UI hiding them.
## Token boundaries (all tested)
| Token | On | Result |
|---|---|---|
| expert app token | admin route | **401** |
| customer token | admin route | **401** |
| admin token | expert route | **401** |
| expert token, after deactivation | expert route | **401**, immediately |
`jwt.strategy.ts` gained an explicit check that rejects any token carrying an
app `typ`. This matters because an expert's app token holds their **AdminUser
id** in `sub`: `JWT_CUSTOMER_SECRET` falls back to `JWT_SECRET` when unset, and
in that configuration the token would otherwise have validated as an admin and
been handed full panel permissions.
## Deliberately NOT ported
`myChild` minted an `api_token` for every listed customer as a side effect
(`ApiMobileController.php:197-201`), which let an agent act **as** that
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.
## Ambiguity resolved the low-risk way
If one mobile ever belonged to both an expert and a customer, the owner ruled
the **expert wins**. Since `Customer.mobile` is unique, the code adopts the
existing customer row as the expert's trading account rather than failing the
login or creating a duplicate, and logs a warning.