toga-ai 1.0.846 → 1.0.848
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/knowledge/1.0/apps/library/features/netsuite-suiteql-rest-shim.md +5 -1
- package/knowledge/1.0/apps/library/features/toga2-api-client-and-bridge.md +5 -2
- package/knowledge/1.0/apps/worker/features/netsuite-togasupply-per-client-sync.md +66 -5
- package/knowledge/2.0/apps/toga2-commerce/INDEX.md +2 -1
- package/knowledge/2.0/apps/toga2-commerce/architecture.md +6 -3
- package/knowledge/2.0/apps/toga2-commerce/features/header-order-for-dropdown.md +36 -32
- package/knowledge/2.0/apps/toga2-commerce/features/view-as-session-tokens.md +90 -0
- package/knowledge/2.0/apps/toga2-commerce/workflows/cypress-testing.md +22 -3
- package/knowledge/INDEX.md +1 -1
- package/package.json +1 -1
|
@@ -6,7 +6,7 @@ project: Library
|
|
|
6
6
|
client: shared
|
|
7
7
|
type: feature
|
|
8
8
|
status: active
|
|
9
|
-
updated: 2026-09-
|
|
9
|
+
updated: 2026-09-21
|
|
10
10
|
owners: [dfranks, jcardinal, kyalamarthi, rgirish]
|
|
11
11
|
files:
|
|
12
12
|
- library/app/api/netsuite/rest.php
|
|
@@ -57,6 +57,7 @@ Field semantics (verified on the live account):
|
|
|
57
57
|
- **Line number = `transactionline.id`, NOT `linesequencenumber`.** `tl.id` equals the REST `item[].line` value (the stable SOAP-era line key); `linesequencenumber` is physical order and re-maps/collides when SOAP-era records are edited. Verified: sales orders 106 orders / 344 lines (2019–2029, incl. edited orders with line-id gaps), invoices + cash sales 62/62. `listSales` historically resolved line via `uniquekey → REST lineUniqueKey → line`; `tl.id` gives the same answer in bulk without the per-id GET. **Re-confirmed at scale 2026-08-27:** the two columns diverge on **2,061 of 4,387** lines (~47%), and `Forecast.OpenOrderItems.lineNumber` (which stores REST `$line->line`) matched `transactionline.id` on **99 of 99** randomly sampled prod rows plus all 7 multi-root orders, with zero join misses. So keying a NetSuite ↔ Forecast line join on `linesequencenumber` silently mismatches about half the book — always join on `transactionline.id`.
|
|
58
58
|
- **ShipItem lines have `costestimate = NULL`** (100% of 42K+ lines, 2025–26). In any profit expression `SUM(-foreignamount + costestimate)`, a NULL cost makes the whole term NULL and SQL **drops the row from the SUM** — so shipping *revenue* still counts but shipping *profit* does not, silently understating NS profit. Always `NVL(tl.costestimate, 0)`. (Caused a fake $663K "gap" in the audit tools — see the reconciliation doc.)
|
|
59
59
|
- **Cash refunds & credit memos carry extra COGS/inventory lines.** SuiteQL `transactionline` with `mainline='F' AND item>0` returns, per item, the customer line **plus** a `CUSTOMERRETURN` and an `ASSET` posting (all `item>0`), which REST's `item[].items` sublist omits. They are distinguished by **`tl.iscogs = 'T'`** (and `accountinglinetype` IN `CUSTOMERRETURN`/`ASSET`). Filter **`tl.iscogs = 'F'`** to get exactly the customer-facing lines. Invoices/cash sales do not have this (0 extra lines). Without the filter, bulk imports duplicate refund/memo lines.
|
|
60
|
+
- **⚠ `listSales()` takes a `$recordTypes` allowlist — pass `RECORD_TYPE_*` CONSTANTS, never raw SuiteQL codes (2026-09-21).** `listSales(...)`'s trailing `array $recordTypes` **defaults to all four** `RECORD_TYPE_INVOICE`/`_CASH_SALE`/`_CASH_REFUND`/`_CREDIT_MEMO`, which the **forecast import** (`worker/crons/toga2/forecast2/common_import_sales_from_netsuite.php`, `periodic_forecast_discrepancy_fix.php`) relies on. `listSales` maps each constant to its SuiteQL code internally (`RECORD_TYPE_INVOICE`→`CustInvc`, `_CASH_SALE`→`CashSale`, `_CASH_REFUND`→`CashRfnd`, `_CREDIT_MEMO`→`CustCred`) and **throws on an unknown/empty type**. The **togasupply sync passes `[RECORD_TYPE_INVOICE]` — INVOICES only** — because a credit memo's `createdFrom` is the invoice it credits (not a sales order) and dead-ended `syncInvoiceFromNetsuite`; see the [per-client-sync doc](../../worker/features/netsuite-togasupply-per-client-sync.md).
|
|
60
61
|
- **Sign conventions are per-type and self-consistent.** SuiteQL returns `foreignamount`/`quantity`/`costestimate` **negative for invoices & cash sales, positive for credit memos & cash refunds**. A uniform `-foreignamount` therefore yields **positive** revenue for invoices and **negative** for reversals — the agreed Forecast2 convention. Do NOT also apply a per-type factor (it double-negates reversals back to positive). The shim flips `amount = -foreignamount`, `costEstimate = -costestimate`.
|
|
61
62
|
- **⚠ `->holdInvoice` on a sales-order shim is NOT a native NetSuite field — it is normalised from the custom checkbox `custbody_asi_hold` (internal id 4082), and that field is SHARED by all clients.** NetSuite REST exposes no top-level `holdInvoice`. The `"asi"` prefix is **Agilant Solutions Inc** (the company), *not* a client prefix — do not read it as client-specific. The read lives **inside the shim**, in all three fetch paths: `buildSalesOrderShims()` (~L533, feeding both `listSalesOrders()` and `fetchSalesOrderByIdFull()`) and `fetchSalesOrderById()` (~L1537), so callers such as `App_Api_Toga2::isTransferOrder()` read an already-normalised boolean. (An earlier version read `$nsOrder->holdInvoice` in the caller with a `custbody_hold_invoice` fallback — the wrong field name; commit `07c31e24`, 2026-08-19, found the real one and moved the read down here.) `worker/crons/toga2/compass/edi/2_create_so_netsuite.php` **writes** the same field. **⚠ The SOAP client `library/app/netsuite.php` never sets `holdInvoice` at all**, so any detection that depends on it (e.g. the `zero_dollar_hold` transfer-order mode — see [per-client sync](../../worker/features/netsuite-togasupply-per-client-sync.md)) is **silently always-false** on a SOAP path. Verified not a live risk: the togasupply engine calls `App_Api_Netsuite_Rest::` on every sales-order/receipt/fulfillment path.
|
|
62
63
|
- **`createdFrom` is not a usable SuiteQL column.** `transaction.createdfrom` is **always NULL** in SuiteQL (26/26). It is recoverable from `previoustransactionlinelink.previousdoc` excluding `previoustype = 'Opprtnty'` (an invoice links to both its SO and its opportunity; REST's `createdFrom` is the SO). **But this is non-deterministic for multi-link transactions** (credit memos return several non-Opprtnty `previousdoc` rows in arbitrary order) — two runs can pick different values. REST `createdFrom` (per-id GET) is the only deterministic source. Treat the link table as best-effort, not authoritative.
|
|
@@ -93,6 +94,9 @@ Field semantics (verified on the live account):
|
|
|
93
94
|
- Field availability varies by NetSuite account **and** record type — probe the live account before assuming a column/relationship exists.
|
|
94
95
|
- **`App_NetSuite::getCustomFieldValue()` (`library/app/netsuite.php`) must be warning-safe.** 1.0 runs on PHP 7.2 in an unguarded path, so any PHP warning → `exit()` (kills the cron). It was hardened 2026-08-27 with `?? []` / `?? null` guards on the custom-field-list traversal. Keep that discipline when touching it.
|
|
95
96
|
|
|
97
|
+
## Change history
|
|
98
|
+
- 2026-09-21 — `listSales()` gained a `$recordTypes` allowlist param (defaults to all 4 for the forecast import); callers pass `RECORD_TYPE_*` constants, not raw SuiteQL codes; throws on unknown/empty. Lets the togasupply sync request INVOICES only. (jcardinal)
|
|
99
|
+
|
|
96
100
|
## Related
|
|
97
101
|
|
|
98
102
|
- `netsuite-suiteql-api-reference.md` — the broad API reference (auth, SuiteQL mechanics, table schemas, type/status codes, custom-field map, performance) these semantics sit on top of.
|
|
@@ -6,7 +6,7 @@ project: Library
|
|
|
6
6
|
client: shared
|
|
7
7
|
type: feature
|
|
8
8
|
status: active
|
|
9
|
-
updated: 2026-09-
|
|
9
|
+
updated: 2026-09-21
|
|
10
10
|
owners: [jcardinal, mhammontree, bala]
|
|
11
11
|
files:
|
|
12
12
|
- library/app/api/toga2.php
|
|
@@ -163,7 +163,7 @@ Signature takes `$integrationType` (`TICKETS` or `REPAIR_ORDERS`) plus **seven i
|
|
|
163
163
|
## Gotchas
|
|
164
164
|
|
|
165
165
|
- **A 2.0 GET silently TRUNCATES a nested child collection — never trust a single response for a count.** When you read a parent with a nested `hasMany` collection in one GET, api2 can return the collection **partial** (observed: a fulfillment's nested `itemFulfillmentItemUnits` came back **45 of 70**) with no error and no obvious signal — so any verification that counts off the nested array is silently wrong. **Read the collection flat and fully paginated** (query the child resource directly with the universal `meta->nextPage` loop) and take counts from **`meta.totalRecordCount`**, not from a single nested response. Surfaced building the NYCHH fulfillment asset-tag verifier (`worker/crons/toga2/netsuite/verify_fulfillment_asset_tag_sync_nychh.php`). **Confirmed again on the fulfillment reconcile (2026-09-13):** the `salesOrderItems` nested collection truncated at **25**, and a fulfillment's `itemFulfillmentItemUnits` re-created 25,766 links for 1,192 real units. The reconcile now reads both flat + fully paginated via two new private helpers, `getItemFulfillmentItemUnitsByItemFulfillmentUuid()` and `getSalesOrderItemsBySalesOrderUuid()` (`recordsPerPage` 1000, page-cap 500, `nextPage ?? null`) — detail on the [per-client sync doc](../../worker/features/netsuite-togasupply-per-client-sync.md).
|
|
166
|
-
- **🔐 Hardcoded credentials.** Every client's `CLIENT_UUID_*`, `API_UUID_*`, and `API_SECRET_*` (plus the "True Workers" master keys) are committed as plaintext class constants at the top of `toga2.php`. Treat them as **compromised-if-leaked**; do not reproduce the values in the KB, tickets, or logs. Long-term they should move to config/SSM. (Observed 2026-06-23.)
|
|
166
|
+
- **🔐 Hardcoded credentials.** Every client's `CLIENT_UUID_*`, `API_UUID_*`, and `API_SECRET_*` (plus the "True Workers" master keys) are committed as plaintext class constants at the top of `toga2.php`. Treat them as **compromised-if-leaked**; do not reproduce the values in the KB, tickets, or logs. Long-term they should move to config/SSM. (Observed 2026-06-23.) **⚠ Also (found 2026-09-21, NOT fixed): a committed JWT (a dev/NYCHH token) sits inline at `library/app/api/toga2.php:641`, inside `App_Api_Toga2::send()` — the live sync call path.** Pre-existing, not introduced this session. **Rotate the token and delete the line;** record its location only, never the value.
|
|
167
167
|
- **Two unrelated `sync_togasupply_<client>.php` families.** Files under `worker/crons/toga2/netsuite/` are the **NetSuite supply importer** (`require` the common engine). Files under `worker/crons/toga2/<client>/` (e.g. `aig/`, `wje/`) are this **bridge** (`syncWithToga`/`syncWithTogadesk`). Same filename, different integration, different parameter keys — they coexist harmlessly. Confirm which folder before editing.
|
|
168
168
|
- **`syncWithToga` and the bridge build SQL by hand** with `App_Database::sqlEscape()` rather than the `App_Model` layer; follow the surrounding escaping discipline when modifying.
|
|
169
169
|
- **Per-record error isolation** in the TOGaDesk sync is via `try/catch` → `\Sentry\captureException`; a thrown exception elsewhere (transport, checkpoint read) still aborts the whole run.
|
|
@@ -179,6 +179,9 @@ Signature takes `$integrationType` (`TICKETS` or `REPAIR_ORDERS`) plus **seven i
|
|
|
179
179
|
- **This logic IS testable without a database — and there is a harness.** `App_Database::sqlEscape()` needs **no DB connection** (pure `str_replace`), so the email transform can be exercised statically. The TRUE-79401 transform is inlined in the DB-writing `private static syncContactToToga1Customer()`, so the harness **mirrors** it and guards against drift by asserting the production statements verbatim — see [static no-DB regression harness](../../test/features/static-no-db-regression-harness.md). **If you edit those 5 statements, the harness will refuse to report until it is updated.** The clean fix if you are already in there: extract the transform to a `public static` helper so the mirror disappears.
|
|
180
180
|
- **Pre-existing (low severity, out of scope): a raw-vs-escaped comparison causes a redundant UPDATE every sync.** At the update path (~line 8584) `syncContactToToga1Customer` compares `$existingCustomer['emailAddress'] !== $emailAddress` — the DB value from `App_Database::fetchRow()` is **unescaped**, while `$emailAddress` has already been through `App_Database::sqlEscape()`. For any value containing `'`, `"` or `\` the two can **never** compare equal, so that column is rewritten on **every** run. Same pattern applies to `businessName` and `phoneNumber` just above. **Predates TRUE-79401**, is a redundant write (not data loss), and was deliberately left out of that ticket's scope — recorded here so a future reader knows it is known.
|
|
181
181
|
|
|
182
|
+
## Change history
|
|
183
|
+
- 2026-09-21 — Recorded a pre-existing committed JWT (dev/NYCHH token) at `toga2.php:641` inside `send()` (the sync call path); flagged to rotate + delete, location-only. (jcardinal)
|
|
184
|
+
|
|
182
185
|
## Related
|
|
183
186
|
|
|
184
187
|
- [NetSuite → TOGa Supply per-client sync](../../worker/features/netsuite-togasupply-per-client-sync.md) — the importer that uses this class's transport and `*FromNetsuite()` methods.
|
|
@@ -6,7 +6,7 @@ project: Worker
|
|
|
6
6
|
client: shared
|
|
7
7
|
type: feature
|
|
8
8
|
status: active
|
|
9
|
-
updated: 2026-09-
|
|
9
|
+
updated: 2026-09-21
|
|
10
10
|
owners: ["dfranks", "bala", "jcardinal", "mhammontree", "snaredla", "rgirish"]
|
|
11
11
|
files:
|
|
12
12
|
- worker/crons/toga2/netsuite/common_sync_togasupply.php
|
|
@@ -834,10 +834,10 @@ then at header level:
|
|
|
834
834
|
Prod check: 391 rows TO-side, 293 SO-side, **0 with both, 0 with neither**.
|
|
835
835
|
|
|
836
836
|
**It throws — never skips — on:** more than one SalesOrder for the NetSuite id; a TO with no
|
|
837
|
-
lines; an SO line with no matching TO line;
|
|
838
|
-
`transferOrderItemId`**, so they physically cannot be moved); or any bridge whose existence check
|
|
837
|
+
lines; an SO line with no matching TO line; or any bridge whose existence check
|
|
839
838
|
returns no `totalRecordCount` (see the ACL shape below). Consistent with the 2026-08-27 fail-loud
|
|
840
|
-
policy.
|
|
839
|
+
policy. **A stale transfer-order Invoice no longer freezes the run — it is now DELETED up front
|
|
840
|
+
(2026-09-21, below);** the old per-line `InvoiceItems` throw is kept only as a safety net.
|
|
841
841
|
|
|
842
842
|
**Bridge direction is PO-FIRST, confirmed from NYCHH data rather than guessed.** The naming rule is
|
|
843
843
|
`<source>_<created from it>`. `Client_Nychh` populates `PurchaseOrderItems_TransferOrderItems` (98
|
|
@@ -922,6 +922,56 @@ actually have to write code for** — the schema alone would have sent us after
|
|
|
922
922
|
| `PurchaseOrders_TransferOrders` | 17 → **104** |
|
|
923
923
|
| `PurchaseOrderItems_TransferOrderItems` | 28 → **163** |
|
|
924
924
|
|
|
925
|
+
#### ⚠ A stale transfer-order Invoice froze SALES_ORDERS — the retirement now DELETES it up front (fixed + deployed + prod-verified 2026-09-21, NYCHH)
|
|
926
|
+
|
|
927
|
+
The retirement's old "throw on any `InvoiceItems`" rule froze NYCHH `SALES_ORDERS` at `1-RUNNING`
|
|
928
|
+
(`Logs.Issue` **813**, `clientId 3`). NetSuite order **4680233** (number **172445**) was reclassified
|
|
929
|
+
`SalesOrder`→`TransferOrder`; the stale `SalesOrder` **65** carried `Invoice` **297781** (id **1143**).
|
|
930
|
+
`Invoices`/`InvoiceItems` have **no `transferOrderId`/`transferOrderItemId`**, so the retirement could
|
|
931
|
+
not move them and threw *"resolve manually"* every run.
|
|
932
|
+
|
|
933
|
+
**Team decision (jcardinal): a transfer-order's invoices are NOT imported, so a stale one is deleted,
|
|
934
|
+
not preserved.** New `private static deleteStaleTransferOrderInvoicesForSalesOrder()` runs **first** in
|
|
935
|
+
the retirement (before the line/header moves):
|
|
936
|
+
|
|
937
|
+
- Deletes, in FK order, the stale TO invoice's **`InvoiceItems` → `InvoiceTrackingNumbers` → the
|
|
938
|
+
`Invoice` header** — both children are FK **RESTRICT**, so children first.
|
|
939
|
+
- `Invoices` has **no soft-delete/void flag**, so it is a **hard delete**.
|
|
940
|
+
- **Throws (refuses)** if the invoice also bills a **different** SalesOrder (a shared invoice) — never
|
|
941
|
+
deletes another order's billing.
|
|
942
|
+
- The old per-line `InvoiceItems` throw is kept as a **safety net** for anything the up-front delete
|
|
943
|
+
didn't clear.
|
|
944
|
+
- All GETs use `recordsPerPage` 1000 + `isset($meta->totalRecordCount)` guards; fail-loud preserved.
|
|
945
|
+
|
|
946
|
+
Reviews: php-reviewer clean, cso SAFE TO SHIP, cto AGREE (cto caught the second FK child,
|
|
947
|
+
`InvoiceTrackingNumbers`). **Verified prod:** SalesOrder 65 + Invoice 1143 deleted, TransferOrder 1929
|
|
948
|
+
kept, `NETSUITE_EXECUTION_MODE_SALES_ORDERS` `1-RUNNING → 1-IDLE`, cursor advanced past `…|4680233`.
|
|
949
|
+
|
|
950
|
+
#### ⚠ The togasupply sync imports INVOICES ONLY — a credit memo froze INVOICES (fixed + deployed + prod-verified 2026-09-21, NYCHH)
|
|
951
|
+
|
|
952
|
+
NYCHH `INVOICES` froze at `1-RUNNING` (`Logs.Issue` **804**) because `syncInvoiceFromNetsuite` refused a
|
|
953
|
+
**credit memo** (NetSuite **4446850**, type `CustCred`). A credit memo's `createdFrom` is the **INVOICE
|
|
954
|
+
it credits, not a sales order** — chain: credit memo `4446850` → invoice `176645` (`CustInvc`) → sales
|
|
955
|
+
order `2603333`. `syncInvoiceFromNetsuite` only resolves `createdFrom` as a sales/transfer order or
|
|
956
|
+
RtnAuth, so a credit memo dead-ends and throws forever.
|
|
957
|
+
|
|
958
|
+
**Root cause: the INVOICES header fetch was pulling four transaction types.** `App_Api_Netsuite_Rest::listSales()`
|
|
959
|
+
returned `CustInvc`, `CashSale`, `CashRfnd`, `CustCred`; credit memos + cash refunds were never in scope
|
|
960
|
+
for the togasupply sync. **Team decision (jcardinal): the togasupply sync imports INVOICES ONLY.**
|
|
961
|
+
|
|
962
|
+
- **Fix (worker):** `common_sync_togasupply.php` (INVOICES section, ~L978) now calls `listSales()` with
|
|
963
|
+
`[App_Api_Netsuite_Rest::RECORD_TYPE_INVOICE]`.
|
|
964
|
+
- **Fix (library):** `listSales()` gained a trailing `array $recordTypes` param defaulting to all four
|
|
965
|
+
`RECORD_TYPE_*` constants (so the **forecast import** —
|
|
966
|
+
`worker/crons/toga2/forecast2/common_import_sales_from_netsuite.php` +
|
|
967
|
+
`periodic_forecast_discrepancy_fix.php` — is unaffected via the default). Callers pass **`RECORD_TYPE_*`
|
|
968
|
+
constants, never raw SuiteQL codes**; `listSales` maps constant→code internally and **throws on an
|
|
969
|
+
unknown/empty type**. Detail on the [SuiteQL/REST shim](../../library/features/netsuite-suiteql-rest-shim.md).
|
|
970
|
+
|
|
971
|
+
php-reviewer clean (additive; forecast callers unaffected), sql-reviewer SAFE TO MERGE (allowlist
|
|
972
|
+
mapping, no injection). **Verified prod:** `NETSUITE_EXECUTION_MODE_INVOICES` `1-RUNNING → 1-IDLE`
|
|
973
|
+
(window still 1s; self-heals ×3 from 2024-02-22).
|
|
974
|
+
|
|
925
975
|
### ⚠ TWO code paths create `PurchaseOrderItems` — only one stamped `fulfillmentType` (fixed 2026-09-17)
|
|
926
976
|
|
|
927
977
|
`fulfillmentType` was stamped by `syncPurchaseOrderFromNetsuite()` (from the order-level
|
|
@@ -1283,7 +1333,9 @@ library (or vice versa) crashes GroWrk and Adyen on their next sync run.
|
|
|
1283
1333
|
**`lastmodifieddate`, NOT `trandate`** — `App_Api_Netsuite_Rest::listSales()` defaults
|
|
1284
1334
|
`$dateField='lastmodifieddate'` and the engine calls it without the 4th arg. So a backdated
|
|
1285
1335
|
`trandate` is irrelevant to whether an invoice imports, and a just-modified invoice still won't
|
|
1286
|
-
import until it is >24h old (the upper-bound cap).
|
|
1336
|
+
import until it is >24h old (the upper-bound cap). **The engine also now passes
|
|
1337
|
+
`[RECORD_TYPE_INVOICE]` (INVOICES only — 2026-09-21); credit memos / cash refunds are out of scope
|
|
1338
|
+
and a credit memo threw here — see the credit-memo freeze subsection above.**
|
|
1287
1339
|
- **⚠ DEPLOY ORDER: the cursor-rename migration MUST run on EVERY client DB before the worker/library
|
|
1288
1340
|
deploy — there is NO old-key fallback in code.** `Client/2026-09-02b - NetsuiteSyncCursorRename.sql`
|
|
1289
1341
|
renames `NETSUITE_LAST_SYNC_DATETIME_<SECTION>` → `NETSUITE_LAST_SYNC_CURSOR_<SECTION>`. If the code
|
|
@@ -1995,6 +2047,15 @@ library (or vice versa) crashes GroWrk and Adyen on their next sync run.
|
|
|
1995
2047
|
[NetSuite Sync Alert Monitor](../../library/features/netsuite-sync-alert-monitor.md).
|
|
1996
2048
|
|
|
1997
2049
|
## Change history
|
|
2050
|
+
- 2026-09-21 — **Two NYCHH sync freezes fixed, deployed (library + worker together) and prod-verified.**
|
|
2051
|
+
(1) SALES_ORDERS froze (`Logs.Issue` 813) on a stale transfer-order invoice the retirement couldn't
|
|
2052
|
+
move (`Invoices` has no `transferOrderId`). New `deleteStaleTransferOrderInvoicesForSalesOrder()`
|
|
2053
|
+
deletes the stale TO invoice up front — `InvoiceItems` → `InvoiceTrackingNumbers` → header (both FK
|
|
2054
|
+
RESTRICT), hard delete (no void flag), throws only if the invoice also bills a different SalesOrder;
|
|
2055
|
+
team decision that TO invoices aren't imported. (2) INVOICES froze (`Logs.Issue` 804) on a credit
|
|
2056
|
+
memo whose `createdFrom` is the invoice it credits, not a sales order; the sync now imports INVOICES
|
|
2057
|
+
only — `listSales()` gained a `$recordTypes` param (defaults to all 4 for the forecast import) and the
|
|
2058
|
+
engine passes `[RECORD_TYPE_INVOICE]`. Both verified `1-RUNNING → 1-IDLE`. (jcardinal)
|
|
1998
2059
|
- 2026-09-18 — **Three corrections and one deployed fix.** (1) The stale-SalesOrder retirement
|
|
1999
2060
|
method shipped **missing four FK dependencies** and failed on each in turn: `ItemFulfillments`
|
|
2000
2061
|
**header** `salesOrderId` (all 218 orders), the **upstream** `PurchaseOrders_SalesOrders` header
|
|
@@ -13,12 +13,13 @@
|
|
|
13
13
|
| [Client Fields — per-tenant / language / role content & config](features/client-fields.md) | Per-tenant/language/role JSON config that drives almost all commerce UI text, fields, and login fetches (Layer A global + Layer B FIELDS registry); open before |
|
|
14
14
|
| [Config-Driven Expedited Shipping Gating (Cart)](features/expedited-shipping-gating.md) | Expedited shipping options are config-gated to show only when the cart holds a computer kit; open when touching cart shipping-method options or the gate config. |
|
|
15
15
|
| [Filter / Search-Results Page & the Two Search Entry Points](features/filter-search-results-page.md) | Two search entry points (Home in-place vs Header→/filter) render the same card via different code paths and FIELDS files; open for any search-results or kit-car |
|
|
16
|
-
| [Header "Order for <Name>" dropdown + the
|
|
16
|
+
| [Header "Order for <Name>" dropdown + the persisted pick handed to the cart](features/header-order-for-dropdown.md) | Header pill that lets a buyer pick who they are ordering for from any page, and the persisted store that hands that pick to the cart's order-for cascade; open b |
|
|
17
17
|
| [Inactive-item purchase gating (standalone lines only — kits are exempt by design)](features/inactive-item-purchase-gating.md) | `Items.isActive = 0` blocks standalone purchase but means something different on kit components, so the gate keys on purchase CONTEXT not the flag; open when to |
|
|
18
18
|
| [Multi-Tenant Resolution & Theming](features/multi-tenant-theming.md) | How the hostname resolves the tenant and fans out to API base URL, theme (CSS variables), and fields; open when adding a tenant or debugging wrong-theme/wrong-A |
|
|
19
19
|
| [Order-submit sync sequencing (useSubmitOrder) — why these calls must not run in parallel](features/order-submit-sync-sequencing.md) | The item sync must finish before the header sync on order submit, or concurrent line deletes race a depth-3 response into an uncaught api2 500; open when touchi |
|
|
20
20
|
| [Config-Driven Shipping Cost Waiver (Standard Ground free for computer kits)](features/shipping-cost-waiver-gating.md) | Config-driven cost waiver making Standard Ground free for a bare computer kit (Compass USA); open when touching shipping cost gates or the Compass free-freight |
|
|
21
21
|
| [Storefront persona gating — the Home page's kit / category / item counts, and the persona switcher](features/storefront-persona-gating-and-home-counts.md) | What a storefront user sees on the Home page is decided **in the front end**, from `user._personaIds`, not by the API's ACL. |
|
|
22
|
+
| [View-as another user — parked admin tokens & return-to-self](features/view-as-session-tokens.md) | How an admin impersonates another user and gets back: the delegated token pair, the parked admin pair in localStorage, and the return path. |
|
|
22
23
|
| [AWS Amplify Build & Deploy (non-prod environments)](workflows/amplify-build-and-deploy.md) | How toga2-commerce builds/deploys on AWS Amplify (one dynamic amplify.yml, branch-per-env, per-env blox channel); open to deploy, stand up an env, resolve a blo |
|
|
23
24
|
| [Cart e2e — Cypress conventions & harness (toga2-commerce)](workflows/cypress-testing.md) | Cypress e2e conventions + the Cart harness (cartV2.cy.ts) for toga2-commerce; read before adding any spec here, and note it doubles as the config-cart refactor |
|
|
24
25
|
| [Diagnosing ERR_HTTP2_PROTOCOL_ERROR (one client fails, everyone else is fine)](workflows/http2-protocol-error-diagnosis.md) | Diagnose ERR_HTTP2_PROTOCOL_ERROR when one client/network fails a togacommerce tenant but everyone else works (usually the client's TLS-inspection proxy, not ou |
|
|
@@ -6,7 +6,7 @@ project: TOGa Commerce
|
|
|
6
6
|
client: shared
|
|
7
7
|
type: architecture
|
|
8
8
|
status: active
|
|
9
|
-
updated: 2026-09-
|
|
9
|
+
updated: 2026-09-21
|
|
10
10
|
owners: ["apeterson", "tcox"]
|
|
11
11
|
files:
|
|
12
12
|
- src/main.tsx
|
|
@@ -15,6 +15,7 @@ files:
|
|
|
15
15
|
- src/contexts/AuthContext.tsx
|
|
16
16
|
- src/contexts/helpers/getLoginSettings.ts
|
|
17
17
|
- src/api/axiosInstance.ts
|
|
18
|
+
- src/api/sessionTokens.ts
|
|
18
19
|
- src/stores/
|
|
19
20
|
- src/themeConfig/ThemeContext.tsx
|
|
20
21
|
- src/fieldsConfig/index.ts
|
|
@@ -27,6 +28,7 @@ related:
|
|
|
27
28
|
- 2.0/apps/toga2-commerce/workflows/amplify-build-and-deploy.md
|
|
28
29
|
- 2.0/apps/api2/architecture.md
|
|
29
30
|
- 2.0/apps/toga2-commerce/features/filter-search-results-page.md
|
|
31
|
+
- 2.0/apps/toga2-commerce/features/view-as-session-tokens.md
|
|
30
32
|
---
|
|
31
33
|
|
|
32
34
|
Whole-repo map of the toga2-commerce React storefront (bootstrap, routing, MVVM pages, state, API, multi-tenant resolution); open when working anywhere in this repo.
|
|
@@ -124,7 +126,7 @@ Most use the `persist` middleware (localStorage). Verified files:
|
|
|
124
126
|
| `useEditOrderZu` | "Edit existing order" mode flag + original snapshot |
|
|
125
127
|
| `useEditOrderUuidStore` | UUID of the order being edited |
|
|
126
128
|
| `useSelectedUserZu` | "Order for" user selected in cart |
|
|
127
|
-
| `useOrderForZu` | Header "order for"
|
|
129
|
+
| `useOrderForZu` | Header "order for" pick — **persisted** (`order-for-zu`), owner-scoped: always read via `getOrderForUserFor(pick, ownerUuid)` (see header-order-for-dropdown) |
|
|
128
130
|
| `useReturnOriginalUserFromViewAsStore` | "View as another user" return-to-self tracking |
|
|
129
131
|
| `useEmailOptionsStore` | Notification CC email options (see cart-notification-emails) |
|
|
130
132
|
| `useBundleBuilderZu` | Bundle-builder wizard selections |
|
|
@@ -146,7 +148,8 @@ Most use the `persist` middleware (localStorage). Verified files:
|
|
|
146
148
|
- **Transaction id:** a request interceptor stamps every call with `params.transactionId = uuidv4()`.
|
|
147
149
|
- **Auth token:** a second request interceptor attaches `Authorization: Bearer <token>` — using the stored `accessToken` if a `user` exists in localStorage, otherwise fetching a **public token** via `POST {baseURL}/auth/public`.
|
|
148
150
|
- **1 ms delay interceptor:** a third request interceptor `await delay(1)` before sending.
|
|
149
|
-
- **401 handling:** response interceptor attempts `POST {baseURL}/auth/refresh` (Bearer refresh token) once (`_retry`), retries the original request on success, else calls `performLogout()` (clears all auth + cart + `commerce` cache keys and redirects to `/`).
|
|
151
|
+
- **401 handling:** response interceptor attempts `POST {baseURL}/auth/refresh` (Bearer refresh token) once (`_retry`), retries the original request on success, else calls `performLogout()` (clears all auth + cart + `commerce` cache keys and redirects to `/`). The refresh is **single-flight** (one module-level promise) and **identity-bound** — it returns `null` if `localStorage.refreshToken` changed while in flight, so it cannot overwrite a just-minted delegated pair.
|
|
152
|
+
- **Token storage lives in `src/api/sessionTokens.ts`** (host from `src/api/apiBaseUrl.ts`). Besides `accessToken` / `refreshToken` it owns **`originalAuthTokens`** — the admin's own pair parked during view-as and restored on return; `/auth/encrypted` is never called to re-mint. See [view-as-session-tokens](features/view-as-session-tokens.md).
|
|
150
153
|
- Failures are reported to **Sentry** with `errorType: "API"` tags.
|
|
151
154
|
|
|
152
155
|
The backend (`api2`) returns the standard 2.0 envelope (`success`/`data`/`errors`; see api2 arch); generic CRUD helpers live in `src/api/genericApi.ts` (paginated `getData`, `saveData`, `updateData`, `deleteData`, optional `?depth=-1` for nested responses).
|
|
@@ -1,17 +1,16 @@
|
|
|
1
1
|
---
|
|
2
|
-
title: Header "Order for <Name>" dropdown + the
|
|
2
|
+
title: Header "Order for <Name>" dropdown + the persisted pick handed to the cart
|
|
3
3
|
framework: "2.0"
|
|
4
4
|
repo: toga2-commerce
|
|
5
5
|
project: TOGa Commerce
|
|
6
6
|
client: shared
|
|
7
7
|
type: feature
|
|
8
|
-
status:
|
|
8
|
+
status: active
|
|
9
9
|
updated: 2026-09-17
|
|
10
10
|
owners: [tcox]
|
|
11
11
|
files:
|
|
12
12
|
- src/components/OrderForDropdown/OrderForDropdown.tsx
|
|
13
13
|
- src/components/OrderForDropdown/useOrderForDropdownViewModel.ts
|
|
14
|
-
- src/components/OrderForDropdown/types.ts
|
|
15
14
|
- src/stores/useOrderForZu.ts
|
|
16
15
|
- src/components/Header/Header.tsx
|
|
17
16
|
- src/pages/Cart/view/cartForm/EditCart.tsx
|
|
@@ -25,77 +24,81 @@ related:
|
|
|
25
24
|
- 2.0/apps/toga2-commerce/features/client-fields.md
|
|
26
25
|
- 2.0/apps/toga2-commerce/features/cart-notification-emails.md
|
|
27
26
|
- 2.0/apps/toga2-commerce/features/cart-page-config-architecture.md
|
|
27
|
+
- 2.0/apps/toga2-commerce/features/view-as-session-tokens.md
|
|
28
28
|
- 2.0/apps/toga2-commerce/workflows/cypress-testing.md
|
|
29
29
|
---
|
|
30
30
|
|
|
31
|
-
Header pill that lets a buyer pick who they are ordering for from any page, and the
|
|
31
|
+
Header pill that lets a buyer pick who they are ordering for from any page, and the persisted store that hands that pick to the cart's order-for cascade; open before touching the header pill, `useOrderForZu`, or the cart `orderFor` field.
|
|
32
32
|
|
|
33
33
|
## Summary
|
|
34
34
|
|
|
35
|
-
TRUE-81851. Before this, "order for someone else" existed **only** on the cart form. The header now renders an **"Order for <Name>"** pill (next to the logo on desktop, inside the mobile `SlideMenu`) that sets the same pick from any page. The header does **not** run the order-for cascade — it
|
|
35
|
+
TRUE-81851 (pill) + TRUE-81852 (persistence). Before this, "order for someone else" existed **only** on the cart form. The header now renders an **"Order for <Name>"** pill (next to the logo on desktop, inside the mobile `SlideMenu`) that sets the same pick from any page. The header does **not** run the order-for cascade — it stores the pick and the **cart stays the single owner** of the cascade.
|
|
36
36
|
|
|
37
|
-
**
|
|
37
|
+
**The pick is sticky.** Developer requirement: *"Order for needs to be persistent once chosen until the user changes it back to themself or logs out."* The store is persisted to localStorage and survives order submit, edit-order exit, view-as return, and page reload.
|
|
38
38
|
|
|
39
|
-
**Per-role, per-tenant gated.** Copy and the on/off switch live in `HEADERFIELDS.json` under
|
|
39
|
+
**Per-role, per-tenant gated.** Copy and the on/off switch live in `HEADERFIELDS.json` under an `orderFor` key, Compass USA only: `isEnabled` true for ADMIN / MANAGER / SUPERUSER, false for USER. Compass Canada and Quad have no key, so the component renders nothing (see gotchas).
|
|
40
40
|
|
|
41
41
|
## How it works
|
|
42
42
|
|
|
43
43
|
### 1. The pill (`src/components/OrderForDropdown/`)
|
|
44
44
|
|
|
45
|
-
MVVM split: `OrderForDropdown.tsx` (view), `useOrderForDropdownViewModel.ts` (state + queries), `types.ts`, `index.ts`. Rendered from `
|
|
45
|
+
MVVM split: `OrderForDropdown.tsx` (view), `useOrderForDropdownViewModel.ts` (state + queries), `types.ts`, `index.ts`. Rendered from `Header.tsx`; hidden in edit-order mode.
|
|
46
46
|
|
|
47
47
|
- Trigger label: **"Order for Myself"** when ordering for yourself, else **"Order for <Name>"**.
|
|
48
|
-
-
|
|
49
|
-
- Icons are the **exact Figma SVG paths inlined** (24x24 user, 12.64x6.82 chevron) — not FontAwesome.
|
|
48
|
+
- Teal `#00B9A8` outline + inset glow on hover, when open, and when someone other than the signed-in user is picked (`data-active="yes"`). Icons are the inlined Figma SVG paths, not FontAwesome.
|
|
50
49
|
- Menu: search box → yourself first as **"<Name> (Myself)"** → the user list → **Load more** past 100. Check mark on the current pick. `aria` listbox/option roles, Escape closes and returns focus to the trigger, outside click closes.
|
|
51
50
|
- Copy keys under `orderFor`: `isEnabled`, `label`, `searchPlaceholder`, `myselfLabel`, `loadingMessage`, `noOptionsFoundMessage`, `loadMoreLabel` — in all four COMPASS/ENGLISH role files.
|
|
52
51
|
|
|
53
52
|
### 2. There is no "delegation" API — it is the cart's own user list
|
|
54
53
|
|
|
55
|
-
Verified 2026-09-17 across `toga2-commerce`, `api2`, `_underscore`, `dbchanges2` and the client-sandbox + prod databases
|
|
54
|
+
Verified 2026-09-17 across `toga2-commerce`, `api2`, `_underscore`, `dbchanges2` and the client-sandbox + prod databases: **no delegation endpoint or table exists.** api2's `/auth/delegator` is the view-as token, unrelated (see [view-as-session-tokens](view-as-session-tokens.md)).
|
|
56
55
|
|
|
57
|
-
The list is the same `GET /users` the cart
|
|
56
|
+
The list is the same `GET /users` the cart uses, via `getUserOptions()` / `getSearchValuesFromAdvancedInput()` in `src/pages/Cart/api/CartApi.ts`:
|
|
58
57
|
|
|
59
58
|
- **Admins** get all active users; server-side search when `CARTPAGE settings.isSearchAnyoneEnabled`.
|
|
60
59
|
- **Supervisors** get their direct reports through the `Users@supervisor` join, filtered locally.
|
|
61
|
-
- Field lists come from the per-tenant `CARTPAGE.ts
|
|
60
|
+
- Field lists come from the per-tenant `CARTPAGE.ts`, read through `useAssignClientFields(fieldKey, language, user).fields.CARTPAGEFIELDS`.
|
|
62
61
|
|
|
63
62
|
Hook queries: `useInfiniteQuery(["orderForDelegationList", user.uuid, fieldKey])` in pages of 100, and `useQuery(["orderForDelegationSearch", user.uuid, fieldKey, text])` debounced 400 ms with a 3-character minimum.
|
|
64
63
|
|
|
65
|
-
### 3.
|
|
64
|
+
### 3. `useOrderForZu` — persisted, owner-scoped
|
|
66
65
|
|
|
67
|
-
|
|
66
|
+
`src/stores/useOrderForZu.ts` uses Zustand `persist` with localStorage key **`order-for-zu`**. Stored shape `{ uuid, name, email, ownerUuid }`, where `ownerUuid` is the **signed-in user's** uuid at pick time. Picking yourself is stored too, so the cart knows to reset.
|
|
68
67
|
|
|
69
|
-
`
|
|
68
|
+
- `getOrderForUserFor(pick, ownerUuid)` returns the pick **only** when `pick.ownerUuid === ownerUuid`. A different login, or a view-as target, never inherits it. A stale pick (ownerUuid mismatch) is cleared on cart mount.
|
|
69
|
+
- **Cleared only by:** picking yourself in the header · the cart's clear-user action (`CartPage.handleClearUser` → `clearOrderForUser()`) · logout (`AuthContext.logout`).
|
|
70
|
+
- **Kept across:** order submit (`useSubmitOrder.ts`, `createNewSalesOrderSubmit.ts`), edit-order exit (`exitEditOrderModeGlobalSyncReset.ts`), view-as return (`AuthLayout.returnToYourViewOnClick`), and full page reload. The clears that used to live in those four places were removed.
|
|
70
71
|
|
|
71
|
-
|
|
72
|
-
- `ownerUuid` is the signed-in user who made the pick. `getPendingOrderForUserFor(pending, ownerUuid)` returns `null` for anyone else, so a view-as switch or a new login can never inherit someone else's pick.
|
|
73
|
-
- `EditCart.tsx` applies the pick to `orderFor` as **"Name (email)"** — the exact label shape the cart's own input produces — then clears the store.
|
|
74
|
-
- An `appliedHeaderPickRef` is set **before** `setValue` and checked **both before and after** the mount seed's `await fetchUserByContactUuid(...)`, so a pick made while that fetch is in flight is not overwritten by the saved contact.
|
|
75
|
-
- Header display order: pending pick (newest) → `useSelectedUserZu.selectedUser` → self.
|
|
72
|
+
### 4. Header ↔ cart sync
|
|
76
73
|
|
|
77
|
-
|
|
74
|
+
The cart owns the cascade: `EditCart.tsx` watches the react-hook-form field `orderFor`, fetches the full user, then sets the sales-quote contact, manager name, cost center and notification emails, and stores the full user in `useSelectedUserZu`. The header must **not** duplicate that (~100 lines) and must **not** write `useSelectedUserZu` directly — the cascade needs the *previous* value and a *full* user object.
|
|
78
75
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
76
|
+
- `EditCart.tsx` applies the saved pick as **"Name (email)"** (the exact label shape the cart's own input produces) whenever the form's `orderFor` uuid differs from the stored pick. `appliedHeaderPickRef` is set **before** `setValue` and checked **both before and after** the mount seed's `await fetchUserByContactUuid(...)`, so the saved contact cannot overwrite a pick applied while that fetch is in flight.
|
|
77
|
+
- The cart's user-load cascade writes back: `onSuccess` calls `setOrderForUser({ uuid, name: _name, email, ownerUuid: user.uuid })` so header and cart never drift.
|
|
78
|
+
- `onError` calls `handleClearUser()` (required `EditCartTypes` prop passed from `CartPage.tsx`) and raises an error toast from the `CARTPAGE.ts` key `orderFor.settings.loadUserErrorMessage`, with the same string hardcoded as a fallback for tenants without the key.
|
|
79
|
+
- Header display order: stored pick → `useSelectedUserZu.selectedUser` → self.
|
|
82
80
|
|
|
83
|
-
|
|
81
|
+
Rejected: header writes `useSelectedUserZu` directly; header runs its own copy of the cascade.
|
|
84
82
|
|
|
85
|
-
### 5. e2e spec
|
|
83
|
+
### 5. e2e spec
|
|
86
84
|
|
|
87
|
-
`cypress/e2e/homePage/orderForDropdown.cy.ts
|
|
85
|
+
`cypress/e2e/homePage/orderForDropdown.cy.ts` — 4 tests, Compass tenant, **4/4 green**. Asserts the pick survives a route change (via the `account-icon` router Link) and `cy.reload()`, and reads `localStorage['order-for-zu'].state.orderForUser.uuid` / `.ownerUuid` directly.
|
|
88
86
|
|
|
89
87
|
Test ids: `order-for-dropdown`, `order-for-trigger`, `order-for-current-name`, `order-for-menu`, `order-for-search`, `order-for-option`, `order-for-load-more` (mobile variants add `-mobile`).
|
|
90
88
|
|
|
91
89
|
## Gotchas
|
|
92
90
|
|
|
93
|
-
- **
|
|
91
|
+
- **The `ownerUuid` guard is what makes persistence safe — never drop it.** A persisted pick with no owner check is inherited by the next login or by a view-as target and silently orders for the wrong person. Read the store through `getOrderForUserFor(pick, ownerUuid)`, never raw.
|
|
94
92
|
- **Do not set `useSelectedUserZu` from the header.** The cascade reads the *previous* selected user out of the store (see [cart-notification-emails](cart-notification-emails.md)); writing it early makes the previous-user email cleanup a no-op and strands the old person's notification emails on the order.
|
|
95
|
-
- **A missing `orderFor` key renders nothing, by design.** `useAssignClientFields` has no fallback for a missing key, so Compass Canada and Quad get `undefined` and the component returns `null` —
|
|
93
|
+
- **A missing `orderFor` key renders nothing, by design.** `useAssignClientFields` has no fallback for a missing key, so Compass Canada and Quad get `undefined` and the component returns `null` — safe default-off. Adding a tenant means adding the key to every role file for that tenant.
|
|
96
94
|
- **`appliedHeaderPickRef` must be checked after the awaited mount fetch too.** Checking only before the `await fetchUserByContactUuid(...)` lets the saved contact overwrite a pick made while the fetch was in flight.
|
|
97
|
-
- **`_production` and `_sandbox-client` have diverged in `EditCart.tsx`** — `_production` lacks the sandbox delegate-manager code, so a stashed patch will not apply cleanly to that file. To move uncommitted work between branches
|
|
98
|
-
- **Tailwind: `max-h-300px` is not a token**
|
|
95
|
+
- **`_production` and `_sandbox-client` have diverged in `EditCart.tsx`** — `_production` lacks the sandbox delegate-manager code, so a stashed patch will not apply cleanly to that file. To move uncommitted work between branches: `git diff stash@{0}^1 stash@{0} > patch`, dry-run with `git apply --check --3way patch`, then `git apply --3way --exclude=<conflicting file> patch` and hand-port the conflicting file. Keep the stash until the work is committed.
|
|
96
|
+
- **Tailwind: `max-h-300px` is not a token** — use `max-h-[300px]`. `w-345px`, `h-300px`, `border-1` and `shadow-subMenu` do exist.
|
|
97
|
+
|
|
98
|
+
## Change history
|
|
99
|
+
|
|
100
|
+
- 2026-09-17 — Pick is now persisted to `localStorage["order-for-zu"]` with an `ownerUuid` guard (TRUE-81852); the submit / edit-order-exit / view-as-return / reload clears were removed, and the cart writes the loaded user back to the store. Spec now 4/4 green. (tcox)
|
|
101
|
+
- 2026-09-17 — Header order-for pill added (TRUE-81851), in-memory hand-off store, cart clears on failed user load. (tcox)
|
|
99
102
|
|
|
100
103
|
## Related
|
|
101
104
|
|
|
@@ -103,4 +106,5 @@ Test ids: `order-for-dropdown`, `order-for-trigger`, `order-for-current-name`, `
|
|
|
103
106
|
- [client-fields](client-fields.md)
|
|
104
107
|
- [cart-notification-emails](cart-notification-emails.md)
|
|
105
108
|
- [cart-page-config-architecture](cart-page-config-architecture.md)
|
|
109
|
+
- [view-as-session-tokens](view-as-session-tokens.md)
|
|
106
110
|
- [cypress-testing](../workflows/cypress-testing.md)
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: View-as another user — parked admin tokens & return-to-self
|
|
3
|
+
framework: "2.0"
|
|
4
|
+
repo: toga2-commerce
|
|
5
|
+
project: TOGa Commerce
|
|
6
|
+
client: shared
|
|
7
|
+
type: feature
|
|
8
|
+
status: active
|
|
9
|
+
updated: 2026-09-17
|
|
10
|
+
owners: [tcox]
|
|
11
|
+
files:
|
|
12
|
+
- src/api/sessionTokens.ts
|
|
13
|
+
- src/api/apiBaseUrl.ts
|
|
14
|
+
- src/api/axiosInstance.ts
|
|
15
|
+
- src/contexts/AuthContext.tsx
|
|
16
|
+
- src/components/AuthLayout/AuthLayout.tsx
|
|
17
|
+
- src/components/NavIcons/NavIconList.tsx
|
|
18
|
+
- cypress/e2e/viewAs/viewAsUser.cy.ts
|
|
19
|
+
related:
|
|
20
|
+
- 2.0/apps/toga2-commerce/architecture.md
|
|
21
|
+
- 2.0/apps/toga2-commerce/features/header-order-for-dropdown.md
|
|
22
|
+
- 2.0/apps/toga2-commerce/workflows/cypress-testing.md
|
|
23
|
+
- 2.0/apps/api2/architecture.md
|
|
24
|
+
---
|
|
25
|
+
|
|
26
|
+
How an admin impersonates another user and gets back: the delegated token pair, the parked admin pair in localStorage, and the return path. Open before touching auth tokens, the axios refresh, or the view-as button.
|
|
27
|
+
|
|
28
|
+
## Summary
|
|
29
|
+
|
|
30
|
+
TRUE-81852 (commit `a8c3a537`). An admin can browse the storefront **as** another user. Previously, returning to yourself re-minted the admin's tokens through the privileged `/auth/encrypted` call. Now the admin's **own** token pair is parked in localStorage when delegation starts and put back on return — `/auth/encrypted` is no longer called on return at all.
|
|
31
|
+
|
|
32
|
+
All token plumbing lives in **`src/api/sessionTokens.ts`** (new). `src/api/apiBaseUrl.ts` (new) exports `apiBaseUrl` so both the axios instance and the token helpers resolve the same host. `src/utils/auth.ts` and `handleClientAuthentication` / `UserData` in `Login/api/LoginApi.tsx` were dead and were deleted.
|
|
33
|
+
|
|
34
|
+
## How it works
|
|
35
|
+
|
|
36
|
+
### Entering view-as (`NavIconList.tsx` onSuccess)
|
|
37
|
+
|
|
38
|
+
1. `fetchUserTokens(uuid, "/auth/delegator")` mints the delegated pair for the target user.
|
|
39
|
+
2. `beginDelegation(delegatedPair, adminUuid)` parks the admin's current pair under localStorage key **`originalAuthTokens`** as `{ v: 1, access, refresh, userUuid, apiHost, savedAt }`, then writes the delegated pair into `accessToken` / `refreshToken`.
|
|
40
|
+
3. It **never overwrites an existing parked pair** — view-as is one level deep. `isViewAsEnabled` also hides the button when `hasSavedOriginalTokens()` is true.
|
|
41
|
+
4. On any error entering view-as, it rolls back: `restoreOriginalTokens` if a pair is parked, plus `clearOriginalUser()`.
|
|
42
|
+
|
|
43
|
+
### Returning (`AuthLayout.returnToYourViewOnClick`)
|
|
44
|
+
|
|
45
|
+
`restoreOriginalTokens(adminUuid)`:
|
|
46
|
+
|
|
47
|
+
1. Checks `userUuid` **and** `apiHost` match, and age ≤ `PARKED_PAIR_MAX_AGE_MS` (**12h**). It **always removes** the parked key, pass or fail.
|
|
48
|
+
2. On success: puts the admin pair back; if the access JWT is expired (`isAccessTokenExpired` decodes `exp`), calls `refreshAuthTokens()` once **before** the cache clear.
|
|
49
|
+
3. On failure: logs a Sentry warning, clears the view-as store, logs out, navigates to `/`. There is **no** `/auth/encrypted` fallback.
|
|
50
|
+
4. Removes `localStorage["commerce"]` (the persisted react-query cache) so the impersonated user's rows cannot rehydrate under the admin.
|
|
51
|
+
|
|
52
|
+
### Where else the parked pair is cleared
|
|
53
|
+
|
|
54
|
+
`login()` clears it (and the view-as store) first; `fetchPublicToken` and `performLogout` clear it; module load sweeps it when there is no `accessToken`.
|
|
55
|
+
|
|
56
|
+
### Refresh safety (`axiosInstance.ts`)
|
|
57
|
+
|
|
58
|
+
The 401 refresh is **single-flight** (one module-level promise shared by concurrent 401s) and **identity-bound**: `requestRefreshedTokens` returns `null` if `localStorage.refreshToken` changed while the refresh was in flight. That stops an in-flight admin refresh from overwriting a just-minted delegated pair (or the reverse).
|
|
59
|
+
|
|
60
|
+
### Decisions (CSO review — SHIP WITH FIXES)
|
|
61
|
+
|
|
62
|
+
Identity-bound refresh · one-level view-as · 12h max parked age · no `/auth/encrypted` fallback (log out instead) · never put error objects in `JSON.parse` failure logs · match on `apiHost` only (clientId dropped).
|
|
63
|
+
|
|
64
|
+
### e2e (`cypress/e2e/viewAs/viewAsUser.cy.ts`)
|
|
65
|
+
|
|
66
|
+
Seeds a long-lived `ADMIN_ACCESS_JWT`; stubs `/auth/delegator` → `mock-delegated-access-token` / `-refresh`, and `**/v2/pages/meta?page=home-page&app=commerce*`; the fetchUser stub matches on a RegExp over the `?fields=uuid,_name,...` list. Asserts: delegated pair active + parked pair present; after return, `accessToken === ADMIN_ACCESS_JWT`, parked key null, and **zero** calls to `/auth/encrypted`. 1/1 green.
|
|
67
|
+
|
|
68
|
+
## Gotchas
|
|
69
|
+
|
|
70
|
+
- **Never re-mint the admin's tokens on return.** The old `/auth/encrypted` path was a privileged call made from the browser. Restore the parked pair, or log out — those are the only two outcomes.
|
|
71
|
+
- **Always remove `originalAuthTokens`, even on a failed restore.** Leaving a stale parked pair blocks the next view-as (`beginDelegation` refuses to overwrite) and keeps an old admin refresh token on disk.
|
|
72
|
+
- **Clear `localStorage["commerce"]` on return.** The persisted query cache is shared; without the clear, the impersonated user's data rehydrates under the admin identity (see the 24h persisted-cache gotcha in [architecture](../architecture.md)).
|
|
73
|
+
- **Keep the refresh identity-bound.** A refresh that resolves after the identity changed must return `null`, not write tokens.
|
|
74
|
+
- **A view-as test must stub `/v2/pages/meta?page=home-page&app=commerce`.** Unstubbed, the real call fails and triggers the enter-view-as rollback, so the spec sees a return-to-admin it did not ask for.
|
|
75
|
+
|
|
76
|
+
## Open items (out of ticket, not fixed)
|
|
77
|
+
|
|
78
|
+
- `SENTRY_AUTH_TOKEN` is committed in 25 tracked `.env*` files — treat it as compromised and rotate it. (Never store the value in this KB.)
|
|
79
|
+
- Confirm api2 authorizes `/auth/delegator` **server-side** as admin-only; the client-side gate is not a control.
|
|
80
|
+
|
|
81
|
+
## Change history
|
|
82
|
+
|
|
83
|
+
- 2026-09-17 — Created. Admin pair parked in `originalAuthTokens` on delegation and restored on return; `/auth/encrypted` re-mint removed; single-flight identity-bound refresh (TRUE-81852, commit a8c3a537). (tcox)
|
|
84
|
+
|
|
85
|
+
## Related
|
|
86
|
+
|
|
87
|
+
- [architecture](../architecture.md)
|
|
88
|
+
- [header-order-for-dropdown](header-order-for-dropdown.md)
|
|
89
|
+
- [cypress-testing](../workflows/cypress-testing.md)
|
|
90
|
+
- [api2 architecture](../../api2/architecture.md)
|
|
@@ -11,12 +11,12 @@ owners: [tcox]
|
|
|
11
11
|
files:
|
|
12
12
|
- toga2-commerce/cypress/e2e/cartPage/cartV2.cy.ts
|
|
13
13
|
- toga2-commerce/cypress/e2e/homePage/orderForDropdown.cy.ts
|
|
14
|
+
- toga2-commerce/cypress/e2e/viewAs/viewAsUser.cy.ts
|
|
14
15
|
- toga2-commerce/cypress/fixtures/cart/fetchUserShippingMethods.json
|
|
15
16
|
- toga2-commerce/cypress/support/commands.ts
|
|
16
17
|
- toga2-commerce/cypress/support/e2e.ts
|
|
17
18
|
- toga2-commerce/src/pages/Cart/CartPage.tsx
|
|
18
19
|
- toga2-commerce/src/pages/Cart/view/cartForm/CartForm.tsx
|
|
19
|
-
- toga2-commerce/src/pages/Cart/view/cartForm/CartFormSection.tsx
|
|
20
20
|
- toga2-commerce/src/pages/Cart/view/cartTable/CartContentsTable.tsx
|
|
21
21
|
- toga2-commerce/src/pages/Cart/view/cartTable/CartTableItem.tsx
|
|
22
22
|
- toga2-commerce/src/components/Inputs/AdvancedInput.tsx
|
|
@@ -27,6 +27,7 @@ related:
|
|
|
27
27
|
- 2.0/apps/toga2-commerce/features/expedited-shipping-gating.md
|
|
28
28
|
- 2.0/apps/toga2-commerce/features/cart-bundle-submission-and-identity.md
|
|
29
29
|
- 2.0/apps/toga2-commerce/features/header-order-for-dropdown.md
|
|
30
|
+
- 2.0/apps/toga2-commerce/features/view-as-session-tokens.md
|
|
30
31
|
- 2.0/apps/toga25-supply/workflows/cypress-testing.md
|
|
31
32
|
---
|
|
32
33
|
|
|
@@ -100,7 +101,12 @@ The cart UI was instrumented with `data-testid` attributes so specs anchor on **
|
|
|
100
101
|
|
|
101
102
|
### Other active specs
|
|
102
103
|
|
|
103
|
-
- `cypress/e2e/homePage/orderForDropdown.cy.ts`
|
|
104
|
+
- `cypress/e2e/homePage/orderForDropdown.cy.ts` — the header order-for pill, COMPASS only, 4 tests, **4/4 green**. Asserts the pick survives a route change and `cy.reload()` by reading `localStorage['order-for-zu']`. Ids `order-for-*` (see [header-order-for-dropdown](../features/header-order-for-dropdown.md)).
|
|
105
|
+
- `cypress/e2e/viewAs/viewAsUser.cy.ts` — admin view-as enter/return, **1/1 green**. Seeds a long-lived admin JWT, stubs `/auth/delegator` and `**/v2/pages/meta?page=home-page&app=commerce*` (see [view-as-session-tokens](../features/view-as-session-tokens.md)).
|
|
106
|
+
|
|
107
|
+
### Known-red specs (pre-existing, not yours)
|
|
108
|
+
|
|
109
|
+
On prod base `aedd1f7a`, Compass tenant: **13 failures** — `getSupportV2` (4), `homePageContentV2` (7), `firstTimeLoggingIn` (2). Reproduced identically on an untouched `git archive aedd1f7a` copy, so they are **not** caused by feature work. Likely cause: the login command's `/users/{uuid}` stub field list no longer matches the app's request, so the call falls through to the real API and 401s. Full Compass suite on the TRUE-81852 feature branch: **54 pass / 13 fail** — the same 13.
|
|
104
110
|
|
|
105
111
|
**Not yet covered — slice 2 work-list:**
|
|
106
112
|
- **Bundle scenarios** — blocked on the stale bundle seeds (rebuild `seedCartBundle` / `seedSalesQuoteBundle` first, above).
|
|
@@ -114,13 +120,24 @@ The cart UI was instrumented with `data-testid` attributes so specs anchor on **
|
|
|
114
120
|
2. Start the **dev server separately** first: `npm run compass` | `npm run compasscanada` | `npm run quad`.
|
|
115
121
|
3. **Windows invocation:** `$env:TENANT="compass"; npm run cypress` — `cross-env` is **not** installed, so set the env var in the shell.
|
|
116
122
|
4. Requires **hosts-file entries** `127.0.0.1 compass|compasscanada|quad.togacommerce` (present on this machine but **undocumented in the README** — a new dev must add them).
|
|
123
|
+
5. Headless run (works despite there being no npm script): `Remove-Item Env:ELECTRON_RUN_AS_NODE` first (see gotcha), then `$env:TENANT="compass"; npx cypress run --env TENANT=compass` with vite dev serving `http://compass.togacommerce:5173`.
|
|
117
124
|
|
|
118
125
|
## Gotchas
|
|
119
126
|
|
|
127
|
+
- **`ELECTRON_RUN_AS_NODE=1` breaks every spec in a VS Code terminal.** VS Code's integrated terminals set it, and `npx cypress run` then fails verification on every spec with `Cypress.exe ... bad option: --smoke-test`. Fix: `Remove-Item Env:ELECTRON_RUN_AS_NODE` in that shell before running. Reinstalling the Cypress binary does **not** help.
|
|
128
|
+
- **The users API URL-encodes hyphens as `%2D`.** A glob like `**/v2/users?*no-such*` never matches — use a hyphen-free search term in the test, or match with a RegExp.
|
|
129
|
+
- **"Get Support" in the header is a plain `<a>`** (full page reload). To test that state survives a *route change*, click `account-icon` — that one is a router `Link`.
|
|
130
|
+
- **The `EnvironmentBadge` covers the header corner.** For outside-click tests, click `header-search-input` instead of the corner.
|
|
131
|
+
- **A later `cy.intercept` wins over an earlier one for the same URL** — define the narrow override after the general stub, not before.
|
|
120
132
|
- **`BaseButton` never sets the native `disabled` attribute (test-critical).** `src/components/BaseButton/BaseButton.tsx` sets `aria-disabled="true"|"false"` and suppresses `onClick` inside `handleClick` — it deliberately leaves the element enabled so a disabled button (e.g. **Proceed to Checkout**) still receives mouse events and its `BaseTooltip` (*"Please complete all required fields to proceed."*) works. Cypress assertions **MUST** use `.should('have.attr', 'aria-disabled', 'true'|'false')`, **never** `.should('be.disabled')` — `be.disabled` times out even though the button is visually/functionally disabled. (This cost the only 2 first-run failures.)
|
|
121
133
|
- **Seed → then `cy.visit('/cart')`.** The persist stores rehydrate on load only; seeding `localStorage` and navigating in-app leaves the stores stale (see seeding section).
|
|
122
134
|
- **Bundle seeds are on the legacy shape** — do not write a bundle spec against them until they are rebuilt to the `bundleZuCartUuid` + `bundleProgressContents` contract.
|
|
123
|
-
- **ESLint fails repo-wide in a fresh checkout** because `.eslintrc.cjs` references `eslint-plugin-react-compiler`, which is not installed; `npm install --save-dev eslint-plugin-react-compiler` fixes it. To lint without touching the checkout, run `eslint --no-eslintrc
|
|
135
|
+
- **ESLint fails repo-wide in a fresh checkout** because `.eslintrc.cjs` references `eslint-plugin-react-compiler`, which is not installed; `npm install --save-dev eslint-plugin-react-compiler` fixes it. To lint without touching the checkout, run `npx eslint --no-eslintrc --plugin react-hooks --plugin @typescript-eslint <files>`. This is a local-environment issue, not a spec problem.
|
|
136
|
+
|
|
137
|
+
## Change history
|
|
138
|
+
|
|
139
|
+
- 2026-09-17 — Added `viewAsUser.cy.ts`; orderForDropdown now green; recorded the `ELECTRON_RUN_AS_NODE` failure, the intercept pitfalls, and the 13 pre-existing red specs on base `aedd1f7a`. (tcox)
|
|
140
|
+
- 2026-09-17 — Created with the V2 convention set and the `cartV2.cy.ts` cart harness (slice 1). (tcox)
|
|
124
141
|
|
|
125
142
|
## Related
|
|
126
143
|
|
|
@@ -128,4 +145,6 @@ The cart UI was instrumented with `data-testid` attributes so specs anchor on **
|
|
|
128
145
|
- [cart-notification-emails](../features/cart-notification-emails.md)
|
|
129
146
|
- [expedited-shipping-gating](../features/expedited-shipping-gating.md)
|
|
130
147
|
- [cart-bundle-submission-and-identity](../features/cart-bundle-submission-and-identity.md)
|
|
148
|
+
- [header-order-for-dropdown](../features/header-order-for-dropdown.md)
|
|
149
|
+
- [view-as-session-tokens](../features/view-as-session-tokens.md)
|
|
131
150
|
- [toga25-supply Cypress harness](../../toga25-supply/workflows/cypress-testing.md)
|
package/knowledge/INDEX.md
CHANGED
|
@@ -31,7 +31,7 @@ _Auto-generated by `knowledge.js index`. Do not hand-edit._
|
|
|
31
31
|
- **talos-backend** (TOGa IQ Backend) — 1 doc(s) → [2.0/apps/talos-backend/INDEX.md](2.0/apps/talos-backend/INDEX.md)
|
|
32
32
|
- **voice-to-voice** (TOGa Voice) — 4 doc(s) → [2.0/apps/voice-to-voice/INDEX.md](2.0/apps/voice-to-voice/INDEX.md)
|
|
33
33
|
- **ai-bdr** (AI-BDR) — 13 doc(s) → [2.0/apps/ai-bdr/INDEX.md](2.0/apps/ai-bdr/INDEX.md)
|
|
34
|
-
- **toga2-commerce** (TOGa Commerce) —
|
|
34
|
+
- **toga2-commerce** (TOGa Commerce) — 24 doc(s) → [2.0/apps/toga2-commerce/INDEX.md](2.0/apps/toga2-commerce/INDEX.md)
|
|
35
35
|
- **toga25-supply** (TOGa 2.5 Supply) — 21 doc(s) → [2.0/apps/toga25-supply/INDEX.md](2.0/apps/toga25-supply/INDEX.md)
|
|
36
36
|
- **toga-blox** (TOGa Blox) — 14 doc(s) → [2.0/apps/toga-blox/INDEX.md](2.0/apps/toga-blox/INDEX.md)
|
|
37
37
|
- **bdr** (BDR) — 0 doc(s) → [2.0/apps/bdr/INDEX.md](2.0/apps/bdr/INDEX.md)
|
package/package.json
CHANGED