toga-ai 1.0.928 → 1.0.930
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-api-reference.md +25 -2
- package/knowledge/1.0/apps/tools/INDEX.md +1 -0
- package/knowledge/1.0/apps/tools/features/force-logout.md +46 -0
- package/knowledge/1.0/apps/tools/features/worker2-job-bridge.md +13 -2
- package/knowledge/1.0/apps/worker/features/netsuite-togasupply-per-client-sync.md +28 -1
- package/knowledge/2.0/apps/api2/features/v2-api-error-codes.md +19 -1
- package/knowledge/2.0/apps/dbchanges2/features/rerunnable-additive-inserts.md +32 -1
- package/knowledge/2.0/apps/toga2-commerce/INDEX.md +1 -1
- package/knowledge/2.0/apps/toga2-commerce/features/catalog-cache-freshness.md +57 -9
- package/knowledge/2.0/apps/toga2-commerce/features/force-logout-on-deployment.md +5 -0
- package/knowledge/2.0/apps/toga25-supply/features/bundle-item-pricing.md +51 -9
- package/knowledge/2.0/apps/toga25-supply/features/force-logout-on-deployment.md +5 -1
- package/knowledge/2.0/apps/toga25-supply/workflows/amplify-deployment.md +31 -1
- package/knowledge/INDEX.md +2 -2
- package/knowledge/clients/nychh/INDEX.md +2 -2
- package/knowledge/clients/nychh/features/transfer-order-notification-emails.md +80 -1
- package/knowledge/clients/nychh/profile.md +2 -2
- package/package.json +1 -1
|
@@ -6,8 +6,8 @@ project: Library
|
|
|
6
6
|
client: shared
|
|
7
7
|
type: feature
|
|
8
8
|
status: active
|
|
9
|
-
updated: 2026-
|
|
10
|
-
owners: ["dfranks", "jcardinal", "kyalamarthi", "ajean", "rgirish"]
|
|
9
|
+
updated: 2026-10-01
|
|
10
|
+
owners: ["dfranks", "jcardinal", "kyalamarthi", "ajean", "rgirish", "bala"]
|
|
11
11
|
files:
|
|
12
12
|
- library/app/api/netsuite/rest.php
|
|
13
13
|
- library/ssl/netsuite_ec_key.pem
|
|
@@ -106,6 +106,7 @@ Type codes (`transaction.type`):
|
|
|
106
106
|
|
|
107
107
|
Status codes (`transaction.status` letter):
|
|
108
108
|
**Sales Orders:** `A` Pending Approval · `B` Pending Fulfillment · `C` Cancelled · `D` Partially Fulfilled · `E` Pending Billing/Partially Fulfilled · `F` Pending Billing · `G` Billed · `H` Closed. **"Open" sales orders = `B`, `D`, `E`, `F`.** Note `listSalesOrders()` returns **all** statuses for a window (no server-side status filter) — callers must filter.
|
|
109
|
+
**Item Fulfillments (`ItemShip`):** `A` Picked · `B` Packed · `C` Shipped.
|
|
109
110
|
**Invoices:** `A` Open · `B` Paid In Full. **Credit Memos:** `A` Open · `B` Fully Applied. **Cash Sales:** `A` Unapproved Payment · `C` Deposited. **Cash Refunds:** `Y` (the only status; empty string in SOAP).
|
|
110
111
|
|
|
111
112
|
Header columns present (✓ confirmed in SuiteQL): `id`, `tranid`, `trandate`, `lastmodifieddate`, `type`, `status`, `entity`, `employee`, `leadsource`, `terms`, `memo`, `otherrefnum`, `foreigntotal`/`total` (both return the same value), `foreignamountunpaid` (AR balance — invoices only; NULL for SO/credit memo/cash sale), `duedate`, `shipmethod`, `opportunity`, `shippingaddress` (numeric id → join `transactionShippingAddress`), and custom fields (`CUSTBODY_END_CUSTOMER`, `CUSTBODY_STOCKING_ORDER`, …).
|
|
@@ -114,6 +115,25 @@ Header fields NOT in SuiteQL (✗ — require a per-id REST GET): `shippingCost`
|
|
|
114
115
|
|
|
115
116
|
Tracking numbers in SuiteQL (verified 2026-09-28): `transaction.trackingnumberlist` (on the IF) / `linkedtrackingnumberlist` (on the SO) return **`trackingnumber` record ids**, not the numbers — resolve with `SELECT id, trackingnumber FROM trackingnumber WHERE id IN (...)`. `trackingnumbermap` returned nothing. The REST per-id GET below also has them.
|
|
116
117
|
|
|
118
|
+
### Order → the transactions created FROM it: `nexttransactionlink`
|
|
119
|
+
|
|
120
|
+
**`createdfrom` is a `transactionline` column — SuiteQL rejects it on `transaction`.** There is no
|
|
121
|
+
header-level createdfrom to filter on. To find an order's fulfillments (or any child document), join
|
|
122
|
+
`nexttransactionlink`:
|
|
123
|
+
|
|
124
|
+
```sql
|
|
125
|
+
SELECT ntl.previousdoc, ntl.nextdoc, t.tranid, t.type, t.status
|
|
126
|
+
FROM nexttransactionlink ntl
|
|
127
|
+
JOIN transaction t ON t.id = ntl.nextdoc
|
|
128
|
+
WHERE ntl.previousdoc IN (<order internal ids>)
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
Pair it with the `ItemShip` status letters above to see how far each fulfillment got. This is how a
|
|
132
|
+
**NYCHH transfer order** is traced: `Client_Nychh.TransferOrders.c_netsuiteInternalSalesOrderId`
|
|
133
|
+
holds a NetSuite **sales order** internal id (a $0 SO *is* the transfer order — see
|
|
134
|
+
[the outbound push](../../../2.0/apps/worker2/features/netsuite-transferorder-outbound-push.md)), so
|
|
135
|
+
`previousdoc` is that id and the `ItemShip` rows that come back are its fulfillments.
|
|
136
|
+
|
|
117
137
|
### `transactionline` table
|
|
118
138
|
|
|
119
139
|
Key columns (confirmed present): `transaction`, `linesequencenumber`, `uniquekey`, `id`, `item`, `itemtype`, `mainline`, `quantity`, `foreignamount`, `rate`, `costestimate`, `class`, `memo`, `quantitybilled`, `quantitycommitted`, `quantitypicked`, `quantityshiprecv`, `createdfrom`, `entity` (line-level customer), `location`, `subsidiary`.
|
|
@@ -271,4 +291,7 @@ SalesOrd per-id GET counts by `lastmodifieddate` window: ~16,600 (90d) / ~61,900
|
|
|
271
291
|
- `../../worker/features/forecast2-netsuite-reconciliation.md` — the audit/trueup tooling that consumes this API.
|
|
272
292
|
|
|
273
293
|
## Change history
|
|
294
|
+
- 2026-10-01 — Added `nexttransactionlink` as the order → child-document join (`createdfrom` is a
|
|
295
|
+
`transactionline` column and is rejected on `transaction`), and the `ItemShip` status letters
|
|
296
|
+
(`A` picked / `B` packed / `C` shipped). Found tracing NYCHH transfer orders. (bala)
|
|
274
297
|
- 2026-09-30 — Gotcha: `inventorybalance.quantityavailable` ignores SO commitments; use `aggregateitemlocation`. (rgirish)
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
| [Tools — Developers Folder (UUID & Password Generators)](features/developer-tools.md) | Tools "Developers" folder — the Generate UUID and Generate Password tools (reference example of the one-tool-per-`mvc/<route>/get.php` pattern); open for the ge |
|
|
13
13
|
| [/emails Email Queue Review (Tools → 2.0 Logs_<Client>.Email)](features/email-queue-review.md) | Staff page to browse any 2.0 client's `Logs_<Client>.Email` queue and release/reject `HELD` review-hold emails. |
|
|
14
14
|
| [/errors Curation Console (Tools → shared Core Logs DB)](features/errors-curation-console.md) | Internal-only 1.0 Tools triage/curation screen (`/errors`) for the 2.0 Issue/Event error pipeline, reading the shared Core Logs DB via `db_toga2logs`; curates, |
|
|
15
|
+
| [Force Logout (tools /developers/force-logout → worker2 sets META_LAST_REFRESH_DATETIME)](features/force-logout.md) | Tools page that signs every 2.0 front-end user out after a big deploy by changing Core parameter `META_LAST_REFRESH_DATETIME` through a worker2 job; open to tri |
|
|
15
16
|
| [GitHub Audit (monthly code-churn report)](features/github-audit.md) | Internal monthly code-churn report at `/developers/github-audit` (lines added/removed across every `agilantsolutions` repo for one month); open for the two-phas |
|
|
16
17
|
| [Legacy Email Notifier (tools /email-migration/notify)](features/legacy-email-notifier.md) | SSO-gated admin tool at `/email-migration/notify` that emails employees a digest of mail still hitting their legacy domains (asks senders to switch to `@togatec |
|
|
17
18
|
| [Tools — Multi-Client SQL (per-client-DB SQL fan-out)](features/multi-client-sql.md) | Tools `/developers/multi-client-sql` — fans one SQL change-set across a live list of 2.0 client/log/archive/module DBs into a copy/download script (never execut |
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Force Logout (tools /developers/force-logout → worker2 sets META_LAST_REFRESH_DATETIME)
|
|
3
|
+
framework: "1.0"
|
|
4
|
+
repo: tools
|
|
5
|
+
project: Tools
|
|
6
|
+
client: shared
|
|
7
|
+
type: feature
|
|
8
|
+
status: active
|
|
9
|
+
updated: 2026-10-01
|
|
10
|
+
owners: [bala]
|
|
11
|
+
files:
|
|
12
|
+
- tools/_/app/developers/forcelogout.php
|
|
13
|
+
- tools/mvc/developers/force-logout/get.php
|
|
14
|
+
- tools/mvc/developers/force-logout/post.php
|
|
15
|
+
- tools/_/app/nav.php
|
|
16
|
+
- worker2/Worker/Platform/ForceLogout.php
|
|
17
|
+
related:
|
|
18
|
+
- ./worker2-job-bridge.md
|
|
19
|
+
- ./developer-tools.md
|
|
20
|
+
- ../../../2.0/apps/toga2-commerce/features/force-logout-on-deployment.md
|
|
21
|
+
- ../../../2.0/apps/toga25-supply/features/force-logout-on-deployment.md
|
|
22
|
+
---
|
|
23
|
+
|
|
24
|
+
Tools page that signs every 2.0 front-end user out after a big deploy by changing Core parameter `META_LAST_REFRESH_DATETIME` through a worker2 job; open to trigger a force logout, or before touching `Core.Parameters` from code.
|
|
25
|
+
|
|
26
|
+
## Summary
|
|
27
|
+
|
|
28
|
+
Page `/developers/force-logout` (nav: Developers → Force Logout, persona **Development Team** only). Dev picks a date/time, presses Update. Replaces the old hand-written INSERT/UPDATE on `Core.Parameters` in prod. toga2-commerce and toga25-supply poll that key and log the user out on **any** change; the value itself does not matter (see the two FE docs in `related`).
|
|
29
|
+
|
|
30
|
+
## How it works
|
|
31
|
+
|
|
32
|
+
1. `post.php` actions: `load` (current value), `queue-update`, `job-status`.
|
|
33
|
+
2. `App_Developers_ForceLogout::queueLogoutTimeUpdate()` validates strict `Y-m-d H:i:s` and **refuses a value equal to the stored one** (no change = no logout). Logs via `error_log` with the user uuid (audit).
|
|
34
|
+
3. Queues `Platform/ForceLogout/SetLogoutTime` with `App_Worker::enqueue` (see [worker2-job-bridge](./worker2-job-bridge.md)).
|
|
35
|
+
4. Page polls `job-status` every 2s: reads `WorkerJobs.isSuccess/dtStarted/output` scoped to this action only; shows only the first line of a failure, file paths stripped.
|
|
36
|
+
5. worker2 `_Worker_Platform_ForceLogout::SetLogoutTime(value, requestedByUserUuid = '')` loads `_Model_Core_Parameter` by the hardcoded key, sets `value`, `save()`. Turns the query cache off first, then calls `_Database::transactionCommit(database: _underscore::DB_CORE)` explicitly.
|
|
37
|
+
|
|
38
|
+
## Gotchas
|
|
39
|
+
|
|
40
|
+
- **⚠ Never take the parameter key from input.** `Core.Parameters` also holds api2 secrets (`API_SECRET_ACCESS_TOKEN` etc). Key is a constant in both tools and worker2.
|
|
41
|
+
- **`createFromFormat` alone accepts `2026-02-31`** (rolls over). Validate with a round trip: `format()` of the parsed date must equal the input.
|
|
42
|
+
- **Works on prod tools only today.** Only `config.production.ini` has both `[worker]` (queue_url/queue_region) and `[database_toga2core]`. alpha has Core but no `[worker]`; no beta tools config has a worker queue.
|
|
43
|
+
- **Deploy worker2 before tools**, or the first job fails with an unknown class.
|
|
44
|
+
|
|
45
|
+
## Change history
|
|
46
|
+
- 2026-10-01 — Built the page + worker2 action, replacing hand-run SQL on Core.Parameters. TRUE-82414. (bala)
|
|
@@ -6,11 +6,12 @@ project: Tools
|
|
|
6
6
|
client: shared
|
|
7
7
|
type: feature
|
|
8
8
|
status: active
|
|
9
|
-
updated: 2026-
|
|
10
|
-
owners: [mhammontree]
|
|
9
|
+
updated: 2026-10-01
|
|
10
|
+
owners: [mhammontree, bala]
|
|
11
11
|
files:
|
|
12
12
|
- tools/_/app/worker.php
|
|
13
13
|
related:
|
|
14
|
+
- ./force-logout.md
|
|
14
15
|
- ../../../clients/compass-usa/features/people-file-import-admin-page.md
|
|
15
16
|
- ./mvc-data-access-patterns.md
|
|
16
17
|
- ../../../2.0/apps/worker2/architecture.md
|
|
@@ -63,6 +64,16 @@ empty result — because the wrapper format is worker2's, not a contract.
|
|
|
63
64
|
`maxExecutionTime`. Never make a page wait for long work — poll, don't block.
|
|
64
65
|
- **Deploy worker2 first.** A Tools page that calls a new action is broken until the action
|
|
65
66
|
exists.
|
|
67
|
+
- **Most tools configs cannot enqueue.** Only `config.production.ini` has a `[worker]` section
|
|
68
|
+
(queue_url/queue_region); alpha and beta tools have none.
|
|
69
|
+
|
|
70
|
+
## Local testing (tools → worker2)
|
|
71
|
+
|
|
72
|
+
1. Tools picks `config.<ENVIRONMENT>.ini` from the `ENVIRONMENT` env var; `config.dev-local.ini` is git-ignored and needs `[worker]` + the DB sections.
|
|
73
|
+
2. Tools `composer install` fails on local PHP 8.1 (`symfony/filesystem` needs 8.2). Workaround: a git-ignored `tools/vendor/autoload.php` that requires worker2's vendor autoload.
|
|
74
|
+
3. The AWS SDK ignores the host in `QueueUrl`. Set `AWS_ENDPOINT_URL_SQS` to a local fake SQS that POSTs the message body to `http://worker2/` (Laragon vhost).
|
|
75
|
+
4. Dev-bypass login must use a `Client_True` email (e.g. `@togatech.com`, not `@goagilant.com`).
|
|
66
76
|
|
|
67
77
|
## Change history
|
|
78
|
+
- 2026-10-01 — Added the local tools→worker2 test setup and the prod-only `[worker]` config fact (TRUE-82414). (bala)
|
|
68
79
|
- 2026-09-23 — Created from TRUE-82250: documented the three `enqueue()` outcomes, the log-line-wrapped `output`, and the CTO decision that tools never speaks SFTP (phpseclib 1.x sha1-only KEX + credential duplication). (mhammontree)
|
|
@@ -6,7 +6,7 @@ project: Worker
|
|
|
6
6
|
client: shared
|
|
7
7
|
type: feature
|
|
8
8
|
status: active
|
|
9
|
-
updated: 2026-
|
|
9
|
+
updated: 2026-10-01
|
|
10
10
|
owners: ["dfranks", "bala", "jcardinal", "mhammontree", "snaredla", "rgirish"]
|
|
11
11
|
files:
|
|
12
12
|
- worker/crons/toga2/netsuite/common_sync_togasupply.php
|
|
@@ -106,6 +106,10 @@ The value is `<seconds>-<STATE>`. Three readings, and only the first is a real f
|
|
|
106
106
|
|
|
107
107
|
- **`1-RUNNING`** (window collapsed ÷3 to 1s) = throwing on **every** run = real freeze. Go read the
|
|
108
108
|
error (`Logs.Event` by clientId → file:line → `Logs_<Client>.Api` on the transactionId).
|
|
109
|
+
**⚠ Nothing alerts on the way down.** `common_sync_togasupply.php:675-681` shrinks the window
|
|
110
|
+
÷3 and the paired *"Interval is already at minimum but previous execution still failed to
|
|
111
|
+
finish"* throw is **commented out** (“so the script can continue”), so a phase degrades
|
|
112
|
+
864000 → … → **1 second** silently and never climbs back. Only the `Parameters` value shows it.
|
|
109
113
|
- **A large-window `*-RUNNING`** (e.g. `864000-RUNNING`, `288000-RUNNING`) = **usually** healthy
|
|
110
114
|
mid-run or a deliberate pause/backfill. **⚠ CORRECTED 2026-09-18 — this is NOT sufficient on its
|
|
111
115
|
own, and the old "NOT stuck, do not fix it" wording caused a real miss.** NYCHH showed
|
|
@@ -577,6 +581,23 @@ purchase orders, invoices and item receipts were disabled again on 2026-09-03**
|
|
|
577
581
|
is exactly why every NYCHH cursor stops at **2026-09-03**. Before concluding "the sync is broken",
|
|
578
582
|
check the launcher's toggles at the relevant commit.
|
|
579
583
|
|
|
584
|
+
### ⚠ NYCHH syncs ONCE A DAY — and its live state is in the CLIENT db, not Core
|
|
585
|
+
|
|
586
|
+
Two traps that cost a diagnosis, both NYCHH-specific:
|
|
587
|
+
|
|
588
|
+
- **The `*/5` schedule is not the real cadence.** `cron.worker.sync.json` registers
|
|
589
|
+
`sync_togasupply_hh.php` every 5 minutes like everyone else, but the wrapper calls
|
|
590
|
+
**`App_Framework::cronInitialization(86400)`** (“THIS CRON JOB IS INTENDED TO BE RUN EVERY DAY”),
|
|
591
|
+
so 287 of every 288 invocations exit immediately. Anything NetSuite ships after the morning run
|
|
592
|
+
waits until the next day. Account for that before calling a late import “broken” — and when
|
|
593
|
+
rewinding a cursor, run the wrapper by hand rather than waiting (the 10-day ceiling above means a
|
|
594
|
+
deep rewind would otherwise take weeks).
|
|
595
|
+
- **Read `Client_Nychh.Parameters`, never the `NETSUITE_HH_*` keys in `Core`.** Core still carries
|
|
596
|
+
legacy `NETSUITE_HH_*` rows whose last-sync dates sit in **May 2026**; they are **stale leftovers
|
|
597
|
+
that nothing writes**. The live cursors and execution modes are
|
|
598
|
+
`NETSUITE_LAST_SYNC_CURSOR_*` / `NETSUITE_EXECUTION_MODE_*` in the client DB (see *Data model*).
|
|
599
|
+
Reading the Core copies leads straight to a wrong diagnosis.
|
|
600
|
+
|
|
580
601
|
### Replaying one order means rewinding the cursor by hand
|
|
581
602
|
|
|
582
603
|
The cursor is a **seek** position (`<lastmodifieddate>|<internalId>`, see above), so an order whose
|
|
@@ -2044,6 +2065,12 @@ library (or vice versa) crashes GroWrk and Adyen on their next sync run.
|
|
|
2044
2065
|
[NetSuite Sync Alert Monitor](../../library/features/netsuite-sync-alert-monitor.md).
|
|
2045
2066
|
|
|
2046
2067
|
## Change history
|
|
2068
|
+
- 2026-10-01 — NYCHH diagnosis traps recorded: the wrapper is gated to **once a day** by
|
|
2069
|
+
`cronInitialization(86400)` despite its `*/5` schedule entry, and the live cursors/modes live in
|
|
2070
|
+
`Client_Nychh.Parameters` — the `NETSUITE_HH_*` keys in `Core` are stale (May 2026) leftovers.
|
|
2071
|
+
Also noted that the “interval already at minimum” throw is **commented out**
|
|
2072
|
+
(`common_sync_togasupply.php:675-681`), so a phase can decay to a 1-second window with no alert.
|
|
2073
|
+
(bala)
|
|
2047
2074
|
- 2026-09-30 — Closed-order rule v3: drop only when every line is closed and nothing was created from it ([doc](../../library/features/netsuite-closed-order-rule.md)). Sync never auto-creates warehouse Locations (`throwIfUnknownWarehouse`); unit location from the line, not `custitemnumber_location`. Added `process_netsuite_records.php` and the stuck-sync diagnosis path. (rgirish)
|
|
2048
2075
|
- 2026-09-24 — TO↔PO links: two-direction reconcile (vendor POs no longer upstream; retireStale moves downstream SO links to 326/330); fixed SO downstream line-link deletes on PO re-sync; new `process_netsuite_order.php` + `NETSUITE_SYNC_LOOKUPS_ONLY`. (jcardinal)
|
|
2049
2076
|
- 2026-09-22 — **The `Logs.Issue` 589 doom-loop's framework root cause is now fully explained and has
|
|
@@ -6,7 +6,7 @@ project: API
|
|
|
6
6
|
client: shared
|
|
7
7
|
type: feature
|
|
8
8
|
status: active
|
|
9
|
-
updated: 2026-
|
|
9
|
+
updated: 2026-10-01
|
|
10
10
|
owners: [mhammontree, tcox, jcardinal, ajean, bala, apeterson]
|
|
11
11
|
files:
|
|
12
12
|
- api2/Component/Api/V2/V2.php
|
|
@@ -86,6 +86,16 @@ The V2 JSON engine (`Component/Api/V2/V2.php`) returns short **message codes** i
|
|
|
86
86
|
15. **A `4xx` validation (`EV-*`) response still leaks the raw failing SQL + DB name (pre-existing, UNFIXED as of 2026-08-13).** Distinct from the 2026-07-30 fix that gated the 500 `message`/`trace` behind `isDebugMode()`: on a DB error surfaced as a **validation** response, the `EV-10` body returned to the **external** caller still contains the raw failing SQL statement **and the DB name** (observed in Office Depot's 400 body on the ASN deadlock path). Worth scrubbing independently. Also **why deadlock detection was built on the MySQL errno** (1213/1205), not on matching the response text — see [V2 deadlock-retry](./v2-deadlock-retry.md).
|
|
87
87
|
16. **A NOT NULL column that is NOT a writable V2 field must be DEFAULTED in the ORM — never sent in the payload.** Adding the key to the POST body throws **`EV-8`** ("field does not exist") because the column has no writable-field registration; but leaving it out throws the DB NOT NULL error on insert. The fix is neither — declare a model default so the ORM writes it without a payload key, e.g. `public $isActive = [self::FIELD_BOOLEAN, self::FIELDOPT_DEFAULT => true];` on `_Model_Client_TransferOrder` (2026-08-27, NYCHH transfer-order POST). Contrast with a NOT NULL column that *should* come from the caller/upstream (that is `EV-10` — resolve the value, don't default it).
|
|
88
88
|
|
|
89
|
+
17. **⚠ `status: 200` + `isSuccess: true` does NOT prove the write landed — check `messages[]`.**
|
|
90
|
+
A DB failure can be attached as an **ERROR message** while the envelope still reports 200/success,
|
|
91
|
+
with the real cause buried in **`messages[].identifiers.error`**. Observed 2026-09-30 on a bundle
|
|
92
|
+
item price save: an `EV-10` *"Column 'currencyId' cannot be null"* rode inside a 200 response.
|
|
93
|
+
Because the HTTP status is 2xx, axios does not throw and blox `apiPut` resolves — so the SPA ran
|
|
94
|
+
its success path and told the user "Bundle item updated" on a write that never happened. **Every
|
|
95
|
+
client must scan `messages[]` for `type: ERROR` and fail on it**, not branch on `isSuccess`/status
|
|
96
|
+
alone. (`isSuccess` is only `status` in the 2xx range; it is not a write receipt.) Background:
|
|
97
|
+
[bundle item pricing](../../toga25-supply/features/bundle-item-pricing.md).
|
|
98
|
+
|
|
89
99
|
### BREAKING (2026-07-30): `error` is now an object, not a bare string
|
|
90
100
|
|
|
91
101
|
The V2 envelope's `error` changed from a bare string (`"EO-1"`) to an **object**:
|
|
@@ -132,9 +142,17 @@ This applies to `api2/Controller/Index.php` and to any `catch (_Exception_Valida
|
|
|
132
142
|
- **Always send an explicit `fields=` list** so a denied field surfaces as `EZ-2` instead of silent omission — but note a `_`-prefixed calculated field named in `fields=` is never ACL-checked (see [V2 REST query contract](v2-rest-query-contract.md)).
|
|
133
143
|
- **`error` is an object `{code, id}`, not a bare string** (since 2026-07-30) — `id` is always present (null on a graceful rejection); do not regex-sweep `.error` → `.error?.code` (local `{success,error}` shapes are not envelopes). OAuth keeps the flat string form deliberately.
|
|
134
144
|
- **Test `_Exception_Business` before `_Exception_Validation` in every catch chain** — business now extends validation; wrong order silently stops capturing business exceptions.
|
|
145
|
+
- **⚠ 200 + `isSuccess: true` is not a write receipt** — a DB failure can ride along as an ERROR entry
|
|
146
|
+
in `messages[]` (cause in `messages[].identifiers.error`). Scan `messages[]` for `type: ERROR`; a
|
|
147
|
+
front end that trusts the status shows a false "saved".
|
|
135
148
|
- **A 4xx validation body still leaks the raw failing SQL + DB name** to external callers (unfixed) — scrub independently.
|
|
136
149
|
- **`c_` prefix is significant** — a mis-prefixed field name rejects the whole write with EV-12; verify native-vs-custom with `SHOW COLUMNS`.
|
|
137
150
|
|
|
151
|
+
## Change history
|
|
152
|
+
- 2026-10-01 — Added note 17: a 200 / `isSuccess: true` envelope can still carry an ERROR entry in
|
|
153
|
+
`messages[]` (DB cause in `messages[].identifiers.error`), so axios/blox never throws and a front end
|
|
154
|
+
reports a false "saved" (`EV-10` on a bundle item price save). (apeterson)
|
|
155
|
+
|
|
138
156
|
## Related
|
|
139
157
|
|
|
140
158
|
- [Add a writable field to a V2 record (3-file migration) + expose a scripted API](../../_underscore/features/acl-permission-chain.md)
|
|
@@ -6,7 +6,7 @@ project: Database Changes
|
|
|
6
6
|
client: shared
|
|
7
7
|
type: feature
|
|
8
8
|
status: active
|
|
9
|
-
updated: 2026-
|
|
9
|
+
updated: 2026-10-01
|
|
10
10
|
owners: [bala, kyalamarthi, apeterson, ajean, jcardinal, rgirish]
|
|
11
11
|
files:
|
|
12
12
|
- dbchanges2/Client_Compass/2026-08-26a - CompassCreativeStudioPersona.sql
|
|
@@ -17,6 +17,7 @@ files:
|
|
|
17
17
|
- dbchanges2/Client_Compass/2026-09-18a - BackfillUpstreamItemFulfillmentTracking.sql
|
|
18
18
|
- dbchanges2/Client_Compass/2026-09-21a - BackfillUpstreamItemFulfillmentUnits.sql
|
|
19
19
|
- dbchanges2/Client_Compass/2026-09-23a - BundlesTableViewFieldFlags.sql
|
|
20
|
+
- dbchanges2/Client_Compass/2026-09-24a - DeduplicateBundleItemPrices.sql
|
|
20
21
|
related:
|
|
21
22
|
- ../architecture.md
|
|
22
23
|
- ../../worker2/features/netsuite-salesorder-open-orders-sync.md
|
|
@@ -318,6 +319,29 @@ PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
|
|
|
318
319
|
no-email straggler does not abort the ALTER for every other tenant. Wrap the backfill in
|
|
319
320
|
`START TRANSACTION … COMMIT` and put the ALTER **after** the COMMIT.
|
|
320
321
|
|
|
322
|
+
### ⚠ A column DEFAULT does NOT protect a `_Model`-written column
|
|
323
|
+
|
|
324
|
+
Making an existing column `NOT NULL DEFAULT <value>` reads as self-healing — "writers that never send
|
|
325
|
+
the column get the default". **That is false for any column declared on a `_Model`.** `_Model` writes
|
|
326
|
+
**every** declared field on INSERT, so it emits `currencyId = NULL` explicitly, and an explicit NULL
|
|
327
|
+
**never** falls back to a column DEFAULT. The insert dies `Error 1048: Column 'x' cannot be null`.
|
|
328
|
+
|
|
329
|
+
> Before adding `NOT NULL` to a column, grep the `_underscore` model for the field. If it is declared,
|
|
330
|
+
> the migration needs a **writer** that sets it — a model default
|
|
331
|
+
> (`public $currencyId = [self::FIELD_FOREIGNKEY, self::FIELDOPT_DEFAULT => …]`) or a payload value.
|
|
332
|
+
> A DEFAULT clause alone only helps hand-written SQL that omits the column.
|
|
333
|
+
|
|
334
|
+
And you usually cannot just leave the column nullable: a unique key over a nullable column enforces
|
|
335
|
+
nothing, because MySQL treats every NULL as distinct — so a dedupe that adds
|
|
336
|
+
`UNIQUE (a, b, currencyId)` is only real once `currencyId` is NOT NULL **and** populated by the ORM.
|
|
337
|
+
Ship the model default in the **same** change as the ALTER, never after it.
|
|
338
|
+
|
|
339
|
+
Live break: `Client_Compass/2026-09-24a - DeduplicateBundleItemPrices.sql` took down every bundle
|
|
340
|
+
item price insert on client-sandbox — see
|
|
341
|
+
[bundle item pricing](../../toga25-supply/features/bundle-item-pricing.md). Related, from the other
|
|
342
|
+
direction: a NOT NULL column that is not a writable V2 field must be defaulted in the ORM, never sent
|
|
343
|
+
in the payload ([V2 error codes](../../api2/features/v2-api-error-codes.md), note 16).
|
|
344
|
+
|
|
321
345
|
### ⚠ latin1 schema drift breaks a forced COLLATE in a fan-out file
|
|
322
346
|
|
|
323
347
|
Some tenants' `uuid`/text columns are `latin1`, not utf8mb4 (verified Nychh, Spglobal). So a forced
|
|
@@ -451,6 +475,8 @@ an INSERT that names only `uuid`/`tableViewId`/`recordFieldId`/`isVisible`/`inde
|
|
|
451
475
|
|
|
452
476
|
## Gotchas
|
|
453
477
|
|
|
478
|
+
- **⚠ `NOT NULL DEFAULT <value>` does not save a `_Model` column** — the ORM writes an explicit NULL,
|
|
479
|
+
which skips the DEFAULT and errors 1048. Ship a model default with the ALTER.
|
|
454
480
|
- **⚠ `col = 0` never matches a NULL** — guard flag-enabling UPDATEs with `COALESCE(col, 0) = 0`, or the migration reports success and changes nothing.
|
|
455
481
|
- **⚠ An unguarded ACL grant migration duplicates silently — `AclRecordPermissions` has no unique key
|
|
456
482
|
that stops it (found in prod 2026-09-15).** `_modules/netsuite/2026-09-11c - ItemClassRecordPermission.sql`
|
|
@@ -514,6 +540,11 @@ an INSERT that names only `uuid`/`tableViewId`/`recordFieldId`/`isVisible`/`inde
|
|
|
514
540
|
[FIELD_SQL calculated fields](../../_underscore/features/calculated-sql-fields.md).
|
|
515
541
|
|
|
516
542
|
## Change history
|
|
543
|
+
- 2026-10-01 — Added the `_Model` + column-DEFAULT trap: `_Model` writes every declared field, so an
|
|
544
|
+
explicit NULL skips a `NOT NULL DEFAULT` clause and the insert dies 1048; a unique key over a
|
|
545
|
+
nullable column enforces nothing, so the model default must ship with the ALTER
|
|
546
|
+
(`Client_Compass/2026-09-24a - DeduplicateBundleItemPrices.sql` broke every bundle item price
|
|
547
|
+
insert). (apeterson)
|
|
517
548
|
- 2026-09-28 — `Users_Personas` / `Users_Roles` added to the composite-unique table (both unique on the id pair, so a guarded re-run is a clean no-op), plus the counterpoint to the dtCreated rule: these user bridges have no timestamp column at all. (apeterson)
|
|
518
549
|
- 2026-09-23 — Added the NULL-unsafe flag-guard trap (`col = 0` matches no NULL row; use `COALESCE(col, 0) = 0`), found when a bundles TableViewFields migration updated 0 rows; flagged the 2026-08-26 `EnableTableViewFieldFilters` files as carrying the same hole. (apeterson)
|
|
519
550
|
- 2026-09-22 — Added multi-tier chain backfill rules from the Compass tracking/serial backfills: re-run the same file until the verify count is 0 (each pass moves data up one tier; both files needed 3 passes); `GROUP BY` the target key pair instead of `SELECT DISTINCT` when many downstream rows roll up to one upstream row (bundles tripped a bridge UNIQUE 6-to-1); measure the safe/ambiguous/no-match split before writing bulk DML and skip the ambiguous rows (4,900 broken links = 1,982 safe + 0 ambiguous + 2,918 no-match). Sharpened the unique-key rule with measured composite keys across the four item-fulfillment tables — `ItemFulfillmentItemUnits` has none and already carries 254 duplicates. (rgirish)
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
| [Cart Notification Emails — auto-add/remove lifecycle + duplicate prevention](features/cart-notification-emails.md) | Cart Notifications section: CC-email duplicate prevention + auto-add/remove of order-for-user & delegate-manager emails; open when touching notification emails |
|
|
9
9
|
| [Cart Order-Total & Shipping Computation](features/cart-order-total-computation.md) | How the cart total (subtotal/shipping/tax/total) is computed and where shipping cost flows from; open when a cart total or shipping charge looks wrong. |
|
|
10
10
|
| [Cart Page — config-driven form architecture (current state + planned refactor)](features/cart-page-config-architecture.md) | Current state of the Cart form (field-config-driven, section-hardcoded, two dual-mode forms) plus the DECIDED-but-unbuilt config-driven refactor to the toga2.5 |
|
|
11
|
-
| [Catalog cache freshness — the
|
|
11
|
+
| [Catalog cache freshness — the persisted query cache and the per-query staleTime overrides](features/catalog-cache-freshness.md) | The persisted React Query cache (`localStorage["commerce"]`) plus the per-query `staleTime` overrides that still serve prices up to 24h old, how to opt a query |
|
|
12
12
|
| [Category Tile Order (AssortmentItems.sortOrder) — merchandising a storefront category](features/category-tile-sort-order.md) | Storefront category tile order is one DB column (`AssortmentItems.sortOrder` ASC) with a NULL-sorts-first trap; open when asked to reorder items on a category p |
|
|
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. |
|
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
---
|
|
2
|
-
title: Catalog cache freshness — the
|
|
2
|
+
title: Catalog cache freshness — the persisted query cache and the per-query staleTime overrides
|
|
3
3
|
framework: "2.0"
|
|
4
4
|
repo: toga2-commerce
|
|
5
5
|
project: TOGa Commerce
|
|
6
6
|
client: shared
|
|
7
7
|
type: feature
|
|
8
8
|
status: active
|
|
9
|
-
updated: 2026-
|
|
9
|
+
updated: 2026-10-01
|
|
10
10
|
owners: ["bala", "apeterson", "tcox"]
|
|
11
11
|
files:
|
|
12
|
+
- toga2-commerce/src/lib/queryClient.ts
|
|
12
13
|
- toga2-commerce/src/App.tsx
|
|
13
14
|
- toga2-commerce/src/lib/queryCacheRestoration.ts
|
|
14
15
|
- toga2-commerce/src/contexts/AuthContext.tsx
|
|
@@ -18,32 +19,43 @@ files:
|
|
|
18
19
|
- toga2-commerce/src/pages/BundleView/hooks/useBundleQueryData.ts
|
|
19
20
|
- toga2-commerce/src/pages/Home/viewModel/useHomeViewModel.ts
|
|
20
21
|
- toga2-commerce/src/pages/ItemsView/viewModel/useItemDetailsViewModel.ts
|
|
22
|
+
- toga2-commerce/src/pages/Filter/viewModel/useFilterViewModel.ts
|
|
23
|
+
- toga2-commerce/src/pages/Cart/viewModel/useCartViewModel.ts
|
|
24
|
+
- toga2-commerce/src/pages/Cart/viewModel/useEditAddressFromCartForm.ts
|
|
25
|
+
- toga2-commerce/src/queries/queries.ts
|
|
26
|
+
- toga2-commerce/src/hooks/useDeploymentGuard.tsx
|
|
21
27
|
related:
|
|
22
28
|
- ./inactive-item-purchase-gating.md
|
|
29
|
+
- ./force-logout-on-deployment.md
|
|
30
|
+
- ../../../standards/frontend.md
|
|
23
31
|
- ../architecture.md
|
|
24
32
|
- ../../toga25-supply/features/persisted-query-cache.md
|
|
25
33
|
- ../../toga25-supply/features/force-logout-on-deployment.md
|
|
26
34
|
---
|
|
27
35
|
|
|
28
|
-
The
|
|
36
|
+
The persisted React Query cache (`localStorage["commerce"]`) plus the per-query `staleTime` overrides that still serve prices up to 24h old, how to opt a query out, and the stacked causes behind "stale data / cache won't clear / stuck login"; open for any freshness or forced-logout bug.
|
|
29
37
|
|
|
30
38
|
## Summary
|
|
31
39
|
|
|
32
|
-
> **Status 2026-
|
|
40
|
+
> **Status 2026-10-01:** the **global** default is already `staleTime: 0` (causes 1, 2 and 3 fixed; cause 4 open). The remaining staleness is **per-query**: several price-bearing queries hardcode `staleTime: 24h`. See [Where the staleness lives today](#where-the-staleness-lives-today-2026-10-01) before changing any knob — setting the global `staleTime` to 0 is a **no-op**, it is already 0.
|
|
33
41
|
|
|
34
|
-
|
|
42
|
+
The whole React Query cache is persisted to **`localStorage["commerce"]`** via `PersistQueryClientProvider`. Paired with a 24h `staleTime` that serves **catalogue and price state up to 24 hours out of date, across full page reloads** — in **both directions**: after an item is switched off, a user who already viewed it keeps seeing the live product page with a working Add to Cart; after it is switched back on, it stays hidden. The 24h value was the **client default** until 2026-09-22; it now survives only as **per-query overrides**.
|
|
35
43
|
|
|
36
44
|
Any query whose answer **gates an action** (can this be bought? is this still available?) must be opted out of *both* mechanisms. Opting out of one is not enough.
|
|
37
45
|
|
|
38
|
-
**Three long-running production complaints all trace back to this file set**, pinned to `file:line` on 2026-09-03 (read-only, branch `TRUE-81610`): "users don't see updated data", "clearing their cache doesn't work", "users get stuck on a broken login page after a forced logout". They are **four separate causes** (below)
|
|
46
|
+
**Three long-running production complaints all trace back to this file set**, pinned to `file:line` on 2026-09-03 (read-only, branch `TRUE-81610`): "users don't see updated data", "clearing their cache doesn't work", "users get stuck on a broken login page after a forced logout". They are **four separate causes** (below); causes 1-3 are fixed, cause 4 is open.
|
|
39
47
|
|
|
40
48
|
## How it works
|
|
41
49
|
|
|
42
|
-
### The default:
|
|
50
|
+
### The default: fresh in memory, still persisted to disk
|
|
43
51
|
|
|
44
|
-
`
|
|
52
|
+
The `QueryClient` **no longer lives in `App.tsx`** — it moved to **`src/lib/queryClient.ts`**, which sets **`staleTime: 0`** and **`refetchOnWindowFocus: false`**. The cache is still persisted to `localStorage["commerce"]` via `createSyncStoragePersister`, now with a build-id **`buster`** on the persister.
|
|
45
53
|
|
|
46
|
-
|
|
54
|
+
> **⚠ The old "24h default `staleTime`" is gone — do not "fix" freshness by setting the global default to 0.** It already is 0. Any remaining 24h behaviour comes from **per-query overrides** (next section). Verified 2026-10-01; older docs and the [architecture doc](../architecture.md) still describe the 24h default.
|
|
55
|
+
|
|
56
|
+
A per-query `staleTime` always beats the client default, so a hook that hardcodes 24h is unaffected by the global value — including the `["clientFields", …]` query that carries the per-tenant `FIELDS` content, which is why copy/config deploys can still look stale for returning users.
|
|
57
|
+
|
|
58
|
+
**TOGa 2.5 Supply** reached the same default — `staleTime: 0` *because* the cache is persisted — and has the same per-hook override problem. See [supply's persisted query cache](../../toga25-supply/features/persisted-query-cache.md).
|
|
47
59
|
|
|
48
60
|
### The platform's cache-bust: the Core parameters blob
|
|
49
61
|
|
|
@@ -106,6 +118,38 @@ The persister is also set to **`throttleTime: 0`**: the 1000ms default meant a q
|
|
|
106
118
|
|
|
107
119
|
Separately, commerce's `login()` **never cleared the cache**, so signing in as a different user inherited the prior user's data. ✅ **That half is fixed 2026-09-22** — `resetClientState()` runs on **both** login and logout (see [SSO & session gating](./sso-redirect-and-session-gating.md)). The force-logout aggression and the history monkey-patch are **still open**.
|
|
108
120
|
|
|
121
|
+
### Where the staleness lives today (2026-10-01)
|
|
122
|
+
|
|
123
|
+
Inventory verified read-only on 2026-10-01. Every value below is a **per-query override** that wins over the global `staleTime: 0`.
|
|
124
|
+
|
|
125
|
+
| staleTime | Where |
|
|
126
|
+
|---|---|
|
|
127
|
+
| **24h** | `pages/ItemsView/viewModel/useItemDetailsViewModel.ts:76` (item detail) · `pages/Filter/viewModel/useFilterViewModel.ts:106,134` (filter/search results) · `pages/Cart/viewModel/useCartViewModel.ts:310` (shipping methods) · `pages/Cart/viewModel/useEditAddressFromCartForm.ts:75` |
|
|
128
|
+
| **5 min** | `queries/queries.ts:39,46,68,78` (login / prefetch) |
|
|
129
|
+
| **30s** (+ `gcTime: 1h`) | `pages/Home/viewModel/useHomeViewModel.ts:150,175,201` · `pages/BundleView/hooks/useBundleQueryData.ts:26,42` |
|
|
130
|
+
|
|
131
|
+
**Consequence — the answer to "why doesn't a price change show up":** a price or availability edit can take **up to 24 hours** to reach a shopper on the item page, and **a browser refresh does not fix it**, because the cache is persisted to `localStorage["commerce"]` and survives reload and browser restart.
|
|
132
|
+
|
|
133
|
+
### Planned change — ticket scope (agreed 2026-10-01, NOT implemented)
|
|
134
|
+
|
|
135
|
+
1. Remove the per-query `staleTime` overrides on item detail, filter/search results, cart shipping methods, Home and BundleView, so the global `0` applies.
|
|
136
|
+
2. Remove `refetchOnWindowFocus: false` from `src/lib/queryClient.ts`, so React Query's default `true` applies app-wide.
|
|
137
|
+
3. Then delete the now-redundant `refetchOnWindowFocus: true` (and its comment) at `src/hooks/useDeploymentGuard.tsx:93`.
|
|
138
|
+
|
|
139
|
+
Open in the ticket: whether `useEditAddressFromCartForm.ts:75` and the 5-minute `queries/queries.ts` queries are in scope. **`gcTime: 1h` on Home and BundleView is deliberately left alone** — `gcTime` controls how long unused data is kept, not when a query refetches.
|
|
140
|
+
|
|
141
|
+
### What "fresh" will mean after that change
|
|
142
|
+
|
|
143
|
+
A data change reaches the shopper on: **navigating to the page** (the query mounts), **refresh / fresh URL**, **the tab becoming visible again**, **network reconnect** (`refetchOnReconnect` defaults true), or a **mutation that invalidates the query**. Nothing polls, so it does **not** update while the shopper sits still on one page with the tab in front.
|
|
144
|
+
|
|
145
|
+
Side benefit: `AuthLayout`-owned queries (header, notifications, `FIELDS` copy) never remount, so today they only refresh on a full page load. Turning focus refetching on covers them — a partial answer to the "FIELDS copy is stale after a deploy" gotcha.
|
|
146
|
+
|
|
147
|
+
Three limits to state to the business up front:
|
|
148
|
+
|
|
149
|
+
1. **Prices already in the cart do NOT update.** The cart lives in Zustand + `localStorage`, not React Query.
|
|
150
|
+
2. **Alt-tabbing to another application and back is not a focus event** — see [frontend standard §4](../../../standards/frontend.md) and [force logout on deployment](./force-logout-on-deployment.md).
|
|
151
|
+
3. **On a cold load the old value paints first** from `localStorage["commerce"]` and swaps when the response lands.
|
|
152
|
+
|
|
109
153
|
### Decision — `gcTime` is the speed knob, `staleTime` is the correctness knob
|
|
110
154
|
|
|
111
155
|
Decided 2026-09-03. The 24h `staleTime` was reached for to make heavy item/bundle pages feel fast. **It is the wrong knob, and it buys zero perceived speed here.**
|
|
@@ -131,6 +175,9 @@ Keep the **app default at `0`**, like supply.
|
|
|
131
175
|
|
|
132
176
|
## Gotchas
|
|
133
177
|
|
|
178
|
+
- **⚠ Setting the global `staleTime` to 0 fixes nothing — it is already 0.** The 24h behaviour is per-query (`src/lib/queryClient.ts` holds the default; the overrides are listed above).
|
|
179
|
+
- **⚠ A price change can be 24h late on the item page and a refresh will not clear it** — the stale copy is in `localStorage["commerce"]`.
|
|
180
|
+
- **⚠ A cart line's price never refetches.** The cart is Zustand + `localStorage`, outside React Query; no `staleTime` change touches it.
|
|
134
181
|
- **⚠ Never verify a catalogue or FIELDS change on a warm browser.** Clear `localStorage.removeItem('commerce')` or log out first. A "it didn't deploy" report is usually a rehydrated cache.
|
|
135
182
|
- **⚠ `gcTime: 0` does not stop persistence.** Active queries are still dehydrated. Exclude the key via `dehydrateOptions.shouldDehydrateQuery` as well.
|
|
136
183
|
- **⚠ Nothing is excluded from persistence on `_production`** — the `shouldDehydrateQuery` allowlist lives only on `_beta`/`#sprint84`. Check the branch.
|
|
@@ -142,6 +189,7 @@ Keep the **app default at `0`**, like supply.
|
|
|
142
189
|
- **`AuthLayout` never remounts**, so `refetchOnMount` fires once per session for queries it owns — a route-change refresh has to be wired explicitly. That interacts with everything above: a query can be both stale *and* never refetched during a session.
|
|
143
190
|
|
|
144
191
|
## Change history
|
|
192
|
+
- 2026-10-01 — Corrected: the global default is already `staleTime: 0` (`QueryClient` moved to `src/lib/queryClient.ts`, `refetchOnWindowFocus: false`, persister `buster` added). Per-query 24h overrides inventoried at `file:line`; ticket scope and the post-change freshness promise (and its cart / alt-tab / first-paint limits) recorded. No code changed. (apeterson)
|
|
145
193
|
- 2026-09-22 — Causes 1 and 2 fixed: app default `staleTime: 0` + `refetchOnWindowFocus: false`, heavy hooks on 30s/1h, and all clears routed through `clearPersistedQueryCache()` with `throttleTime: 0`. Login now resets client state. TRUE-81991 (`e546db3d`). (apeterson)
|
|
146
194
|
- 2026-09-21 — Cause 3 (public-token auth gate) fixed on `TRUE-82172` and moved to its own doc; causes 1, 2, 4 still open. (tcox)
|
|
147
195
|
- 2026-09-03 — Four stacked causes pinned to `file:line`; `gcTime`-vs-`staleTime` decision recorded. (bala, apeterson)
|
|
@@ -8,6 +8,7 @@ type: feature
|
|
|
8
8
|
status: active
|
|
9
9
|
updated: 2026-10-01
|
|
10
10
|
owners: [apeterson]
|
|
11
|
+
owners: [apeterson, bala]
|
|
11
12
|
files:
|
|
12
13
|
- toga2-commerce/src/hooks/useDeploymentGuard.tsx
|
|
13
14
|
- toga2-commerce/src/lib/deploymentVersion.ts
|
|
@@ -15,6 +16,7 @@ files:
|
|
|
15
16
|
- toga2-commerce/src/components/AuthLayout/AuthLayout.tsx
|
|
16
17
|
- toga2-commerce/src/lib/authStorage.ts
|
|
17
18
|
related:
|
|
19
|
+
- ../../../../1.0/apps/tools/features/force-logout.md
|
|
18
20
|
- ./sso-redirect-and-session-gating.md
|
|
19
21
|
- ./catalog-cache-freshness.md
|
|
20
22
|
- ../../toga25-supply/features/force-logout-on-deployment.md
|
|
@@ -32,6 +34,8 @@ Ported from toga25-supply (`force-logout-on-deployment`). Two commerce-only diff
|
|
|
32
34
|
|
|
33
35
|
Reached commerce `_production` only in merge `26e2fae7` (2026-09-30, TRUE-81991); supply had it earlier. That gap is why one parameter bump logged supply users out and commerce users not at all.
|
|
34
36
|
|
|
37
|
+
**Trigger:** change the value with tools `/developers/force-logout` ([force-logout](../../../../1.0/apps/tools/features/force-logout.md)), not hand SQL.
|
|
38
|
+
|
|
35
39
|
## How it works
|
|
36
40
|
|
|
37
41
|
1. One query on `META_LAST_REFRESH_DATETIME`, `enabled` only while authenticated, `staleTime: 0`, `refetchOnWindowFocus: true`, `refetchInterval` **5 minutes**.
|
|
@@ -76,4 +80,5 @@ To also fire on an in-app navigation: a `useLocation()` effect calling `refetch(
|
|
|
76
80
|
|
|
77
81
|
## Change history
|
|
78
82
|
- 2026-10-01 — Documented the exact refetch triggers (mount / tab-visibility / 5-min poll; **not** route change), the background-pause behavior, the first-bump no-op, the ignored `key=` param, and the commerce-vs-supply query config difference. Route-change refetch deferred. (apeterson)
|
|
83
|
+
- 2026-10-01 — Trigger is now the tools Force Logout page (TRUE-82414). (bala)
|
|
79
84
|
- 2026-09-25 — Built for commerce: moved out of `AuthContext` into `<DeploymentGuard />` so a toaster can be shown; added 5-min poll + focus refetch; baseline key added to `AUTH_STORAGE_KEYS`. TRUE-81991. (apeterson)
|
|
@@ -6,7 +6,7 @@ project: TOGa 2.5 Supply
|
|
|
6
6
|
client: shared
|
|
7
7
|
type: feature
|
|
8
8
|
status: active
|
|
9
|
-
updated: 2026-
|
|
9
|
+
updated: 2026-10-01
|
|
10
10
|
owners: ["apeterson"]
|
|
11
11
|
files:
|
|
12
12
|
- toga25-supply/src/layout/BundleItemRecordModalLayout/hooks/useBundleItemEditForm.tsx
|
|
@@ -14,10 +14,14 @@ files:
|
|
|
14
14
|
- toga25-supply/src/layout/BundleRecordModalLayout/viewModel/FIELDS/DEFAULT/bundleRecordViewFields.json
|
|
15
15
|
- toga25-supply/src/layout/BundleRecordModalLayout/components/BundleItemsTable.tsx
|
|
16
16
|
- dbchanges2/Client_Compass/2026-09-24a - DeduplicateBundleItemPrices.sql
|
|
17
|
+
- _underscore/Model/Client/BundleItem.php
|
|
18
|
+
- _underscore/Model/Client/BundleItemPrice.php
|
|
17
19
|
related:
|
|
18
20
|
- ../../api2/features/nested-relationship-writes.md
|
|
19
21
|
- ../../api2/features/v2-rest-query-contract.md
|
|
20
22
|
- ../../_underscore/features/calculated-sql-fields.md
|
|
23
|
+
- ../../dbchanges2/features/rerunnable-additive-inserts.md
|
|
24
|
+
- ../../api2/features/v2-api-error-codes.md
|
|
21
25
|
- ./record-modals-and-nested-tables.md
|
|
22
26
|
- ../../../clients/compass-usa/workflows/changing-a-kit-line-item.md
|
|
23
27
|
---
|
|
@@ -35,8 +39,20 @@ updated on a PUT, and the calculated price fields need `calcDepth: 2` when read
|
|
|
35
39
|
|
|
36
40
|
### Reading — `_unitPrice` / `_leasePrice`
|
|
37
41
|
|
|
38
|
-
Both are `FIELD_SQL` calculated fields on `_Model_Client_BundleItem
|
|
39
|
-
|
|
42
|
+
Both are `FIELD_SQL` calculated fields on `_Model_Client_BundleItem`, and each is a **gated
|
|
43
|
+
COALESCE**, not a plain lookup:
|
|
44
|
+
|
|
45
|
+
1. **Gate** — `_unitPrice` returns NULL unless the bundle item's `defaultPriceType.slug` is `each`;
|
|
46
|
+
`_leasePrice` unless it is `lease`. Wrong default price type → both fields are NULL, which is not
|
|
47
|
+
a missing-data bug.
|
|
48
|
+
2. **First choice** — the `BundleItemPrices` row matching `defaultPriceTypeId` (`ORDER BY id DESC
|
|
49
|
+
LIMIT 1`, **newest** wins).
|
|
50
|
+
3. **⚠ Fallback** — if that row does not exist, it **COALESCEs to the ITEM's `ItemPrices` row** for
|
|
51
|
+
the same price type.
|
|
52
|
+
|
|
53
|
+
So a price on screen is **not proof that a `BundleItemPrices` row exists**. Confirmed on
|
|
54
|
+
client-sandbox: `SELECT * FROM BundleItemPrices WHERE bundleItemId = 67` returned no rows while the
|
|
55
|
+
modal displayed 166 (the item price). Query the table before assuming you are editing an existing row.
|
|
40
56
|
|
|
41
57
|
- The table columns are sourced from these fields, not from the `bundleItemPrices` array.
|
|
42
58
|
- For **nested** bundle items (inside `GET /v2/bundles/{uuid}`) they need **`calcDepth: 2`**, sent
|
|
@@ -73,16 +89,42 @@ established it **creates a new row** rather than overwriting an unknown one. Nev
|
|
|
73
89
|
be proven. Local-copy counts: `Client_Compass` 5117 rows / 2605 duplicates / 971 groups (36 groups
|
|
74
90
|
with differing prices); `Client_CompassCanada` 333 / 75 / 63; all other tenants zero. **Prod counts
|
|
75
91
|
not yet taken.**
|
|
76
|
-
-
|
|
77
|
-
`dbchanges2/Client_Compass/2026-09-24a - DeduplicateBundleItemPrices.sql` and the
|
|
78
|
-
`Client_CompassCanada` twin
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
92
|
+
- **⚠ The dedupe migration BREAKS every bundle item price insert — open bug as of 2026-10-01.**
|
|
93
|
+
`dbchanges2/Client_Compass/2026-09-24a - DeduplicateBundleItemPrices.sql` (and the
|
|
94
|
+
`Client_CompassCanada` twin) keeps `MAX(id)` per `(bundleItemId, priceTypeId)`, then adds a unique
|
|
95
|
+
key on `(bundleItemId, priceTypeId, currencyId)` and makes `currencyId` **NOT NULL DEFAULT
|
|
96
|
+
`<currency>`**. The column DEFAULT does **not** cover the writers: `_Model` writes every declared
|
|
97
|
+
field, so it emits `currencyId = NULL` explicitly and an explicit NULL never falls back to a column
|
|
98
|
+
DEFAULT. `_Model_Client_BundleItemPrice` and `_Model_Client_ItemPrice` declare `currencyId` as a
|
|
99
|
+
plain FK, and nothing in PHP or the SPA ever sets it. Live on client-sandbox:
|
|
100
|
+
`Error #: 1048 / Column 'currencyId' cannot be null` on
|
|
101
|
+
`INSERT INTO BundleItemPrices SET … currencyId = NULL, price = 200`. The NOT NULL is load-bearing —
|
|
102
|
+
with `currencyId` NULL the new unique key stops nothing, because MySQL treats each NULL as distinct.
|
|
103
|
+
General rule: [a column DEFAULT never protects a `_Model`-written column](../../dbchanges2/features/rerunnable-additive-inserts.md).
|
|
104
|
+
**Fix undecided** (as of 2026-10-01): (a) default `currencyId` to the client's currency in
|
|
105
|
+
`_Model_Client_BundleItemPrice` / `_Model_Client_ItemPrice` — no SQL, no FE deploy, recommended; or
|
|
106
|
+
(b) have the SPA send `currency: { uuid }`, which **also needs** the ACL grant below.
|
|
107
|
+
The dedupe itself is otherwise safe: no foreign key references `BundleItemPrices` and
|
|
108
|
+
`SalesOrderItems` snapshots its own price column, so no order or quote is affected.
|
|
109
|
+
- **⚠ The `currencies` route has no ACL grant for the Compass roles**, so the SPA cannot read a
|
|
110
|
+
currency at all. `GET /v2/currencies` → **403 `EZ-1`** (recordId **215**, roleIds `[1,7,9,4,5,15,12,16]`);
|
|
111
|
+
a nested `itemPrices.currency.uuid` projection returns 200 with `"currency": null`. Any front-end fix
|
|
112
|
+
that needs a currency uuid therefore requires a **dbchanges2 `AclRecordPermissions` seed first** — it
|
|
113
|
+
is not a front-end-only change. See [ACL permission chain](../../_underscore/features/acl-permission-chain.md).
|
|
114
|
+
- **⚠ A failed price save can report SUCCESS.** api2 returned `status: 200` / `isSuccess: true` while
|
|
115
|
+
carrying the `EV-10` DB failure inside `messages[].identifiers.error`, so blox `apiPut` did not throw
|
|
116
|
+
and the modal showed "Bundle item updated" on a write that never landed. Check `messages[]` for
|
|
117
|
+
`type: ERROR` — see [V2 error codes](../../api2/features/v2-api-error-codes.md).
|
|
82
118
|
- **⚠ FIELDS JSON edits appear to do nothing** — the persisted React Query cache freezes them; see
|
|
83
119
|
[persisted query cache](./persisted-query-cache.md).
|
|
84
120
|
|
|
85
121
|
## Change history
|
|
122
|
+
- 2026-10-01 — Corrected `_unitPrice`/`_leasePrice`: both are price-type-gated and **COALESCE to the
|
|
123
|
+
item's `ItemPrices` row**, so a displayed price does not prove a `BundleItemPrices` row exists.
|
|
124
|
+
Recorded the open breakage from the 2026-09-24 dedupe migration (`currencyId` NOT NULL DEFAULT vs.
|
|
125
|
+
`_Model` writing an explicit NULL → 1048 on every insert), the missing `currencies` ACL grant
|
|
126
|
+
(record 215, EZ-1), and the 200/`isSuccess:true`-with-an-ERROR-message false success (TRUE-82138).
|
|
127
|
+
(apeterson)
|
|
86
128
|
- 2026-09-24 — Documented the two-call save (price edits were inserting duplicates via a nested PUT),
|
|
87
129
|
the strict-`priceType` row match with create-not-guess fallback, the `_unitPrice`/`_leasePrice`
|
|
88
130
|
columns needing `calcDepth: 2` as a plain query param, and the Compass/Compass Canada
|
|
@@ -7,12 +7,13 @@ client: shared
|
|
|
7
7
|
type: feature
|
|
8
8
|
status: active
|
|
9
9
|
updated: 2026-10-01
|
|
10
|
-
owners: [apeterson]
|
|
10
|
+
owners: [apeterson, bala]
|
|
11
11
|
files:
|
|
12
12
|
- toga25-supply/src/hooks/useDeploymentGuard.tsx
|
|
13
13
|
- toga25-supply/src/App.tsx
|
|
14
14
|
related:
|
|
15
15
|
- ../../toga2-commerce/features/force-logout-on-deployment.md
|
|
16
|
+
- ../../../../1.0/apps/tools/features/force-logout.md
|
|
16
17
|
---
|
|
17
18
|
|
|
18
19
|
Force-signs-out every user when a deploy bumps a Core parameter, so session + caches reset to the new build; open when debugging surprise logouts or `useDeploymentGuard`.
|
|
@@ -21,6 +22,8 @@ Force-signs-out every user when a deploy bumps a Core parameter, so session + ca
|
|
|
21
22
|
|
|
22
23
|
On large deploys the backend bumps Core parameter `META_LAST_REFRESH_DATETIME`. The FE watches it and, when it changes mid-session, force-signs-out every logged-in user so session, `localStorage`, and React Query cache (including the persisted `supply-chain-query-cache`) reset cleanly. Implemented as a headless `<DeploymentGuard />` driven by `useDeploymentGuard`, mounted inside the `AuthProvider` / `ToasterProvider` tree in `App.tsx` — **above** `<AppRouter />`, so the hook cannot use `useLocation()`.
|
|
23
24
|
|
|
25
|
+
**Trigger:** change the value with tools `/developers/force-logout` ([force-logout](../../../../1.0/apps/tools/features/force-logout.md)), not hand SQL.
|
|
26
|
+
|
|
24
27
|
## How it works
|
|
25
28
|
|
|
26
29
|
1. **Poll the Core parameter.** TanStack `useQuery` reads `META_LAST_REFRESH_DATETIME` via `apiGet("/core-parameters", { fields: ["value"], key: "META_LAST_REFRESH_DATETIME" })`, reading `response.data.coreParameters` (tolerant of an array or a single object). Query: `enabled` only while authenticated, `staleTime: 0`, `refetchOnWindowFocus: true`, `refetchInterval` 5 minutes.
|
|
@@ -32,5 +35,6 @@ On large deploys the backend bumps Core parameter `META_LAST_REFRESH_DATETIME`.
|
|
|
32
35
|
**When it actually fires, the first-bump no-op, and the ignored `key=` param are identical here** — documented once in [commerce's force-logout doc](../../toga2-commerce/features/force-logout-on-deployment.md) (sections *What actually triggers a refetch* and *Gotchas*). Supply-only difference: `App.tsx` leaves `refetchOnWindowFocus` at React Query's default `true` app-wide, so every on-screen query refetches on tab visibility — not just this one. Sign-out timing is the same in both apps.
|
|
33
36
|
|
|
34
37
|
## Change history
|
|
38
|
+
- 2026-10-01 — Trigger is now the tools Force Logout page (TRUE-82414). (bala)
|
|
35
39
|
- 2026-10-01 — Recorded that the guard sits above the router (blocks `useLocation()`); moved the shared trigger/gotcha detail to the commerce doc rather than duplicating it. (apeterson)
|
|
36
40
|
- 2026-09-16 — Initial implementation. (apeterson)
|
|
@@ -6,7 +6,7 @@ project: TOGa 2.5 Supply
|
|
|
6
6
|
client: shared
|
|
7
7
|
type: workflow
|
|
8
8
|
status: active
|
|
9
|
-
updated: 2026-
|
|
9
|
+
updated: 2026-10-01
|
|
10
10
|
owners: [jcardinal, apeterson, tcox]
|
|
11
11
|
files:
|
|
12
12
|
- toga25-supply/amplify.yml
|
|
@@ -42,6 +42,21 @@ How toga25-supply deploys to all its Amplify environments from one shared `ampli
|
|
|
42
42
|
|
|
43
43
|
For every remote `_*` branch: `VITE_MODE="${b#_}"; [ -f ".env.$VITE_MODE" ]`. That tells you exactly which branches would fail, in seconds, without a deploy. Restoration of a lost spec/env-set is a verbatim copy, not a rewrite: `git checkout <known-good-ref> -- .env.<mode> …` (19 files restored from `origin/_sandbox-client-backup820`, comments intact). Do **not** overwrite files that already exist (`.env`, `.env.development`, `.env.stage` were left alone). Env-file format is `VITE_API = 'https://...'` — spaces around `=`, single-quoted — and they contain only public base URLs, no secrets.
|
|
44
44
|
|
|
45
|
+
### Prove a change actually reached a deployed environment (do this FIRST)
|
|
46
|
+
|
|
47
|
+
Before debugging "my change is not showing up", confirm whether the built bundle contains it. Read the
|
|
48
|
+
deployed artifact — never infer it from the merge.
|
|
49
|
+
|
|
50
|
+
1. `curl https://<tenant>.client.sandbox.togasupply.com/` and read the `/assets/index-<hash>.js` path
|
|
51
|
+
out of the HTML.
|
|
52
|
+
2. `curl` that asset and `grep` for a string unique to your change (a route segment, a field name —
|
|
53
|
+
e.g. `bundle-item-prices`, `_unitPrice`, `_leasePrice`).
|
|
54
|
+
3. Read the asset's `last-modified` response header for the actual build time.
|
|
55
|
+
|
|
56
|
+
A hit means the front end is deployed and current, and the fault is **elsewhere** (API, ACL, DB) —
|
|
57
|
+
this settled TRUE-82138 in one step on 2026-09-29 (bundle carried all three strings,
|
|
58
|
+
`last-modified: Tue, 29 Sep 2026 21:58:19 GMT`).
|
|
59
|
+
|
|
45
60
|
### Reference — how the build resolves mode (load-bearing)
|
|
46
61
|
|
|
47
62
|
- Branch name convention is **`_<mode>`** — a single leading underscore followed by the mode.
|
|
@@ -86,6 +101,15 @@ Worse, **`.env.development` is git-TRACKED**, so developers keep flipping it to
|
|
|
86
101
|
- **`amplify.yml` existed in exactly ONE commit (`5a276be`) and was absent from the root of EVERY remote branch — including `_production`.** So Amplify had been building from a **console-stored** build spec, which means the fail-loud `_<mode>` guardrail was **not actually running**. It was recreated on `_sandbox-client`. The recreated spec runs **`npx tsc -b` before `vite build`**, which the previous version did not: **a type error now fails the deploy.**
|
|
87
102
|
- **TWO competing `.env` conventions coexist across branches.** The `_<mode>` convention needs `sandbox-client`, `sandbox-dev`, `client-*`, `qa-*`, `qc-*`, `production`, `demo`, `stage`. But `_sandbox-client`, `_production`, `_beta` and `_main` carry an **OLD** set (`.env.alpha` / `.beta` / `.gamma` / `.sprint` / `.test`); only `_stage`, `_sandbox-dev` and the snapshot `_sandbox-client-backup820` carry the new 21-mode set. Dry-running the derivation across all **27** remote `_*` branches, only **`_beta` and `_stage`** would have built. A "reset from prod" is how a branch loses the new set.
|
|
88
103
|
- **⚠ Open data issue, needs a human:** on `_sandbox-client`, `.env.stage` points at `https://api.dev.sandbox.togahub.com/v2` (**dev-sandbox**) while the backup has `https://api.stage.togahub.com/v2`. As-is, `_stage` would deploy pointing at dev-sandbox. Also 4 orphan old-convention files remain with no matching `_<mode>` branch (`.env.alpha`/`.gamma`/`.sprint`/`.test`; `.env.beta` still has one).
|
|
104
|
+
- **⚠ The `amplify.yml` deletion RECURS — and a feature branch can carry it into a release branch.**
|
|
105
|
+
Verified 2026-09-29: commit **`37f7aeb` "Remove yml file"** deleted `amplify.yml` from
|
|
106
|
+
**`_sandbox-client`**, riding in with the TRUE-82138 feature-branch merge. So `_sandbox-client` is
|
|
107
|
+
again building from the **console spec**, losing the `npx tsc -b` gate, the Cypress test phase, and
|
|
108
|
+
the fail-loud `_<mode>` guardrail — the exact state the 2026-08-31 entry above fixed. Current
|
|
109
|
+
presence (2026-10-01): `amplify.yml` exists **only** on `_sandbox-dev`; absent on `_production`,
|
|
110
|
+
`_sandbox-client`, `_stage`, `_main`. **Check `git log --diff-filter=D -- amplify.yml` on any branch
|
|
111
|
+
you merge into a release branch**, and re-check the file exists after every merge, not just after a
|
|
112
|
+
reset-from-prod.
|
|
89
113
|
- **Deleting a remote branch silently breaks its Amplify deploy trigger.** An Amplify branch is **its own resource with its own webhook association** — **not** inferred from the repo. Deleting the git branch either deletes the Amplify branch resource (when automatic branch deletion is enabled) or leaves it with a stale association. Either way **re-creating the git branch does NOT reconnect it**: pushes land in GitHub and Amplify ignores them.
|
|
90
114
|
- **A missing `amplify.yml` is NOT the cause of a missing trigger** — a build would still start and fall back to the console spec. Don't conflate the two.
|
|
91
115
|
- **Diagnose:** GitHub → *Settings → Webhooks* → the Amplify webhook → **Recent Deliveries**. A delivery with `200` and no Amplify job = the branch is not connected; **no delivery at all** = the webhook is gone. Or `aws amplify list-apps` then `aws amplify list-branches --app-id <id>`.
|
|
@@ -97,3 +121,9 @@ Worse, **`.env.development` is git-TRACKED**, so developers keep flipping it to
|
|
|
97
121
|
- **⚠ The missing-blox-declaration defect RECURS per branch — it is not fixed once.** Verified 2026-08-31: `_sandbox-client` **also** had no `@agilant/toga-blox` dependency entry and no lock entry despite ~200 imports, exactly as `_production` had (fixed there in `92644c8`). The symlink hides it, so **re-run the audit on every branch you deploy**. Fixed by installing the **exact** pin `1.1.1-sandbox-client.140` (never a caret — some published versions ship an empty `dist`). That install surfaced one more masked peer: **`react-day-picker`** (blox peer `^9.5.1`) was neither declared nor present in the app's own `node_modules`; added at `^9.14.0`. All 19 blox peers are now declared. Installing from the registry also replaced the symlink with a real directory, which independently removed the duplicate-`@tanstack/react-query` root cause (see [local-link-into-a-consumer-app](../../toga-blox/workflows/local-link-into-a-consumer-app.md)). Verified after install: `tsc -b` clean, 44/44 tests, `vite build --mode sandbox-client` clean (1773 modules), exactly one react-query copy.
|
|
98
122
|
- **⚠ Latent and NOT fixed — `framer-motion` peer mismatch.** The app declares `^12.35.0`; blox's peer range is `^11.0.8`; `legacy-peer-deps=true` silenced the warning, so blox installed a **nested framer-motion v11**. framer-motion provides React context (`AnimatePresence`, `MotionConfig`) — i.e. the **same duplicate-instance bug class**, just not throwing today. Fix direction: widen blox's peer to `^11 || ^12`, **or** add `framer-motion` to the app's dedupe/alias list (which forces blox onto v12 — needs testing).
|
|
99
123
|
- **The audit that catches the blox defects — reusable for any blox consumer.** Enumerate the shared lib's `peerDependencies` from `node_modules/<pkg>/package.json`, then check each one for a **real directory** (not a symlink passthrough) in the *app's* `node_modules`. Do this whenever a repo has ever been developed against a linked local checkout.
|
|
124
|
+
|
|
125
|
+
## Change history
|
|
126
|
+
- 2026-10-01 — Added the "prove the change reached the environment" bundle-fetch check (curl the
|
|
127
|
+
`/assets/index-<hash>.js`, grep for a unique string, read `last-modified`), and recorded the
|
|
128
|
+
`amplify.yml` deletion recurrence on `_sandbox-client` (commit `37f7aeb`, 2026-09-29) carried in by a
|
|
129
|
+
feature-branch merge — the file now exists only on `_sandbox-dev`. (apeterson)
|
package/knowledge/INDEX.md
CHANGED
|
@@ -15,7 +15,7 @@ _Auto-generated by `knowledge.js index`. Do not hand-edit._
|
|
|
15
15
|
- **test** (Test) — 16 doc(s) → [1.0/apps/test/INDEX.md](1.0/apps/test/INDEX.md)
|
|
16
16
|
- **toga** (TOGa) — 3 doc(s) → [1.0/apps/toga/INDEX.md](1.0/apps/toga/INDEX.md)
|
|
17
17
|
- **store** (Store) — 3 doc(s) → [1.0/apps/store/INDEX.md](1.0/apps/store/INDEX.md)
|
|
18
|
-
- **tools** (Tools) —
|
|
18
|
+
- **tools** (Tools) — 24 doc(s) → [1.0/apps/tools/INDEX.md](1.0/apps/tools/INDEX.md)
|
|
19
19
|
|
|
20
20
|
## 2.0 framework
|
|
21
21
|
|
|
@@ -62,7 +62,7 @@ _Auto-generated by `knowledge.js index`. Do not hand-edit._
|
|
|
62
62
|
- **ManageLife** (`managelife`) → [clients/managelife/INDEX.md](clients/managelife/INDEX.md)
|
|
63
63
|
- **Northwell Health** (`northwell`) → [clients/northwell/INDEX.md](clients/northwell/INDEX.md)
|
|
64
64
|
- **New York City Department of Education** (`nycdoe`) → [clients/nycdoe/INDEX.md](clients/nycdoe/INDEX.md)
|
|
65
|
-
- **NYC Health
|
|
65
|
+
- **NYC Health + Hospitals** (`nychh`) → [clients/nychh/INDEX.md](clients/nychh/INDEX.md)
|
|
66
66
|
- **Office Depot** (`office-depot`) → [clients/office-depot/INDEX.md](clients/office-depot/INDEX.md)
|
|
67
67
|
- **PC Matic B2B Client Profile** (`pcmaticb2b`) → [clients/pcmaticb2b/INDEX.md](clients/pcmaticb2b/INDEX.md)
|
|
68
68
|
- **Prudential Financial** (`prudential`) → [clients/prudential/INDEX.md](clients/prudential/INDEX.md)
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# Client: NYC Health
|
|
1
|
+
# Client: NYC Health + Hospitals `nychh`
|
|
2
2
|
|
|
3
3
|
| Doc | Framework | Summary |
|
|
4
4
|
|-----|-----------|---------|
|
|
@@ -10,4 +10,4 @@
|
|
|
10
10
|
| [NYCHH transfer-order 2.0 model — inventory quantities + V2 field enablement](features/transfer-order-inventory-quantities.md) | 2.0 | NYCHH 2.0 transfer-order model — two-branch inventory quantities + the model/ACL work that lets a TransferOrder POST; open when NYCHH `_qty*` reads 0 or a Trans |
|
|
11
11
|
| [NYCHH TransferOrder → NetSuite push (location roles, interceptor row 312, metadata state)](features/transfer-order-netsuite-push.md) | 2.0 | NYCHH is the **first and only** client on the outbound transfer-order push. |
|
|
12
12
|
| [NYCHH transfer-order notification emails (Submitted / Packed / Shipped)](features/transfer-order-notification-emails.md) | 2.0 | NYC Health + Hospitals (H+H) gets three notification emails for a transfer order raised in **TOGa Supply**: **Submitted**, **Packed** and **Shipped**. |
|
|
13
|
-
| [NYC Health
|
|
13
|
+
| [NYC Health + Hospitals](profile.md) | 2.0 | NYC Health & Hospitals (NYCHH) client profile — open for supply-chain / TransferOrder / NetSuite-sync work on prod schema `Client_Nychh`. |
|
|
@@ -6,13 +6,14 @@ project: Worker
|
|
|
6
6
|
client: nychh
|
|
7
7
|
type: client-feature
|
|
8
8
|
status: active
|
|
9
|
-
updated: 2026-
|
|
9
|
+
updated: 2026-10-01
|
|
10
10
|
owners: ["bala", "rgirish", "jcardinal"]
|
|
11
11
|
files:
|
|
12
12
|
- worker2/Worker/Client/Nychh/TransferOrderEmails.php
|
|
13
13
|
- _underscore/Model/Nychh/TransferOrder.php
|
|
14
14
|
- _underscore/Model/Nychh/ItemFulfillment.php
|
|
15
15
|
- dbchanges2/Client_Nychh/2026-09-15a - TransferOrderEmails.sql
|
|
16
|
+
- dbchanges2/Client_Nychh/2026-09-30a - TransferOrderPackedAssetTagException.sql
|
|
16
17
|
- dbchanges2/Client_Nychh/2026-09-15b - TransferOrderEmailGoLiveRecipient.sql
|
|
17
18
|
- dbchanges2/Core/2026-09-16a - NychhTransferOrderEmailsCron.sql
|
|
18
19
|
related:
|
|
@@ -47,6 +48,8 @@ Three rules carry most of the risk:
|
|
|
47
48
|
2. **Only orders raised in TOGa Supply may be announced.** `TransferOrders.createdByUserId` is the
|
|
48
49
|
discriminator; without it H+H would be mailed about every order the NetSuite sync ever imported.
|
|
49
50
|
3. **A failed send throws — it is never swallowed**, so nothing is ever marked sent that did not go.
|
|
51
|
+
4. **Packed (and shipped) are HELD while a line is short of asset tags** — ops is warned instead, and
|
|
52
|
+
the customer email sends itself once the tags land. See below.
|
|
50
53
|
|
|
51
54
|
Recipient is `DevTeam@togatech.com` while testing. `2026-09-15b` swaps it to
|
|
52
55
|
`EndUserDeviceLifecycle@nychhc.org` — run that file only after sign-off.
|
|
@@ -57,6 +60,8 @@ Recipient is `DevTeam@togatech.com` while testing. `2026-09-15b` swaps it to
|
|
|
57
60
|
- `SendSubmitted(string $transferOrderUuid)` — queued from the TransferOrder `postPut`.
|
|
58
61
|
- `SendFulfillment(string $itemFulfillmentUuid)` — queued from the ItemFulfillment hooks.
|
|
59
62
|
- `SendPending(bool $dryRun = true)` — the `*/5` backstop cron.
|
|
63
|
+
- `resolveStagesStillToAnnounce()` — which stages are still owed (see the late-shipped case).
|
|
64
|
+
- `readLinesMissingAssetTags()` / `sendPackedExceptionEmail()` — the asset-tag hold.
|
|
60
65
|
- `initialize()` registers the client DB before any query runs.
|
|
61
66
|
- Builders: `buildItemRowsHtml()`, `buildTrackingSectionHtml()`, `buildAssetTagTableHtml()`,
|
|
62
67
|
`escapeForEmailHtml()`; budget constant `MAXIMUM_EMAIL_BODY_BYTES`.
|
|
@@ -70,6 +75,8 @@ Recipient is `DevTeam@togatech.com` while testing. `2026-09-15b` swaps it to
|
|
|
70
75
|
- submitted `0f4a6c21-9d3e-4b87-a15c-6e2b8f70d914`
|
|
71
76
|
- packed `7b1d9e54-3c62-48af-b0d7-25f4a8c31e6b`
|
|
72
77
|
- shipped `c58f3a07-6b94-4d21-8e3f-914ad7b06c52`
|
|
78
|
+
- packed **exception** (internal warning, seeded by `2026-09-30a`)
|
|
79
|
+
`9d3b1e6a-47c2-4f85-b019-2ae85c7d4630`
|
|
73
80
|
- **`nychh_email_templates/*.html`** — ⚠ the HTML source of the three templates. **These files are
|
|
74
81
|
in no git repo** — they live only in the working folder. The dbchanges2 seed SQL is generated
|
|
75
82
|
*from* them, so the `EmailTemplates` rows in `2026-09-15a` are the only durable copy.
|
|
@@ -114,6 +121,14 @@ they must stay in step: the Submitted guard, the order read, and both `SendPendi
|
|
|
114
121
|
2. **Packed / Shipped** — the ItemFulfillment `postPost` / `postPut` queue `SendFulfillment` with
|
|
115
122
|
the fulfillment uuid. The stage reached is recorded on `c_transferOrderEmailStageSent`, with
|
|
116
123
|
`c_dtTransferOrderEmailSent` as the timestamp, so a fulfillment cannot re-announce a stage.
|
|
124
|
+
`resolveStagesStillToAnnounce()` decides what is owed: normally the one stage reached, but when a
|
|
125
|
+
fulfillment **arrives already shipped** and packed was never announced it returns
|
|
126
|
+
**`[packed, shipped]`** and both send in that order in one run. Before this the packed stage was
|
|
127
|
+
skipped outright — and **the asset-tag check lives on packed**, so a late shipped fulfillment
|
|
128
|
+
bypassed it completely. Real case: transfer order **291773** got Submitted and Shipped, never
|
|
129
|
+
Packed. The NYCHH NetSuite sync is a **daily** cron, so a fulfillment landing fully shipped is
|
|
130
|
+
normal, not an edge case — see
|
|
131
|
+
[the per-client sync](../../../1.0/apps/worker/features/netsuite-togasupply-per-client-sync.md).
|
|
117
132
|
3. The **backstop cron** sweeps anything the hooks missed (below).
|
|
118
133
|
4. All three paths share the same markers, so nothing sends twice.
|
|
119
134
|
5. The worker renders the stored `EmailTemplates` body, fills `{orderItems}` and
|
|
@@ -137,6 +152,42 @@ PUT follows.** Fulfillment **2704** in production: header row at 20:33:09, lines
|
|
|
137
152
|
- It also picks up any send that threw (the marker was never written).
|
|
138
153
|
- Caps at **25 of each kind per run** (`MAXIMUM_EMAILS_PER_RUN`).
|
|
139
154
|
|
|
155
|
+
## ⚠ Missing asset tags HOLD the packed email (and warn ops instead)
|
|
156
|
+
|
|
157
|
+
Added 2026-09-30 (TRUE-82111). `SendFulfillment` runs `readLinesMissingAssetTags()` whenever
|
|
158
|
+
**packed** is one of the stages still owed. A fulfillment line counts as **short** when either:
|
|
159
|
+
|
|
160
|
+
- it has **fewer linked units than its quantity** (`unitsLinked < ItemFulfillmentItems.quantity`), or
|
|
161
|
+
- a linked unit has a **blank `Units.assetTag`**.
|
|
162
|
+
|
|
163
|
+
When any line is short, `SendFulfillment` **returns before writing
|
|
164
|
+
`c_transferOrderEmailStageSent`** — so packed stays pending and the backstop cron re-sends it by
|
|
165
|
+
itself the moment the tags arrive. **The hold covers shipped too**, because the early return happens
|
|
166
|
+
before the stage loop.
|
|
167
|
+
|
|
168
|
+
Instead, an **internal** warning goes out on template `9d3b1e6a-47c2-4f85-b019-2ae85c7d4630`
|
|
169
|
+
(`2026-09-30a`): **to `operations@togatech.com`, CC `HealthcareteamNYCHHC@togatech.com`** —
|
|
170
|
+
**never the client**. It lists one row per short line. Subject carries both numbers so it is
|
|
171
|
+
searchable:
|
|
172
|
+
|
|
173
|
+
```
|
|
174
|
+
Action needed: asset tags missing on transfer order {transferOrderNumber}, fulfillment {itemFulfillmentNumber}
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
- **It warns once per fulfillment, not every five minutes.** New custom field
|
|
178
|
+
**`c_dtTransferOrderPackedExceptionSent`** on `ItemFulfillments` (record **28**) is the marker;
|
|
179
|
+
`sendPackedExceptionEmail()` returns early when it is already set, and stamps it only after a
|
|
180
|
+
successful send.
|
|
181
|
+
- **⚠ Warned rows must still be SELECTED by the cron, just sorted last.**
|
|
182
|
+
`sendPendingFulfillmentEmails()` takes `LIMIT 25` per run, and a held fulfillment keeps
|
|
183
|
+
`c_transferOrderEmailStageSent` NULL forever, so 25 stuck rows would sit at the front of every run
|
|
184
|
+
and starve everyone else. The order is now
|
|
185
|
+
`ORDER BY (c_dtTransferOrderPackedExceptionSent IS NOT NULL) ASC, id ASC`. **Do not filter them
|
|
186
|
+
out** — that strands them permanently; they must still be picked up the run after the tags land.
|
|
187
|
+
|
|
188
|
+
See [the NYCHH asset-tag backfill](../../../2.0/apps/worker2/features/nychh-asset-tag-backfill.md)
|
|
189
|
+
for where the tags come from.
|
|
190
|
+
|
|
140
191
|
## Schema, registration and deploy order
|
|
141
192
|
|
|
142
193
|
`dbchanges2/Client_Nychh/2026-09-15a - TransferOrderEmails.sql` carries the whole feature:
|
|
@@ -145,6 +196,8 @@ PUT follows.** Fulfillment **2704** in production: header row at 20:33:09, lines
|
|
|
145
196
|
|---|---|
|
|
146
197
|
| `c_dtTransferOrderSubmittedEmailSent` | `TransferOrders` (record **312**) |
|
|
147
198
|
| `c_transferOrderEmailStageSent`, `c_dtTransferOrderEmailSent` | `ItemFulfillments` (record **28**) |
|
|
199
|
+
| `c_dtTransferOrderPackedExceptionSent` (`2026-09-30a`) | `ItemFulfillments` (record **28**) |
|
|
200
|
+
| The packed-exception `EmailTemplates` row + its TO/CC rows (`2026-09-30a`) | `Client_Nychh` |
|
|
148
201
|
| The three `EmailTemplates` rows + recipient rows | `Client_Nychh` |
|
|
149
202
|
| `CustomRecordFields` rows + `AclCustomFieldPermissions` grants (Base and API) | `Client_Nychh` |
|
|
150
203
|
| The record-312 **PUT** interceptor row | `ApiPayloadInterceptors` |
|
|
@@ -255,9 +308,22 @@ Cost of the split, stated so nobody is surprised later:
|
|
|
255
308
|
`Logs_Nychh.Email` without an inbox — that is how these were verified. See
|
|
256
309
|
[the email send pipeline](../../../2.0/apps/_underscore/features/email-send-pipeline.md).
|
|
257
310
|
- **The `nychh_email_templates/*.html` sources are not version-controlled** (see *Key files*).
|
|
311
|
+
- **⚠ An email that leaves the dev team never describes internal process.** The packed-exception
|
|
312
|
+
body first explained NetSuite, TOGa Supply, the sync and the five-minute retry; all of it was
|
|
313
|
+
removed on instruction. An email says **what happened, what to do, and that it is handled** —
|
|
314
|
+
nothing about which system, which cron, or how often we retry. Process detail belongs in code
|
|
315
|
+
comments and the dbchanges2 header. Applies to every template here, not just the exception one.
|
|
316
|
+
- **⚠ The ops / healthcare-team addresses have never actually been exercised.** On dev sandbox the
|
|
317
|
+
debug redirect rewrites every recipient, so the `operations@togatech.com` TO and the
|
|
318
|
+
`HealthcareteamNYCHHC@togatech.com` CC on the exception template are unproven until prod. Check the
|
|
319
|
+
first live exception email lands on both.
|
|
258
320
|
|
|
259
321
|
## Testing
|
|
260
322
|
|
|
323
|
+
**106 tests** as of 2026-09-30. Each fix is proven by **reverting it** and confirming the matching
|
|
324
|
+
tests fail. The five scenarios were then run end to end on **dev sandbox** against fulfillment
|
|
325
|
+
**898** / transfer order **286536**.
|
|
326
|
+
|
|
261
327
|
A local end-to-end harness (scratchpad, **not committed**) runs the **real** worker class against a
|
|
262
328
|
throwaway local schema built with `CREATE TABLE LIKE` from `Client_Nychh`, stubbing **only**
|
|
263
329
|
`_Query` / `_Database` / `_Model_Client_EmailTemplate`, and rendering the **real** template bodies
|
|
@@ -266,6 +332,19 @@ loaded out of the dbchanges2 file so an unresolved `{placeholder}` fails a test.
|
|
|
266
332
|
unescaped values and both detail-box defects were all found there, not in production.
|
|
267
333
|
|
|
268
334
|
## Change history
|
|
335
|
+
- 2026-10-01 — **Packed/shipped are now HELD when a fulfillment line is short of asset tags**
|
|
336
|
+
(TRUE-82111). A line is short when it has fewer linked units than its quantity **or** a linked unit
|
|
337
|
+
has a blank `assetTag`; `SendFulfillment` then returns without writing
|
|
338
|
+
`c_transferOrderEmailStageSent`, so the customer email sends itself once the tags land, and an
|
|
339
|
+
internal warning goes to `operations@togatech.com` (CC `HealthcareteamNYCHHC@togatech.com`) once
|
|
340
|
+
per fulfillment via new marker `c_dtTransferOrderPackedExceptionSent` and template
|
|
341
|
+
`9d3b1e6a-…` (`2026-09-30a`). Two fixes came with it: `resolveStagesStillToAnnounce()` now sends
|
|
342
|
+
**packed then shipped in one run** when a fulfillment arrives already shipped (transfer order
|
|
343
|
+
**291773** had Submitted + Shipped and no Packed — which also meant it skipped the asset-tag
|
|
344
|
+
check), and `sendPendingFulfillmentEmails()` sorts warned rows **last**
|
|
345
|
+
(`ORDER BY (c_dtTransferOrderPackedExceptionSent IS NOT NULL) ASC, id ASC`) so 25 held rows cannot
|
|
346
|
+
fill the `LIMIT 25` and starve every other fulfillment. Also recorded the rule that outbound emails
|
|
347
|
+
never describe internal process. (bala)
|
|
269
348
|
- 2026-09-24 — `TransferOrderEmails` reads PO numbers from upstream bridge 325 (column `purchaseOrderId` dropped). (jcardinal)
|
|
270
349
|
- 2026-09-23 — Submitted template put on review hold; missing REVIEW row → job failure note. (jcardinal)
|
|
271
350
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
---
|
|
2
|
-
title: "NYC Health
|
|
2
|
+
title: "NYC Health + Hospitals"
|
|
3
3
|
framework: "2.0"
|
|
4
4
|
apps:
|
|
5
5
|
- _underscore
|
|
@@ -18,7 +18,7 @@ project: _Underscore
|
|
|
18
18
|
client: nychh
|
|
19
19
|
type: profile
|
|
20
20
|
status: active
|
|
21
|
-
updated: 2026-
|
|
21
|
+
updated: 2026-10-01
|
|
22
22
|
owners: ["jcardinal", "apeterson", "bala", "akhokhani"]
|
|
23
23
|
files:
|
|
24
24
|
- dbchanges2/Client_Nychh/2026-09-02a - TransferOrderNetsuitePushInterceptor.sql
|
package/package.json
CHANGED