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
@@ -0,0 +1,52 @@
|
||||
-- Sales-expert app login: the expert's own trading account.
|
||||
--
|
||||
-- WHY THIS COLUMN EXISTS
|
||||
-- ----------------------
|
||||
-- In the reference a sales agent WAS a Customer row (customers.type = 1), so
|
||||
-- when the agent placed an order from the app, setOrder did
|
||||
-- $customer = Customer::findOrFail(auth()->id());
|
||||
-- $gramLevel = $customer->gram_level;
|
||||
-- (ApiMobileController.php:534-535) — the order was simply that agent's own
|
||||
-- order, against that agent's own credit limit and ledger.
|
||||
--
|
||||
-- This system models a sales expert as an AdminUser (role SALES_EXPERT, D17),
|
||||
-- which has no ledger, no gold debt and no credit limit. An AdminUser therefore
|
||||
-- cannot be the subject of an Order: Order.customerId is a required FK to
|
||||
-- Customer, and every ledger entry hangs off a Customer.
|
||||
--
|
||||
-- Rather than reverse D17 (the owner ruled the expert stays an AdminUser), each
|
||||
-- expert gets ONE linked Customer row that represents only their own trading
|
||||
-- position. Orders the expert places for themselves land on that row, exactly
|
||||
-- as the reference's single row did. The expert's REFERRED customers remain
|
||||
-- separate rows pointing at the AdminUser via Customer.referredByAgentId —
|
||||
-- that relationship is unchanged.
|
||||
--
|
||||
-- PURELY ADDITIVE
|
||||
-- ---------------
|
||||
-- One nullable column plus a unique index. No existing row is modified, no
|
||||
-- data is backfilled and nothing is deleted. A profile with a NULL
|
||||
-- tradingCustomerId simply has no trading account yet; the API creates it
|
||||
-- lazily on first login, so existing experts keep working untouched.
|
||||
|
||||
ALTER TABLE "SalesExpertProfile"
|
||||
ADD COLUMN IF NOT EXISTS "tradingCustomerId" TEXT;
|
||||
|
||||
COMMENT ON COLUMN "SalesExpertProfile"."tradingCustomerId" IS
|
||||
'The Customer row that carries this expert''s OWN orders, debt and credit limit. Their referred customers are separate rows linked by Customer.referredByAgentId. Null until the expert first signs into the app, when it is created from the profile (gramLevel -> goldCreditLimitGrams, wagePercent -> makingChargeOffsetPercent).';
|
||||
|
||||
-- One trading account per expert, and no two experts may share one.
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "SalesExpertProfile_tradingCustomerId_key"
|
||||
ON "SalesExpertProfile" ("tradingCustomerId");
|
||||
|
||||
-- SET NULL, not CASCADE: if the trading Customer is ever removed, the expert
|
||||
-- and their profile must survive — deleting a customer must never delete an
|
||||
-- admin account.
|
||||
DO $$
|
||||
BEGIN
|
||||
ALTER TABLE "SalesExpertProfile"
|
||||
ADD CONSTRAINT "SalesExpertProfile_tradingCustomerId_fkey"
|
||||
FOREIGN KEY ("tradingCustomerId") REFERENCES "Customer"("id")
|
||||
ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
@@ -227,6 +227,22 @@ model SalesExpertProfile {
|
||||
phone String? @db.VarChar(15)
|
||||
nationalCode String? @db.VarChar(20)
|
||||
|
||||
/// The Customer row carrying this expert's OWN orders, debt and credit limit.
|
||||
///
|
||||
/// The reference stored a sales agent AS a Customer (type=1), so an order the
|
||||
/// agent placed from the app was simply their own, against their own
|
||||
/// gram_level (ApiMobileController.php:534-535). Here the expert is an
|
||||
/// AdminUser (D17), which has no ledger and cannot be an Order's subject, so
|
||||
/// this one linked Customer plays that part.
|
||||
///
|
||||
/// Distinct from the expert's REFERRED customers, which are ordinary Customer
|
||||
/// rows pointing at the AdminUser through Customer.referredByAgentId.
|
||||
///
|
||||
/// Null until the expert first signs into the app, when it is created from
|
||||
/// this profile. See D21.
|
||||
tradingCustomerId String? @unique
|
||||
tradingCustomer Customer? @relation("SalesExpertTradingAccount", fields: [tradingCustomerId], references: [id], onDelete: SetNull)
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @default(now()) @updatedAt
|
||||
|
||||
@@ -280,6 +296,11 @@ model Customer {
|
||||
referredByAgentId String?
|
||||
referredByAgent AdminUser? @relation("ReferralAgent", fields: [referredByAgentId], references: [id])
|
||||
|
||||
/// Set only on the one Customer row that acts as a sales expert's own trading
|
||||
/// account (see SalesExpertProfile.tradingCustomerId). Empty for every
|
||||
/// ordinary customer.
|
||||
salesExpertTradingFor SalesExpertProfile? @relation("SalesExpertTradingAccount")
|
||||
|
||||
// Credit limit + cached ledger balances (source of truth is LedgerEntry; these
|
||||
// are denormalized for fast reads and reconciled by a scheduled job).
|
||||
goldCreditLimitGrams Decimal @default(0) @db.Decimal(14, 4) // legacy gram_level
|
||||
|
||||
Reference in New Issue
Block a user