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:
co-authored by
Claude Opus 5
parent
53436ec855
commit
69f4ba2b29
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user