toga-ai 1.0.528 → 1.0.530

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.
@@ -6,8 +6,8 @@ project: Library
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-08-04
10
- owners: [jcardinal, mhammontree]
9
+ updated: 2026-08-05
10
+ owners: [jcardinal, mhammontree, bala]
11
11
  files:
12
12
  - library/app/api/toga2.php
13
13
  - worker/crons/toga2/aig/sync_togasupply_aig.php
@@ -275,6 +275,14 @@ enable flags** and an optional `$monitorTogadeskDepartmentIds[]`:
275
275
 
276
276
  ## Gotchas / known issues
277
277
 
278
+ - **A 2.0 GET silently TRUNCATES a nested child collection — never trust a single response for a
279
+ count.** When you read a parent with a nested `hasMany` collection in one GET, api2 can return the
280
+ collection **partial** (observed: a fulfillment's nested `itemFulfillmentItemUnits` came back **45
281
+ of 70**) with no error and no obvious signal — so any verification that counts off the nested array
282
+ is silently wrong. **Read the collection flat and fully paginated** (query the child resource
283
+ directly with the universal `meta->nextPage` loop) and take counts from **`meta.totalRecordCount`**,
284
+ not from a single nested response. Surfaced building the NYCHH fulfillment asset-tag verifier
285
+ (`worker/crons/toga2/netsuite/verify_fulfillment_asset_tag_sync_nychh.php`).
278
286
  - **🔐 Hardcoded credentials.** Every client's `CLIENT_UUID_*`, `API_UUID_*`, and `API_SECRET_*`
279
287
  (plus the "True Workers" master keys) are committed as plaintext class constants at the top of
280
288
  `toga2.php`. Treat them as **compromised-if-leaked**; do not reproduce the values in the KB,
@@ -353,6 +361,10 @@ enable flags** and an optional `$monitorTogadeskDepartmentIds[]`:
353
361
 
354
362
  ## Change history
355
363
 
364
+ - 2026-08-05 — Recorded that a 2.0 GET **silently truncates a nested child collection** (observed
365
+ `itemFulfillmentItemUnits` 45 of 70) with no error — so verification/counts must read the child
366
+ resource **flat + fully paginated** and use `meta.totalRecordCount`, never a single nested
367
+ response. Surfaced building the NYCHH fulfillment asset-tag verifier. (bala)
356
368
  - 2026-08-04 (**uncommitted/undeployed** at time of writing) — `authenticate()` now records the
357
369
  ambient client uuid for error reporting (`App_Error_Capture::setCurrentClientUuid()`, store-only)
358
370
  because it is the one place every 1.0→2.0 cron passes through holding its `UUID_CLIENT` — this is
@@ -108,9 +108,15 @@ divergent shapes (read `emailAddress` with `email` fallback).
108
108
 
109
109
  **SAML** (rumcsi, towfoundation, newcenturyholdingsllc) is handled in `framework.php`
110
110
  (`meta`/`acs`/`sls`); newcenturyholdingsllc and towfoundation **auto-create `people` rows** for
111
- unknown SAML users. rumcsi additionally accepts a legacy `?hash=<base64 email>` auto-login — **an unauthenticated
112
- account-takeover path that appears to be the client's actual production login**; do not remove it
113
- before completing the `acs` handler (see [login flows](features/login-flows.md)).
111
+ unknown SAML users. rumcsi additionally accepts a `?hash=<base64 email>` auto-login — **an unauthenticated
112
+ account-takeover path that is also, per the routing evidence, RUMC's live production SSO login**:
113
+ the block holds the only rumcsi route to `/enterprise_dashboard` (where a real production SSO
114
+ login lands), and togaview has no `?saml=` handling, so the 2.0 SAML gateway is not involved.
115
+ Only rumcsi's `acs` handler fails to populate the session (newcenturyholdingsllc `:819` and
116
+ towfoundation `:1180` do), which is why only rumcsi has a magic link. **Deleting it breaks RUMC
117
+ login into an IdP redirect loop** — sign the token or complete the `acs` handler instead, in
118
+ coordination with the external link generator. A cold-login capture to confirm the inbound
119
+ `?hash=` hop is still outstanding (see [login flows](features/login-flows.md)).
114
120
 
115
121
  ## Dashboards
116
122
  Five dashboards, one per audience/business model, each delegating to a client override or the
@@ -72,26 +72,65 @@ lookup silently found nothing. Removed, and `email LIKE '…'` changed to `=` in
72
72
 
73
73
  ## Gotchas / known issues
74
74
 
75
- ### OPEN SECURITY ITEM — rumcsi `?hash=` unauthenticated account takeover (deliberately left in place)
75
+ ### OPEN SECURITY ITEM — rumcsi `?hash=` is the LIVE production SSO login (do not delete it)
76
76
  `_/app/framework.php` authenticates a rumcsi user on nothing but
77
77
  `base64_decode(urldecode($_GET['hash']))` matching an active `people` row for the rumcsi
78
78
  clientid — **no password, signature, nonce, or expiry** — then populates ~18 session keys and
79
79
  redirects to `/enterprise_dashboard`. Anyone who can guess a RUMC user's email can log in as
80
80
  them.
81
81
 
82
- It was removed during TRUE-80671 and then **deliberately reverted**, because it may BE the
83
- production login mechanism: the rumcsi SAML `acs` handler does `App_MVC::routeTo('/')` on success
84
- and **never populates `$_SESSION`** (unchanged since commit 9e7d306, "phase one … make sure
85
- redirect works"); repo-wide only two places ever build a rumcsi session — this hash block and
86
- `mvc/login/post.php:462`, and the latter is unreachable because the SAML gate redirects to the
87
- IdP before a password POST can be processed; and RUMC login is confirmed **working in
88
- production**. Nothing in togaview/togadesk/library generates such a link, so links are presumably
89
- generated outside our codebase.
90
-
91
- - **Next step:** obtain the post-SSO redirect URL from Jonathan to confirm whether it carries
92
- `hash=`.
93
- - **Proper fix:** complete the rumcsi `acs` handler (mirror newcenturyholdingsllc:
94
- `$auth->getNameId()` → `people` lookup → session build) — **not** restoring the bypass.
82
+ It was removed during TRUE-80671 and then **deliberately reverted**. Routing evidence gathered
83
+ afterwards shows this is not a dormant legacy backdoor — it is **how RUMC actually logs in**:
84
+
85
+ - Jonathan performed a real production SSO login and was returned to
86
+ `https://rumcsi.togaview.agilantsolutions.com/enterprise_dashboard`.
87
+ - Inside the rumcsi block, **`framework.php:278` is the ONLY path to `/enterprise_dashboard`** —
88
+ and it sits **inside the hash block**, behind `if ($rumcsiUser)`. The SAML `acs` handler instead
89
+ does `App_MVC::routeTo('/')`. The only other RUMC route to that page is
90
+ `mvc/login/post.php:511`, which is the *password* login. **Landing on `/enterprise_dashboard`
91
+ after an SSO login is the hash block's signature.**
92
+ - The absence of `?hash=` in the final URL is **expected, not exculpatory**: the block consumes
93
+ the parameter and issues a fresh redirect, so the query string is gone from the address bar.
94
+ - **The modern alternative is ruled out.** The 2.0 SAML gateway (repo `saml`,
95
+ `Controller/Index.php:456-460`) hands off with `?saml=<base64 JSON whose client and user UUIDs
96
+ are encrypted via `_String::encryptWithKey`>` — the correct mechanism, which `tools` uses.
97
+ togaview has **zero** handling for `?saml=` (no `$_GET['saml']` anywhere). RUMC does not come
98
+ through that gateway, and there is no hidden session-population path.
99
+ - **Had the deletion shipped, RUMC SSO would have broken outright:** authenticate at the IdP →
100
+ `acs` → `/` → no `contactId` → bounced back to the IdP. A redirect loop, not a login. The
101
+ revert was correct.
102
+
103
+ **Still open — who generates the link.** Nothing in togaview/togadesk/library builds a `hash=`
104
+ URL, so the generator is external (likely RUMC's IdP-initiated flow or their own portal).
105
+ **Confirming test:** a **cold** SSO login in a private window with DevTools → Network →
106
+ *Preserve log*, capturing the full redirect chain and checking whether the hop **into** togaview
107
+ carries `?hash=`. Jonathan's test may not have exercised a cold login at all — with a live session
108
+ cookie the `!array_key_exists('contactId', $_SESSION)` gate is skipped entirely and
109
+ `/enterprise_dashboard` is simply where he already was.
110
+
111
+ **Fix shape — a coordinated change, never a deletion.** Two options, both requiring coordination
112
+ with whoever generates the link:
113
+ - **(a) Keep the handoff but sign it.** Replace the unsigned base64 email with an HMAC-signed
114
+ token via `App_String::encrypt`/`decrypt` (`library/app/string.php`). Smallest change; kills
115
+ guessability.
116
+ - **(b) Complete the rumcsi `acs` handler** so it builds the session itself, then retire the
117
+ handoff.
118
+
119
+ ⚠ **Do NOT delete the block first, under either option.**
120
+
121
+ ### Why rumcsi is the only one of the three SAML clients with a magic-link login
122
+ The real lesson is the contrast between the three `acs` handlers in `_/app/framework.php`:
123
+
124
+ | Client | `acs` handler | Sets `contactId` / session? |
125
+ |---|---|---|
126
+ | newcenturyholdingsllc | `:819` | **Yes** — completes the login |
127
+ | towfoundation | `:1180` | **Yes** — completes the login |
128
+ | rumcsi | `:399-417` | **No** — only `routeTo('/')` (since 9e7d306, "phase one … make sure redirect works") |
129
+
130
+ rumcsi's handler was never finished, and the `?hash=` block is what filled the gap. When adding a
131
+ SAML client, the `acs` handler **must** populate the session — an unfinished one invites exactly
132
+ this kind of unsigned side door.
133
+
95
134
  - Note the surviving SAML branch is gated on `!inTestMode()`, so **test mode leaves rumcsi with
96
135
  no login path at all**.
97
136
 
@@ -106,6 +145,14 @@ generated outside our codebase.
106
145
  - The flows are order-dependent: a contact matching an earlier flow never reaches later ones.
107
146
 
108
147
  ## Change history
148
+ - 2026-08-05 (later, TRUE-80671) — **Upgraded the rumcsi `?hash=` finding from "may be" to
149
+ evidenced:** `framework.php:278` inside the hash block is the only rumcsi route to
150
+ `/enterprise_dashboard`, where a real production SSO login landed; togaview has no `?saml=`
151
+ handling at all, so the 2.0 SAML gateway is ruled out; deleting the block would have produced an
152
+ IdP redirect loop, not a login. Recorded the three-way `acs` handler contrast (newcentury :819 /
153
+ towfoundation :1180 populate the session, rumcsi :399-417 never did), both fix shapes (sign the
154
+ token with `App_String::encrypt`, or finish `acs`), and the outstanding cold-login DevTools test.
155
+ (mhammontree)
109
156
  - 2026-08-05 (TRUE-80671, uncommitted) — Fixed the unguarded `$user_name[1]` session-name build
110
157
  that **locked mononym / shared-mailbox users out entirely** (4 sites); removed
111
158
  `sqlProtect(sqlEscape())` and switched email lookups to `=`; recorded the rumcsi `?hash=`
@@ -6,7 +6,7 @@ project: Worker
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-07-09
9
+ updated: 2026-08-05
10
10
  owners: ["dfranks", "bala", "jcardinal"]
11
11
  files:
12
12
  - worker/crons/toga2/netsuite/common_sync_togasupply.php
@@ -91,6 +91,32 @@ places or the sync runs but imports nothing (or 403s). All five steps are requir
91
91
  The most commonly-missing one is `c_netsuiteInternalCustomerId` on the **Customers** record
92
92
  (`recordId 113`, `isIdentifier=1`).
93
93
 
94
+ ### Item-fulfillment section is a FULL reconcile (like `syncSalesOrderFromNetsuite`)
95
+
96
+ `App_Api_Toga2::syncItemFulfillmentFromNetsuite` (`library/app/api/toga2.php`) is now a **full
97
+ reconcile**, not an append: on every re-sync it makes TOGa **match NetSuite**. It updates item
98
+ fulfillment items and units, sets serial / `assetTag` / `macAddress` (**never blanking** a value
99
+ that is already populated), and maintains **all three tracking bridges** —
100
+ `ItemFulfillments_TrackingNumbers` (record 317, header), `ItemFulfillmentItems_TrackingNumbers`
101
+ (318, item), `ItemFulfillmentItemUnits_TrackingNumbers` (319, unit). It **prunes** units/items that
102
+ NetSuite has dropped, **deleting their tracking-bridge links FIRST** because those bridge FKs are
103
+ `RESTRICT` (no cascade) — delete the bridge rows before the parent unit/item or the delete errors.
104
+ See the [tracking-number bridge doc](../../../2.0/apps/_underscore/features/tracking-number-bridges.md).
105
+
106
+ - **Record 318 is fetched in its own isolated GET.** 318 is deliberately excluded from the main
107
+ fulfillment GET: requesting a field a role cannot read **403s the whole GET**, so 318 is
108
+ reconciled in a separate try/catch GET that can fail without taking the rest down.
109
+ - **Re-sync trigger is the ItemShip `lastmodifieddate`.** A tag added later only to the
110
+ serial/inventory-number record does **not** bump the fulfillment, so late tags are **not** caught
111
+ here — they are handled by the separate per-client asset-tag backfill/cron (e.g. NYCHH's, see
112
+ [nychh-asset-tag-backfill](../../../2.0/apps/worker2/features/nychh-asset-tag-backfill.md)).
113
+ - **Upstream-mirror interaction is a cheap no-op for non-Compass clients.** Any
114
+ `/item-fulfillments`, `/item-fulfillment-items`, `/item-fulfillment-item-units` POST/PUT fires
115
+ `_underscore`'s `_Model_Client_ItemFulfillment::ensureUpstreamItemFulfillment` (the Compass
116
+ multi-tier SO→PO→SO mirror), but it is a ~2-query no-op for NYCHH / Quad / Prudential (verified:
117
+ 0 of their fulfillments resolve an upstream sales order). `/units` writes and all three
118
+ tracking-bridge models have **no** interceptor, and **DELETE fires none**.
119
+
94
120
  ### Custom field contract (hardcoded in `common_sync_togasupply.php`)
95
121
 
96
122
  The engine requests these `c_` fields by literal name per route — every synced client must have
@@ -211,6 +237,15 @@ Parameters are stored **per client DB** but accessed **through the TOGa2 API**,
211
237
 
212
238
  ## Change history
213
239
 
240
+ - 2026-08-05 — Documented that `syncItemFulfillmentFromNetsuite` is now a **full reconcile** like
241
+ `syncSalesOrderFromNetsuite`: updates IF items/units + serial/`assetTag`/`macAddress`
242
+ (never blanking), maintains **all three** tracking bridges (317/318/319), and **prunes**
243
+ dropped units/items **deleting their bridge links first** (bridge FKs are `RESTRICT`). Record
244
+ 318 is reconciled in its own isolated try/catch GET (a forbidden field 403s the whole GET);
245
+ re-sync trigger is the ItemShip `lastmodifieddate` (serial-only tag edits are handled by the
246
+ separate asset-tag backfill). The upstream-mirror interceptor
247
+ (`ensureUpstreamItemFulfillment`) is a ~2-query no-op for NYCHH/Quad/Prudential (0 upstream SOs);
248
+ `/units` + the bridge models have no interceptor and DELETE fires none. (bala)
214
249
  - 2026-07-22 — Recorded the **dual-catalog duplicate OD PO-line bug** in
215
250
  `syncSalesOrderFromNetsuite` (uuid-only PO-line reconciliation → shadow-VendorItem duplicate
216
251
  lines) and its SKU-fallback fix. Compass cleanup + sequencing captured in the
@@ -6,8 +6,8 @@ project: _Underscore
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-07-21
10
- owners: ["dfranks", "jcardinal"]
9
+ updated: 2026-08-05
10
+ owners: ["dfranks", "jcardinal", "bala"]
11
11
  files:
12
12
  - _underscore/Component/Api/Netsuite/Netsuite.php
13
13
  related:
@@ -131,6 +131,20 @@ would corrupt values like `O'Brien`. Use `str_replace("'", "''", $value)` to esc
131
131
  string literal. (worker2's `SalesOrder.php` carries this as a private `escapeSuiteQl()` helper;
132
132
  any new SuiteQL caller must do the same.)
133
133
 
134
+ ## SuiteQL custom columns are SELECT-only — you cannot filter (WHERE) on them
135
+
136
+ You **can `SELECT`** a NetSuite custom column in SuiteQL, but you **cannot put it in a `WHERE`**.
137
+ On the `inventorynumber` table, `SELECT custitemnumber_asset_tag, custitemnumber_mac_address` returns
138
+ the values fine — but `WHERE custitemnumber_asset_tag IS NOT NULL` (or any predicate on a custom
139
+ column) returns **0 rows with NO error**. It fails silently, so it looks like "no matching data"
140
+ rather than a bad query. Confirmed live 2026-07-28.
141
+
142
+ **Drive the query off a native column instead**, then filter in PHP: query
143
+ `WHERE inventorynumber IN ('serial1','serial2',…)` (chunk over the serials/keys you already have —
144
+ 500 at a time works), `SELECT` the custom columns alongside, and drop blanks in PHP after the fetch.
145
+ Surfaced building the NYCHH asset-tag backfill (see
146
+ [nychh-asset-tag-backfill](../../worker2/features/nychh-asset-tag-backfill.md)).
147
+
134
148
  ## Logging
135
149
 
136
150
  `send()` calls `setLogging(false)`, so **NetSuite REST request/response bodies are NOT written to
@@ -155,6 +169,11 @@ doc.)
155
169
 
156
170
  ## Change history
157
171
 
172
+ - 2026-08-05 — Recorded the **SuiteQL custom-column filter trap**: a NetSuite custom column
173
+ (e.g. `inventorynumber.custitemnumber_asset_tag`/`_mac_address`) can be `SELECT`ed but **not**
174
+ filtered on — `WHERE custitemnumber_asset_tag IS NOT NULL` returns 0 rows with no error. Drive
175
+ the query off a native column (`WHERE inventorynumber IN (...)`, chunked) and filter blanks in
176
+ PHP. Surfaced building the NYCHH asset-tag backfill. (bala)
158
177
  - 2026-07-21 — **Documented the auth mechanism, config-key locations, `send()` signature, and the
159
178
  `RECORD_*`/query/metadata constant catalog** (folded in from a retired project-local
160
179
  `worker2/netsuite/CLAUDE.md`). Auth = OAuth 2.0 certificate-signed JWT client assertion
@@ -6,8 +6,8 @@ project: _Underscore
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-07-14
10
- owners: ["jcardinal", "mhammontree", "dfranks", "apeterson"]
9
+ updated: 2026-08-05
10
+ owners: ["jcardinal", "mhammontree", "dfranks", "apeterson", "bala"]
11
11
  files:
12
12
  - api2/Component/Api/V2/V2.php
13
13
  - _underscore/Model/Client/AdvanceShippingNoticeItemUnit.php
@@ -121,6 +121,13 @@ returnTrackingNumber: {...} }]`, and an IFIU's as `itemFulfillmentItemUnitTracki
121
121
 
122
122
  - **Tracking is now an inherent child, not a scalar.** Any producer that sent a flat `trackingNumber`
123
123
  on a unit must nest it under the unit's `*TrackingNumbers` child array, or it is silently dropped.
124
+ - **Pruning a unit/item means deleting its bridge rows FIRST — the bridge FKs are `RESTRICT`, not
125
+ cascade.** The IF bridge FKs (`ItemFulfillments/Items/ItemUnits_TrackingNumbers` → parent) are
126
+ `ON DELETE RESTRICT`, so deleting a parent unit/item that NetSuite dropped errors unless you delete
127
+ its 317/318/319 bridge links first. The 1.0 `App_Api_Toga2::syncItemFulfillmentFromNetsuite` full
128
+ reconcile does exactly this (delete bridges, then parent). Any code that prunes IF units/items must
129
+ do the same ordering. See the
130
+ [per-client sync doc](../../../1.0/apps/worker/features/netsuite-togasupply-per-client-sync.md).
124
131
  - **Migration FK pitfalls (all handled in the SQL):** deleting `RecordFields` hits `DefaultRecordFieldSettings`
125
132
  FKs (repoint first); deleting `AclRecordPermissions` hits the `AclLogicGroups`→`AclLogicGroupExpressions`
126
133
  chain (record-41 teardown wraps it in `SET FOREIGN_KEY_CHECKS=0`); deleting `TableViewFields` hits
@@ -173,6 +180,10 @@ returnTrackingNumber: {...} }]`, and an IFIU's as `itemFulfillmentItemUnitTracki
173
180
  - **Porting the item-fulfillment TableView fix to another client is two independent decisions, not a copy-paste.** The **re-root** (Units 31 → ItemFulfillmentItems 29 + OUTER unit chain) is universally portable and fixes the "0 records" bug everywhere. The **tracking source is client-specific**: Compass writes one tracking number per item fulfillment at the **item level (318)**, so its views read 318. Other clients populate different bridge levels — verify with `SELECT COUNT(*)` per bridge before choosing. As of 2026-06-19, **record 318 is empty in `Client_Nychh` and `Client_Quad`**; their tracking lives at the **IF/shipment level (317)** (covers IFIs: NYCHH 7955/10049 ≈ 79%, Quad 4878/4967 ≈ 98%) and the **unit level (319)** (≈ 7–9% — serialized only). Copying Compass's 318 join into a client that doesn't write 318 yields a structurally-correct view with permanently blank tracking until 318 is backfilled. (NYCHH's pre-fix view pointed at the now-deleted record 41 = old IF-level/package bridge, i.e. it originally intended IF-level 317.)
174
181
 
175
182
  ## Change history
183
+ - 2026-08-05 — Recorded that the 1.0 `App_Api_Toga2::syncItemFulfillmentFromNetsuite` full reconcile
184
+ now maintains **all three** IF bridges (317/318/319) and, when pruning units/items NetSuite dropped,
185
+ **deletes their bridge links first** because the bridge parent FKs are `RESTRICT` (no cascade) —
186
+ added the delete-order gotcha. (bala)
176
187
  - 2026-07-14 — **GroWrk** hit the bridge ACL gap (TRUE-79191): 403 EZ-1 on `item-fulfillment-tracking-numbers` made TOGa Supply's Pending Shipments list come back `isSuccess:false` (frontend showed the empty "Create your first shipment" state). GroWrk never got the fix that Prudential (2026-06-15) and Quad (2026-06-18a) already had. Added `Client_Growrk/2026-07-13a - ItemFulfillmentItemReceiptTrackingNumberAclLogicGroups.sql` (all 6 bridges 317-322, idempotent NOT-EXISTS guards, `slug='all'`/`sqlExpression='1'`, root logic group operator AND). Decision: kept Growrk-scoped for now (only client on Fulfill & Ship); shared `Client/` migration remains the long-term fix. (mhammontree)
177
188
  - 2026-07-09 — Noted that `Client_CompassCanada` is missing these bridge tables on **beta**, so a local
178
189
  reset from beta reintroduces the 17-table drift (1146 on SalesOrders load); fix locally with
@@ -27,6 +27,7 @@
27
27
  | [NetSuite Supporting-Record Webhook Importer (the reusable recipe)](features/netsuite-supporting-record-webhook-importer.md) | A single **repeatable recipe** for porting a legacy daily-pull NetSuite *supporting-record* importer (the lookup/dimension tables behind Forecast2 — Employees, | worker2/Worker/Netsuite/Employee.php, worker2/Worker/Netsuite/Account.php, worker2/Worker/Netsuite/Classification.php, worker2/Worker/Netsuite/Customer.php, worker2/Worker/Netsuite/Item.php, worker2/Worker/Netsuite.php, _underscore/Model/Forecast/Employee.php, _underscore/Model/Forecast/Account.php, _underscore/Model/Forecast/Classification.php, _underscore/Component/Forecast/Db/Db.php, test/@dave/test_employee_lifecycle.php, test/@dave/test_account_lifecycle.php, test/@dave/test_classification_lifecycle.php, test/@dave/NetSuite/api-message-queue/ue_api_msg_queue_enqueue.js, worker/crons/toga2/forecast2/import_supporting_records.php |
28
28
  | [Background Email-Template Worker (_Worker_Notification_EmailTemplate)](features/notification-email-template.md) | `_Worker_Notification_EmailTemplate::Send(...)` dispatches a **stored, client-defined `EmailTemplates` row off-thread** as a background WorkerJob. | worker2/Worker/Notification/EmailTemplate.php, worker2/Worker/Client/True.php, _underscore/Model/Client/EmailTemplate.php |
29
29
  | [DB-Driven Notification (Internal) Email](features/notification-email.md) | Internal/notification emails (merge-conflict alerts, ops notices — anything system-generated, not client-facing transactional mail) are sent through one worker | worker2/Worker/Notification/Email.php, _underscore/Model/Client/EmailTemplate.php, dbchanges2/Client/2026-06-23a - EmailTemplateWrapper.sql, dbchanges2/Client_True/2026-06-23a - EmailTemplateWrapper.sql |
30
+ | [NYCHH Asset-Tag Backfill (worker2)](features/nychh-asset-tag-backfill.md) | Keeps NYC Health & Hospitals (`Client_Nychh`) unit asset tags and MAC addresses synced from NetSuite. | worker2/Worker/Client/Nychh.php, worker2/Worker/Client/Nychh/AssetTagBackfill.php, worker/crons/toga2/netsuite/verify_fulfillment_asset_tag_sync_nychh.php, worker/crons/toga2/netsuite/backfill_all_asset_tags_from_netsuite_nychh.php |
30
31
  | [OneUptime push-metric monitors for 2.0 workers](features/oneuptime-worker2-monitoring.md) | A second, **OneUptime-reporting** monitoring pattern for the 2.0 worker2 tier, ported from the 1.0 `App_SystemMonitor_Compass` monitors. | worker2/Worker/Monitor/Compass.php, worker2/Worker/Client/Compass.php, worker2/composer.json, _underscore/Cloud.php, worker2/Worker/Monitor/Operations.php |
31
32
  | [Platform Cache Cleanup (_Worker_Platform_Cache — Clean + Truncate)](features/platform-cache-cleanup.md) | `_Worker_Platform_Cache` owns maintenance of the shared **Cache** cluster that backs api2's [multi-client data retrieval](../../api2/features/cross-client-data- | worker2/Worker/Platform/Cache.php, worker2/Controller/Index.php, worker2/_.php, dbchanges2/Core/2026-07-27a - PlatformCacheCleanCron.sql |
32
33
  | [Startech Webhook Handler (worker2)](features/startech-webhook-handler.md) | Receives inbound webhook events from Startech (Easeedesk) and creates or updates the corresponding ticket in TOGA 2.0. | worker2/Worker/Startech.php |
@@ -0,0 +1,99 @@
1
+ ---
2
+ title: NYCHH Asset-Tag Backfill (worker2)
3
+ framework: "2.0"
4
+ repo: worker2
5
+ project: Worker
6
+ client: nychh
7
+ type: client-feature
8
+ status: active
9
+ updated: 2026-08-05
10
+ owners: ["bala"]
11
+ files:
12
+ - worker2/Worker/Client/Nychh.php
13
+ - worker2/Worker/Client/Nychh/AssetTagBackfill.php
14
+ - worker/crons/toga2/netsuite/verify_fulfillment_asset_tag_sync_nychh.php
15
+ - worker/crons/toga2/netsuite/backfill_all_asset_tags_from_netsuite_nychh.php
16
+ related:
17
+ - ../../_underscore/features/netsuite-rest-client.md
18
+ - ../../../1.0/apps/library/features/toga2-api-client-and-bridge.md
19
+ - ../../../1.0/apps/worker/features/netsuite-togasupply-per-client-sync.md
20
+ - creating-worker-actions.md
21
+ - ../../../clients/nychh/profile.md
22
+ ---
23
+
24
+ ## Summary
25
+
26
+ Keeps NYC Health & Hospitals (`Client_Nychh`) unit asset tags and MAC addresses synced from
27
+ NetSuite. In NetSuite the asset tag / MAC live on the **inventory-number (serial) record**, not
28
+ the item-fulfillment transaction — so the regular per-client fulfillment sync does **not** pick up
29
+ a tag that is added to the serial after the ItemShip is created (the sync's trigger is the
30
+ fulfillment's `lastmodifieddate`, and a serial-only edit does not bump it). This backfill closes
31
+ that gap: it reads every NYCHH unit's serial, asks NetSuite for the current tag/MAC, and writes
32
+ them back.
33
+
34
+ This is the **first NYCHH presence in worker2**. The write path is deliberately **direct-DB**
35
+ (no api2), mirroring `_Worker_Client_Prudential_AssetImports`, to avoid the known beta
36
+ worker2→api2 hard-crash (PHP 8.x, 500 with no trace).
37
+
38
+ ## Key files / entry points
39
+
40
+ - **`worker2/Worker/Client/Nychh.php`** — the NYCHH client worker base. Its inherited
41
+ `initialize()` registers the `Client_Nychh` DB so any `_Model_Client_*` load resolves (see the
42
+ `DB_CLIENT` auto-registration gotcha in [creating-worker-actions](creating-worker-actions.md)).
43
+ - **`worker2/Worker/Client/Nychh/AssetTagBackfill.php`** — the action.
44
+ Method: `Backfill(bool $dryRun = true, ?string $serialNumber = null): string`.
45
+ - Action path: `Client/Nychh/AssetTagBackfill/Backfill` → class
46
+ `_Worker_Client_Nychh_AssetTagBackfill`, method `Backfill`.
47
+ - Live cron: **`Core.CronJobs` id 44**, action `Client/Nychh/AssetTagBackfill/Backfill`.
48
+ - **NetSuite access** — `_Component_Api_Netsuite` (2.0 `_underscore` REST client) SuiteQL. Creds
49
+ come from the worker2 `[netsuite]` ini section (`worker2/Config/<env>.ini`) — **location only,
50
+ never the values**; see [netsuite-rest-client](../../_underscore/features/netsuite-rest-client.md).
51
+
52
+ ## How it works
53
+
54
+ 1. Load all `Client_Nychh.Units` keyed by serial number.
55
+ 2. Ask NetSuite (SuiteQL over the `inventorynumber` table) for the asset tag + MAC of those
56
+ serials, **chunked 500 at a time**, filtering on the **native `inventorynumber` column**:
57
+ `WHERE inventorynumber IN ('serial1','serial2',…)`. Then `SELECT` the custom columns
58
+ `custitemnumber_asset_tag` / `custitemnumber_mac_address` and filter blanks in PHP.
59
+ > **Do not filter on the custom columns.** SuiteQL will `SELECT` them but a
60
+ > `WHERE custitemnumber_asset_tag IS NOT NULL` returns **0 rows with no error** — see the
61
+ > SuiteQL custom-field gotcha in
62
+ > [netsuite-rest-client](../../_underscore/features/netsuite-rest-client.md).
63
+ 3. **Never-blank** batch `UPDATE` of `Units.assetTag` / `Units.macAddress`: a value that is empty
64
+ in NetSuite never overwrites a populated TOGa value (NYCHH tags are sparse-then-growing, so a
65
+ blank NetSuite side is "not assigned yet", not "clear it").
66
+
67
+ ### Running it
68
+
69
+ - `parameters` is spread as **named arguments** into `Backfill` (worker2 dispatch contract — see
70
+ [creating-worker-actions](creating-worker-actions.md)). It runs **dry by default**.
71
+ - **Write:** pass `{"dryRun": false}`.
72
+ - **Single-serial verification:** pass `serialNumber` to run one serial and print a
73
+ NetSuite-vs-TOGa comparison (no batch, useful for spot-checking a specific unit).
74
+
75
+ ### Verification & full-history backfill (1.0 companions)
76
+
77
+ Two 1.0 `worker` scripts (run via PuTTY) back this up:
78
+
79
+ - **`worker/crons/toga2/netsuite/verify_fulfillment_asset_tag_sync_nychh.php`** — read-only,
80
+ compares NetSuite vs TOGa per fulfillment to confirm the sync grabbed tags correctly.
81
+ - **`worker/crons/toga2/netsuite/backfill_all_asset_tags_from_netsuite_nychh.php`** —
82
+ full-history SOAP per-serial backfill; `DRY_RUN` default, never-blank, resumable via a ledger
83
+ file.
84
+
85
+ > **Reading the TOGa side must be flat + fully paginated.** api2 GET silently **truncates nested
86
+ > collections** in a single response (a fulfillment's nested `itemFulfillmentItemUnits` came back
87
+ > as 45 of 70), producing false verification results. Read flat, fully-paginated GETs and take
88
+ > counts from `meta.totalRecordCount` — never from a single nested response. See the gotcha in
89
+ > [App_Api_Toga2](../../../1.0/apps/library/features/toga2-api-client-and-bridge.md).
90
+
91
+ ## Change history
92
+ - 2026-08-05 — BUILT the worker2 NYCHH asset-tag backfill (action
93
+ `Client/Nychh/AssetTagBackfill/Backfill`, cron `Core.CronJobs` id 44) — first NYCHH worker2
94
+ presence. Reads serials, queries NetSuite `inventorynumber` in 500-chunks by native serial
95
+ column (custom tag/MAC columns are SELECT-only, un-filterable), never-blank direct-DB UPDATEs of
96
+ `Units.assetTag`/`macAddress` (no api2, avoids the beta worker2→api2 crash; pattern from
97
+ `_Worker_Client_Prudential_AssetImports`). Added the 1.0 verification + full-history backfill
98
+ companion scripts. (bala)
99
+ </content>
@@ -19,7 +19,7 @@ _Auto-generated by `knowledge.js index`. Do not hand-edit._
19
19
  ## 2.0 framework
20
20
 
21
21
  - **_underscore** (_Underscore) _(framework core)_ — 46 doc(s) → [2.0/apps/_underscore/INDEX.md](2.0/apps/_underscore/INDEX.md)
22
- - **worker2** (Worker) — 39 doc(s) → [2.0/apps/worker2/INDEX.md](2.0/apps/worker2/INDEX.md)
22
+ - **worker2** (Worker) — 40 doc(s) → [2.0/apps/worker2/INDEX.md](2.0/apps/worker2/INDEX.md)
23
23
  - **api2** (API) — 22 doc(s) → [2.0/apps/api2/INDEX.md](2.0/apps/api2/INDEX.md)
24
24
  - **dbchanges2** (Database Changes) _(framework core)_ — 5 doc(s) → [2.0/apps/dbchanges2/INDEX.md](2.0/apps/dbchanges2/INDEX.md)
25
25
  - **toga2-supply** (TOGa Supply) — 5 doc(s) → [2.0/apps/toga2-supply/INDEX.md](2.0/apps/toga2-supply/INDEX.md)
@@ -7,12 +7,15 @@ apps:
7
7
  - toga2-supply
8
8
  - toga25-supply
9
9
  - dbchanges2
10
+ - worker2
11
+ - worker
12
+ - library
10
13
  project: _Underscore
11
14
  client: nychh
12
15
  type: profile
13
16
  status: active
14
- updated: 2026-07-09
15
- owners: ["jcardinal", "apeterson"]
17
+ updated: 2026-08-05
18
+ owners: ["jcardinal", "apeterson", "bala"]
16
19
  files: []
17
20
  related:
18
21
  - ../../2.0/apps/_underscore/features/tracking-number-bridges.md
@@ -26,6 +29,8 @@ table views. Client-specific DB change-sets live in `dbchanges2/Client_Nychh/`.
26
29
  ## Platforms & data
27
30
  - **2.0:** `_underscore` backend, prod schema `Client_Nychh`. Item fulfillments, units, and
28
31
  tracking numbers follow the shared tracking-number bridge model.
32
+ - **Worker tiers:** `worker2` (2.0) runs the NYCHH asset-tag backfill; `worker`/`library` (1.0)
33
+ run the NetSuite→TOGa Supply sync and the asset-tag verification/backfill diagnostics.
29
34
 
30
35
  ## Key features (this client)
31
36
  - Item-fulfillment table views (`item-fulfillments-for-sales-orders`,
@@ -33,6 +38,10 @@ table views. Client-specific DB change-sets live in `dbchanges2/Client_Nychh/`.
33
38
  re-rooted at `ItemFulfillmentItems` with item-level (record 318) tracking. See
34
39
  [Tracking-Number Bridge Migration](../../2.0/apps/_underscore/features/tracking-number-bridges.md)
35
40
  (Client variations + the TableView portability gotcha).
41
+ - **Asset-tag backfill** (worker2, first NYCHH worker2 presence) — keeps `Units.assetTag`/
42
+ `macAddress` synced from the NetSuite serial (inventory-number) record, closing the gap where a
43
+ tag added after the ItemShip never bumps the fulfillment sync. See
44
+ [NYCHH Asset-Tag Backfill](../../2.0/apps/worker2/features/nychh-asset-tag-backfill.md).
36
45
 
37
46
  ## Notes
38
47
  - Tracking data: record 318 (item-level) is currently empty for this client; their tracking
@@ -35,13 +35,19 @@ Richmond University Medical Center (**RUMCSI**) is a framework **1.0** client se
35
35
  - **TOGa View SAML.** `togaview/_/app/framework.php` has a RUMCSI-specific host branch that selects
36
36
  branding (`$_SESSION['stylePath']`) and the SAML login path — an unregistered togaview hostname
37
37
  falls through to default branding and skips SAML.
38
- - **⚠ Portal login is (probably) an unauthenticated `?hash=` link.** The rumcsi SAML `acs`
39
- handler never populates `$_SESSION`, and the only working rumcsi session builder is a
40
- `_/app/framework.php` block that authenticates on nothing but a base64-encoded email in
41
- `?hash=`. RUMC login works in production, so that path may be load-bearing; it was
42
- deliberately left in place pending confirmation of the real post-SSO redirect URL. Full
43
- analysis and the proper fix: [TOGa View login
44
- flows](../../1.0/apps/togaview/features/login-flows.md#open-security-item--rumcsi-hash-unauthenticated-account-takeover-deliberately-left-in-place).
38
+ - **⚠ Portal login IS an unauthenticated `?hash=` magic link — and it is load-bearing.** The
39
+ rumcsi SAML `acs` handler never populates `$_SESSION`; the only working rumcsi session builder
40
+ is a `_/app/framework.php` block that authenticates on nothing but a base64-encoded email in
41
+ `?hash=` (no signature, nonce, or expiry). The routing evidence is firm: `framework.php:278`,
42
+ inside that block, is the **only** rumcsi route to `/enterprise_dashboard`, which is exactly
43
+ where a real production SSO login landed
44
+ (`rumcsi.togaview.agilantsolutions.com/enterprise_dashboard`), and togaview has **no** `?saml=`
45
+ handling, so the 2.0 SAML gateway is not involved. **Deleting the block breaks RUMC SSO into an
46
+ IdP redirect loop** — it must be signed or replaced by a completed `acs` handler, in
47
+ coordination with whoever generates the link (the generator is external to our repos and still
48
+ unidentified). Full analysis, both fix shapes, and the outstanding cold-login confirmation test:
49
+ [TOGa View login
50
+ flows](../../1.0/apps/togaview/features/login-flows.md#open-security-item--rumcsi-hash-is-the-live-production-sso-login-do-not-delete-it).
45
51
  Note also that the SAML branch is gated on `!inTestMode()`, so **test mode leaves rumcsi with
46
52
  no login path**.
47
53
  - **Hardcoded client-ID branching.** RUMCSI appears in TOGa Desk's hardcoded client-ID branching
@@ -49,6 +55,9 @@ Richmond University Medical Center (**RUMCSI**) is a framework **1.0** client se
49
55
 
50
56
  ## Change history
51
57
 
58
+ - 2026-08-05 (later) — routing evidence confirms the `?hash=` path is the live SSO login (only
59
+ route to `/enterprise_dashboard`; no `?saml=` handling in togaview); deleting it would break
60
+ RUMC login into a redirect loop. (mhammontree)
52
61
  - 2026-08-05 — recorded the `?hash=` portal-login path as an open takeover risk left in place
53
62
  pending SSO confirmation, and the test-mode no-login-path gap (TRUE-80671). (mhammontree)
54
63
  - 2026-08-04 — Client profile created during the TRUE-80139 read-only review of the RUMCSI branded
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toga-ai",
3
- "version": "1.0.528",
3
+ "version": "1.0.530",
4
4
  "description": "TOGA Technology Team Claude Knowledge System — shared AI coding harness with skills, knowledge base CLI, and project installer for Claude Code.",
5
5
  "keywords": [
6
6
  "claude",