toga-ai 1.0.339 → 1.0.341

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,7 +6,7 @@ project: TOGa Commerce
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-07-14
9
+ updated: 2026-07-15
10
10
  owners: ["apeterson", "tcox"]
11
11
  files:
12
12
  - src/fieldsConfig/index.ts
@@ -48,7 +48,8 @@ resolved per tenant, per language, and per role**. There are **two layers**:
48
48
  {
49
49
  "languageSwitcher": { "uuid": "1", "isEnabled": true|false },
50
50
  "userApiFields": ["firstName", "lastName", "email", "uuid", "_isAdmin", ...],
51
- "fetchUsersApiFields": ["firstName", "lastName", "email", "uuid", ...]
51
+ "fetchUsersApiFields": ["firstName", "lastName", "email", "uuid", ...],
52
+ "fetchUserApiFields": ["firstName", "lastName", "email", "uuid", "supervisorUser.uuid", ...]
52
53
  }
53
54
  ```
54
55
 
@@ -65,12 +66,18 @@ resolved per tenant, per language, and per role**. There are **two layers**:
65
66
  (`fetchUsers()` in `src/pages/Cart/api/CartApi.ts`, driven from `NavIconList.tsx`). Distinct from
66
67
  `userApiFields` (which is the login fetch). COMPASS/COMPASSCANADA use the 14-field default list;
67
68
  **QUAD uses an 8-field list that intentionally omits `supervisorUser.*`** (see gotcha).
69
+ - `fetchUserApiFields` — the allow-list of user columns fetched by the **singular detail fetch**
70
+ (`fetchUser()` in `src/pages/Cart/api/CartApi.ts`, driven from `NavIconList.tsx` and
71
+ `fetchUserByContactUuid()`). COMPASS/COMPASSCANADA use the 19-field default list;
72
+ **QUAD uses a 13-field list that — unlike the plural `fetchUsersApiFields` — *does* include
73
+ `supervisorUser.*`** (reporting-chain data comes from the detail fetch, not the search list;
74
+ see gotcha).
68
75
 
69
76
  Resolved by **`src/fieldsConfig/getClientLoginFields.ts`** — a `switch(client)` returning the JSON;
70
77
  **default is `COMPASS`** (not DEFAULT). Called from `AuthContext` as `getClientLoginFields(host)`.
71
78
  The `languageSwitcher.isEnabled` value feeds the login-settings branch in `getLoginSettings.ts`.
72
79
 
73
- ### `fetchUsersApiFields` and the config-driven view-as search (C6)
80
+ ### `fetchUsersApiFields` / `fetchUserApiFields` and the config-driven Cart fetches (C6)
74
81
 
75
82
  `fetchUsers()` in `src/pages/Cart/api/CartApi.ts` no longer hardcodes its field list. It resolves it
76
83
  via `getClientLoginFields(fieldKey ?? "").fetchUsersApiFields`, replacing a former
@@ -82,9 +89,25 @@ tenant JSONs are **statically imported**, a tenant missing `fetchUsersApiFields`
82
89
  compile error — not a silent empty fetch — so the list is client-editable via config without
83
90
  touching Cart code.
84
91
 
85
- **Follow-up candidate (not yet done):** `fetchUser()` (singular, same `CartApi.ts`) still holds the
86
- same hardcoded QUAD-vs-default field-list pattern and is a candidate for the identical config
87
- treatment in a future ticket.
92
+ **Singular `fetchUser()` same treatment (TRUE-80016).** `fetchUser()` (the detail fetch, same
93
+ `CartApi.ts`) now hoists its default field list from config too:
94
+
95
+ ```ts
96
+ const fields = singleUserFields ?? getClientLoginFields(fieldKey).fetchUserApiFields;
97
+ ```
98
+
99
+ The former hardcoded default lists moved byte-for-byte into `fetchUserApiFields` in each tenant JSON
100
+ (COMPASS/COMPASSCANADA = the old 19-field default; QUAD = the old 13-field list). Precedence is
101
+ preserved: a caller-supplied **`singleUserFields`** override (Layer B `fields?.getSingleUserFields?.fields`,
102
+ passed by `useEditOrderViewModel.ts` and `useEditCartViewModel.ts`) still wins; `NavIconList.tsx` and
103
+ `fetchUserByContactUuid()` take the config defaults.
104
+
105
+ Unlike `fetchUsers`, the `fieldKey === "QUAD"` vs default **branch was retained** — it encodes
106
+ genuine behavior differences beyond the field list: the default branch uses `depth: 5` and enriches
107
+ the result with `fetchCostCenter()` (cost-center name); the QUAD branch does neither. Only the field
108
+ *lists* were externalized to config; the structural difference stays in code. Naming convention now
109
+ established: **`fetch<X>ApiFields` in `clientFields/<TENANT>.json` mirrors the CartApi function name**
110
+ (`fetchUsers` → `fetchUsersApiFields`, `fetchUser` → `fetchUserApiFields`).
88
111
 
89
112
  ### `userApiFields` and the SAML post-auth fetch (`useAuthenticationFlow`)
90
113
 
@@ -232,6 +255,11 @@ Common field attributes: `uuid`, `label`, `valueKey` (path into the data object)
232
255
  `fetchUser()` after selection. QUAD also uses the `_isGlobalAdmin`/`_isBuyer`/`_isItShopper` role
233
256
  model, not the supervisor hierarchy. Aligning the two lists would smuggle a behavior change into a
234
257
  refactor.
258
+ - **The two QUAD lists intentionally differ on `supervisorUser.*`.** QUAD's `fetchUserApiFields`
259
+ (singular detail fetch) **includes** `supervisorUser.*`, while its `fetchUsersApiFields` (plural
260
+ search list) **omits** them. This is the same fact from both directions: reporting-chain data is
261
+ loaded on the detail fetch, not the search list — so the asymmetry is correct, not a copy-paste
262
+ slip. Do not "reconcile" the two lists.
235
263
 
236
264
  ## Change history
237
265
  - 2026-06-23 — Initial: documented the two-layer field system (global `clientFields/<TENANT>.json`
@@ -249,4 +277,13 @@ Common field attributes: `uuid`, `label`, `valueKey` (path into the data object)
249
277
  TRUE-80015 `fetchUsers` refactor). All user-field lists now flow through config;
250
278
  COMPASSCANADA now resolves its own JSON rather than the generic `else`. Behavior-identical, `tsc`
251
279
  clean. (tcox)
280
+ - 2026-07-15 — Completed the `fetchUser()` follow-up (TRUE-80016): added a `fetchUserApiFields` key
281
+ to each tenant JSON (COMPASS/COMPASSCANADA = former 19-field default, QUAD = former 13-field list,
282
+ byte-for-byte) and made the singular detail fetch hoist `singleUserFields ??
283
+ getClientLoginFields(fieldKey).fetchUserApiFields`. Caller override precedence preserved; the
284
+ `fieldKey === "QUAD"` branch retained (it also gates `depth: 5` + `fetchCostCenter()` enrichment,
285
+ not just fields). Established the `fetch<X>ApiFields` ↔ CartApi-function naming convention. Intended
286
+ refactor-only, no behavior change — not yet verified with `tsc`. Landed standalone on branch
287
+ TRUE-80016 (not stacked on the unmerged TRUE-80015); whichever PR merges second takes minor "keep
288
+ both" conflicts on the shared import line and adjacent JSON keys. (tcox)
252
289
  </content>
@@ -16,7 +16,7 @@
16
16
  | [Etilize Catalog Item Import & Refresh](features/etilize-catalog-item-import.md) | Client-generic catalog onboarding from an S3 CSV plus an Etilize re-pull. | worker2/Worker/Etilize/Items.php |
17
17
  | [Etilize Item Translation Import](features/etilize-item-translation-import.md) | The abstract worker class `_Worker_Etilize_ItemTranslations` imports **non-English** item text from Etilize into the client's `ItemTranslations` table. | worker2/Worker/Etilize/ItemTranslations.php |
18
18
  | [Monitoring Framework (Orchestrator + Child Monitors)](features/monitoring-framework.md) | A unified, DB-driven monitoring framework for business-critical data flows (Compass POs, Prudential asset imports, AIG closed claims, …). | worker2/Worker/Monitor.php, worker2/Worker/Monitors/, worker2/Worker/Monitors/RateEntitlement.php, worker2/Worker/Notification/Email.php, worker2/Worker/Rate.php, dbchanges2/Core/2026-05-21 - Monitors.sql, dbchanges2/Core/2026-06-29a - Rate Entitlement Contract Monitor.sql |
19
- | [NetSuite ↔ ClickUp / TOGA Opportunity Sync (API Message Queue + worker2 webhook)](features/netsuite-opportunity-sync.md) | Outbound sync from NetSuite to TOGA for the record types the Forecast2 importer pulls (opportunities first; sales/items/etc. | worker2/Worker/Netsuite.php, worker2/Worker/Netsuite/Opportunity.php, worker2/Worker/Clickup.php, worker2/Worker/Clickup/Opportunity.php, worker2/Controller/Index.php, _underscore/Worker.php, test/@dave/NetSuite/api-message-queue/lib_amq_queue.js, test/@dave/NetSuite/api-message-queue/ue_api_msg_queue_enqueue.js, test/@dave/NetSuite/api-message-queue/ue_amq_drain.js, test/@dave/NetSuite/api-message-queue/ss_amq_drain.js, test/@dave/NetSuite/api-message-queue/DEPLOY_RUNBOOK.md, test/@dave/clickup/backfill_opportunity_numbers.php, test/@dave/clickup/probe_opportunity_fields.php, test/@dave/probe_clickup_desc_match.php, test/@dave/test_model_load_behavior.php, dbchanges2/Forecast/2026-06-25a - Add unique index on Opportunities netsuiteOpportunityInternalId.sql, worker/crons/toga2/forecast2/common_import_sales_from_netsuite.php |
19
+ | [NetSuite ↔ ClickUp / TOGA Opportunity Sync (API Message Queue + worker2 webhook)](features/netsuite-opportunity-sync.md) | Outbound sync from NetSuite to TOGA for the record types the Forecast2 importer pulls (opportunities first; sales/items/etc. | worker2/Worker/Netsuite.php, worker2/Worker/Netsuite/Opportunity.php, worker2/Worker/Clickup.php, worker2/Worker/Clickup/Opportunity.php, worker2/Controller/Index.php, _underscore/Worker.php, test/@dave/NetSuite/api-message-queue/lib_amq_queue.js, test/@dave/NetSuite/api-message-queue/ue_api_msg_queue_enqueue.js, test/@dave/NetSuite/api-message-queue/ue_amq_drain.js, test/@dave/NetSuite/api-message-queue/ss_amq_drain.js, test/@dave/NetSuite/api-message-queue/DEPLOY_RUNBOOK.md, test/@dave/clickup/backfill_opportunity_numbers.php, test/@dave/clickup/probe_opportunity_fields.php, test/@dave/probe_clickup_desc_match.php, test/@dave/test_model_load_behavior.php, dbchanges2/Forecast/2026-06-25a - Add unique index on Opportunities netsuiteOpportunityInternalId.sql, _underscore/Model/Forecast/Opportunity.php, test/@dave/approach/TRUE-80044.md, worker/crons/toga2/forecast2/common_import_sales_from_netsuite.php |
20
20
  | [NetSuite → Forecast Open-Orders Sync (salesOrder webhook → OpenOrderItems)](features/netsuite-salesorder-open-orders-sync.md) | Webhook-driven, single-record port of the legacy open-orders importer (TRUE-79142). | worker2/Worker/Netsuite/SalesOrder.php, worker2/Worker/Netsuite.php, test/@dave/probe_salesorder_rest_shape.php, test/@dave/probe_open_order_lines.php, test/@dave/check_so_status.php, test/@dave/check_so_history.php, test/@dave/probe_so_rest_lines.php, test/@dave/probe_missing_oo_timing.php, test/@dave/probe_missing_oo_createdby.php, test/@dave/probe_drift_so_dates.php, test/@dave/probe_open_order_gating.php, worker/crons/toga2/forecast2/import_open_orders.php, worker/crons/toga2/forecast2/common_import_sales_from_netsuite.php |
21
21
  | [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 |
22
22
  | [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 |
@@ -6,7 +6,7 @@ project: Worker
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-06-30
9
+ updated: 2026-07-15
10
10
  owners: ["dfranks"]
11
11
  files:
12
12
  - worker2/Worker/Netsuite.php
@@ -25,6 +25,8 @@ files:
25
25
  - test/@dave/probe_clickup_desc_match.php
26
26
  - test/@dave/test_model_load_behavior.php
27
27
  - "dbchanges2/Forecast/2026-06-25a - Add unique index on Opportunities netsuiteOpportunityInternalId.sql"
28
+ - _underscore/Model/Forecast/Opportunity.php
29
+ - test/@dave/approach/TRUE-80044.md
28
30
  - worker/crons/toga2/forecast2/common_import_sales_from_netsuite.php
29
31
  related:
30
32
  - ./netsuite-salesorder-open-orders-sync.md
@@ -166,6 +168,39 @@ Shared helpers: `buildCustomFields()` (the field array, used by both create and
166
168
  removing that `setLogging(false)` is under review (handle the 20–50 KB payload volume the
167
169
  prod-appropriate way — retention/sampling/summary — not by defaulting logging off).
168
170
 
171
+ ## Atomic ClickUp-task claim (TRUE-80044 — decided approach, not yet built)
172
+
173
+ Concurrent NetSuite opportunity webhooks (a real edit racing NetSuite's hourly `OPP` fallback) can both
174
+ pass the non-atomic find-then-create in `maybeCreateClickupTask()` and create **duplicate** ClickUp
175
+ tasks. The fix is a **race-safe DB claim** on the **existing `Forecast.Opportunities` table** — an added
176
+ `clickupTaskId` column — **not** a new dedicated table. (A prior plan proposed a new
177
+ `NetsuiteOpportunityClickupTask` table in `Client_True`; reviewer Rohan Girish rejected that and mandated
178
+ reusing `Forecast.Opportunities`.)
179
+
180
+ How the claim is race-safe:
181
+ - **Conditional UPDATE = the atomic claim.** A raw `_Query`:
182
+ `UPDATE Opportunities SET clickupTaskId='<uuid sentinel>' WHERE netsuiteOpportunityInternalId=X AND
183
+ clickupTaskId IS NULL`, then commit. `affectedRows === 1` → this webhook won the claim and proceeds to
184
+ create the task; `=== 0` → another concurrent handler already claimed it → **skip**. The winner then
185
+ persists the real task id over the sentinel; a `createTask()` failure runs a **compensation-delete**
186
+ (reset `clickupTaskId` back to `NULL`, guarded by the sentinel uuid so it only clears its own claim).
187
+ - It works because (a) `Forecast.Opportunities` already has a **UNIQUE index on
188
+ `netsuiteOpportunityInternalId`** (`dbchanges2/Forecast/2026-06-25a`) → the guarded UPDATE locks a
189
+ single row, and (b) in both `post()` and `put()`, `importOpportunity()` runs and **COMMITS** the
190
+ Forecast row (`Opportunity.php:289`) **before** `maybeCreateClickupTask()` is called, so the row exists
191
+ and is committed at claim time.
192
+ - **Raw `_Query` is required** for the guarded UPDATE — the `_underscore` ORM `save()` is load-then-write
193
+ and cannot express an atomic guarded UPDATE. All non-claim writes stay on the ORM.
194
+ - **Commit the claim BEFORE the ClickUp POST** — releases the single-row lock and dodges the
195
+ `DB_FORECAST` lazy-transaction drop.
196
+ - **`createTask()` must return the new task id (`:string`)** — it currently returns `void` and discards
197
+ the ClickUp response; the winner needs the id to persist over the sentinel.
198
+ - **Edge case:** if `importOpportunity()` was skipped (no NS status → no Forecast row), there is nothing
199
+ to claim; that rare path falls back to today's unguarded create.
200
+
201
+ Requires a new `dbchanges2/Forecast/` ALTER adding the `clickupTaskId` column, and a
202
+ `_underscore/Model/Forecast/Opportunity.php` field addition. Plan: `test/@dave/approach/TRUE-80044.md`.
203
+
169
204
  ## Data model
170
205
 
171
206
  Writes `Forecast.Opportunities` (header) + `Forecast.OpportunityItems` (children), faithful to
@@ -287,16 +322,18 @@ None — platform-wide Forecast sync.
287
322
  the create branch) you must change the **leading** content or clear the field entirely. Verified both ways
288
323
  on opp 74266/internalId 7161054: `74266--` → matched (update branch, skipped); field removed → missed
289
324
  (create branch, new task `868k2rfj8`).
290
- - **ClickUp dedup keys on the `Opportunity #` field, not a DB column.** We deliberately did NOT add
291
- a `clickupTaskId` column — the dedup lookup queries ClickUp itself by `Opportunity #` (= tranId).
292
- Consequences to know:
325
+ - **ClickUp dedup keys on the `Opportunity #` field, not a DB column.** Historically we did NOT add
326
+ a `clickupTaskId` column — the dedup lookup queried ClickUp itself by `Opportunity #` (= tranId).
327
+ **This is being replaced (TRUE-80044) by a race-safe DB-claim approach — see "Atomic ClickUp-task
328
+ claim" below.** Consequences of the field-lookup approach to know:
293
329
  - **Legacy tasks had `Opportunity #` empty** (the old `webhook/` handler never set it), so they
294
330
  can't be matched until backfilled — see the backfill tool below. Without backfill, the first
295
331
  edit of a pre-existing opp creates a *second* task.
296
332
  - **The find-then-create is not atomic.** Two near-simultaneous events for one opp (e.g. the
297
333
  NetSuite hourly `OPP` fallback firing alongside a real edit) can both miss and both create. A
298
334
  committed local task id would close most of that window; the field-lookup approach can't.
299
- ClickUp custom fields aren't unique-constrained, so some race exists regardless.
335
+ ClickUp custom fields aren't unique-constrained, so some race exists regardless. **TRUE-80044
336
+ closes this window with the atomic `clickupTaskId` DB claim — see "Atomic ClickUp-task claim" above.**
300
337
  - **Update is per-field** (`/task/{id}/field/{id}` — no bulk set), so a mid-loop failure leaves a
301
338
  partially-updated task; the thrown error names how far it got. The Presales Lead users field is
302
339
  **add-only** on update (we don't read the prior assignee to remove it).
@@ -449,6 +486,19 @@ deprecated** for production opportunity code.
449
486
  blocked on the Aaron stakeholder decision noted above.
450
487
 
451
488
  ## Change history
489
+ - 2026-07-15 — **Decided the atomic ClickUp-task dedup approach (TRUE-80044) — planning/rework, no code
490
+ shipped.** The non-atomic find-then-create lets concurrent NS opportunity webhooks create duplicate
491
+ ClickUp tasks. Decided fix: a race-safe **conditional-UPDATE claim** on a new `clickupTaskId` column of
492
+ the **existing `Forecast.Opportunities`** table (`UPDATE … SET clickupTaskId='<uuid>' WHERE
493
+ netsuiteOpportunityInternalId=X AND clickupTaskId IS NULL`; commit; `affectedRows===1` wins, `===0`
494
+ skips), with a sentinel-guarded compensation-delete on `createTask()` failure. Reviewer (Rohan Girish)
495
+ **rejected** the prior plan's new dedicated `NetsuiteOpportunityClickupTask` table and mandated the
496
+ column-on-Forecast.Opportunities approach — safe because the table already has a UNIQUE index on
497
+ `netsuiteOpportunityInternalId` (single-row lock) and `importOpportunity()` commits the row before
498
+ `maybeCreateClickupTask()` runs. Requires raw `_Query` (ORM `save()` can't express a guarded UPDATE),
499
+ claim committed before the ClickUp POST (lock release + lazy-transaction), and `createTask()` changed to
500
+ return the new task id. See "Atomic ClickUp-task claim" section. Plan: `test/@dave/approach/TRUE-80044.md`.
501
+ (dfranks)
452
502
  - 2026-06-30 — **Built the CU→NS reverse sync + NS→CU stage drive (TRUE-79181) — integration now
453
503
  bidirectional for field + stage.** New `_Worker_Clickup_Opportunity` handler
454
504
  (`worker2/Worker/Clickup/Opportunity.php`), dispatched by `_Worker_Clickup::Webhook` via
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toga-ai",
3
- "version": "1.0.339",
3
+ "version": "1.0.341",
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",