toga-ai 1.0.250 → 1.0.252
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.
|
@@ -8,5 +8,5 @@
|
|
|
8
8
|
| [Column Visibility (URL-driven show/hide columns)](features/column-visibility.md) | A "Columns" header button that opens a modal listing every column from the table meta, lets the user show/hide columns, adjusts the table live, and persists the | toga25-supply/src/components/ColumnVisibilityModal/, toga25-supply/src/pages/SalesOrders/SalesOrders.tsx, toga25-supply/src/pages/SalesOrders/viewModel/useSalesOrdersPageViewModel.tsx, toga25-supply/src/pages/SalesOrders/hooks/useSalesOrdersTableData.tsx |
|
|
9
9
|
| [Meta-Driven Page & Table Setup](features/meta-driven-table-data.md) | A page in this app is **meta-driven end to end**: the page view model fetches *page meta* (labels, sections, ACL) and *table meta* (the columns/fields + table s | toga25-supply/src/pages/SalesOrders/viewModel/useSalesOrdersPageViewModel.tsx, toga25-supply/src/pages/SalesOrders/hooks/useSalesOrdersTableState.ts, toga25-supply/src/hooks/useTablePageMeta.ts, toga-blox-npm/dist/hooks/useFetchPageMeta.d.ts, toga-blox-npm/dist/hooks/useFetchTablePageMeta.d.ts, toga-blox-npm/dist/hooks/useAssignTableFieldLabels.d.ts, toga-blox-npm/dist/components/Table/hooks/useTableData.d.ts |
|
|
10
10
|
| [Record Modals & Nested Tables](features/record-modals-and-nested-tables.md) | The repo's family of modal + nested-table patterns layered over toga-blox `TableRecordModal` and `PrimaryTable*Layout`. | toga25-supply/src/layout/ItemRecordModalLayout/, toga25-supply/src/layout/SalesOrderRecordModalLayout/, toga25-supply/src/layout/SalesOrderItemsTableLayout/, toga25-supply/src/layout/ItemFulfillmentModal/, toga25-supply/src/layout/GenericNestedTables/, toga25-supply/src/hooks/useTableCellInteractions.ts |
|
|
11
|
-
| [Surface Frontend (DB-driven UI consumption, src/surface/)](features/surface-frontend.md) | The frontend consumer of the platform-wide Surface layer — DB-driven UI config fetched from `GET /v2/surfaces/meta?slug=<slug>` instead of statically-imported J | toga25-supply/src/surface/useFetchSurfaceMeta.ts, toga25-supply/src/pages/SalesOrders/helpers/surfaceBundleToTenantFields.ts, toga25-supply/src/pages/SalesOrders/view/SalesOrderRecordModalLayout/viewModel/useSalesOrderRecordModalLayoutModel.tsx, toga25-supply/src/pages/SalesOrders/view/SalesOrderRecordModalLayout/helpers/index.ts, toga25-supply/src/surface/evaluateSurfaceRule.ts, toga25-supply/src/surface/actionRegistry.ts, toga25-supply/src/surface/componentRegistry.tsx, toga25-supply/src/surface/SurfaceActionBar.tsx, toga25-supply/src/surface/SurfaceSection.tsx, toga25-supply/src/surface/resolve.ts, toga25-supply/src/surface/types.ts, toga25-supply/src/surface/index.ts, toga25-supply/src/pages/Login/LoginPage.tsx, toga25-supply/src/pages/SalesOrders/SalesOrders.tsx, toga25-supply/src/pages/SalesOrders/view/SalesOrderRecordModalLayout/view/sections/SalesOrderTopBar.tsx, toga25-supply/src/pages/Items/ItemsPage.tsx, toga25-supply/src/pages/Items/viewModel/useItemsPageViewModel.tsx, toga25-supply/src/pages/VendorItems/VendorItemsPage.tsx, toga25-supply/src/pages/VendorItems/viewModel/useVendorItemsPageViewModel.tsx, toga25-supply/src/pages/Inventory/Inventory.tsx, toga25-supply/src/pages/Inventory/viewModel/useInventoryPageViewModel.tsx, toga25-supply/src/pages/Inventory/viewModel/FIELDS/index.ts, toga25-supply/src/fieldsConfig/index.ts |
|
|
11
|
+
| [Surface Frontend (DB-driven UI consumption, src/surface/)](features/surface-frontend.md) | The frontend consumer of the platform-wide Surface layer — DB-driven UI config fetched from `GET /v2/surfaces/meta?slug=<slug>` instead of statically-imported J | toga25-supply/src/surface/useFetchSurfaceMeta.ts, toga25-supply/src/pages/SalesOrders/helpers/surfaceBundleToTenantFields.ts, toga25-supply/src/pages/SalesOrders/helpers/buildPatchedTenantFields.ts, toga25-supply/src/pages/SalesOrders/view/SalesOrderRecordModalLayout/viewModel/useSalesOrderRecordModalLayoutModel.tsx, toga25-supply/src/pages/SalesOrders/view/SalesOrderRecordModalLayout/view/SalesOrderView.tsx, toga25-supply/src/pages/SalesOrders/view/SalesOrderRecordModalLayout/view/layoutComponents/SalesOrderSummaryGrid.tsx, toga25-supply/src/pages/SalesOrders/view/SalesOrderRecordModalLayout/helpers/getDetailSections.tsx, toga25-supply/src/pages/SalesOrders/view/SalesOrderRecordModalLayout/helpers/index.ts, toga25-supply/src/surface/evaluateSurfaceRule.ts, toga25-supply/src/surface/actionRegistry.ts, toga25-supply/src/surface/componentRegistry.tsx, toga25-supply/src/surface/SurfaceActionBar.tsx, toga25-supply/src/surface/SurfaceSection.tsx, toga25-supply/src/surface/resolve.ts, toga25-supply/src/surface/types.ts, toga25-supply/src/surface/index.ts, toga25-supply/src/pages/Login/LoginPage.tsx, toga25-supply/src/pages/SalesOrders/SalesOrders.tsx, toga25-supply/src/pages/SalesOrders/view/SalesOrderRecordModalLayout/view/sections/SalesOrderTopBar.tsx, toga25-supply/src/pages/Items/ItemsPage.tsx, toga25-supply/src/pages/Items/viewModel/useItemsPageViewModel.tsx, toga25-supply/src/pages/VendorItems/VendorItemsPage.tsx, toga25-supply/src/pages/VendorItems/viewModel/useVendorItemsPageViewModel.tsx, toga25-supply/src/pages/Inventory/Inventory.tsx, toga25-supply/src/pages/Inventory/viewModel/useInventoryPageViewModel.tsx, toga25-supply/src/pages/Inventory/viewModel/FIELDS/index.ts, toga25-supply/src/fieldsConfig/index.ts |
|
|
12
12
|
| [Cypress Testing Harness (component + e2e)](workflows/cypress-testing.md) | The Cypress test harness for the `toga25-supply` frontend, bootstrapped from scratch (`cypress` was already a dependency but there was no config, no `cypress/` | toga25-supply/cypress.config.ts, toga25-supply/cypress/tsconfig.json, toga25-supply/cypress/support/component.tsx, toga25-supply/cypress/support/component-index.html, toga25-supply/cypress/support/e2e.ts, toga25-supply/cypress/support/commands.ts, toga25-supply/cypress/support/fixtures.ts, toga25-supply/cypress/support/mocks/useApprovalModalViewModel.ts, toga25-supply/cypress/component/SalesOrderApprovalModalsLayout.cy.tsx, toga25-supply/cypress/component/RecordApprovalModalLayout.cy.tsx, toga25-supply/cypress/component/EnterPoNumberModal.cy.tsx, toga25-supply/cypress/e2e/salesOrderApproval.cy.ts |
|
|
@@ -11,7 +11,11 @@ owners: [jcardinal, apeterson]
|
|
|
11
11
|
files:
|
|
12
12
|
- toga25-supply/src/surface/useFetchSurfaceMeta.ts
|
|
13
13
|
- toga25-supply/src/pages/SalesOrders/helpers/surfaceBundleToTenantFields.ts
|
|
14
|
+
- toga25-supply/src/pages/SalesOrders/helpers/buildPatchedTenantFields.ts
|
|
14
15
|
- toga25-supply/src/pages/SalesOrders/view/SalesOrderRecordModalLayout/viewModel/useSalesOrderRecordModalLayoutModel.tsx
|
|
16
|
+
- toga25-supply/src/pages/SalesOrders/view/SalesOrderRecordModalLayout/view/SalesOrderView.tsx
|
|
17
|
+
- toga25-supply/src/pages/SalesOrders/view/SalesOrderRecordModalLayout/view/layoutComponents/SalesOrderSummaryGrid.tsx
|
|
18
|
+
- toga25-supply/src/pages/SalesOrders/view/SalesOrderRecordModalLayout/helpers/getDetailSections.tsx
|
|
15
19
|
- toga25-supply/src/pages/SalesOrders/view/SalesOrderRecordModalLayout/helpers/index.ts
|
|
16
20
|
- toga25-supply/src/surface/evaluateSurfaceRule.ts
|
|
17
21
|
- toga25-supply/src/surface/actionRegistry.ts
|
|
@@ -56,15 +60,18 @@ feature flag — see below). Backend: [surface-resolver](../../_underscore/featu
|
|
|
56
60
|
**structural fingerprint**: `looksLikeBundle` now requires **both** an `elements` array **and** a
|
|
57
61
|
`surface` descriptor object (not the `elements` array alone — see the decoy gotcha below).
|
|
58
62
|
`extractBundle(raw, slug)` then **prefers** the bundle whose `surface.slug === slug`, falling back to
|
|
59
|
-
first-found, so a stray sibling bundle can't win.
|
|
60
|
-
discriminator key so a single-bundle envelope is not mistaken for a group. On a malformed payload it
|
|
63
|
+
first-found, so a stray sibling bundle can't win. On a malformed payload it
|
|
61
64
|
falls back to a safe empty bundle (empty `elements` + empty `messages`/`theme`/`vocabularies` maps)
|
|
62
65
|
so the page never crashes.
|
|
63
66
|
- **`useFetchSurfaceMetaGroup(slugs[])`** — the grouped sibling of `useFetchSurfaceMeta`, for a screen
|
|
64
67
|
needing many section surfaces at once. **Order-insensitive** (the slug list is normalized so cache
|
|
65
68
|
hits don't depend on argument order), `staleTime: Infinity`, hits the grouped endpoint
|
|
66
|
-
`GET /v2/surfaces/meta-group?slugs=...`, and extracts the `{surfaces}` map via
|
|
67
|
-
|
|
69
|
+
`GET /v2/surfaces/meta-group?slugs=...`, and extracts the SLUG-keyed `{surfaces}` map via the
|
|
70
|
+
recursive **`findSurfaceMap(raw, depth=5)`** — which walks the envelope tree and returns the
|
|
71
|
+
`surfaces` map whose **values look like bundles** (or an empty/null map), uniquely identifying the
|
|
72
|
+
inner slug-keyed map regardless of nesting or `data` wrapping. (`extractGroup`'s old
|
|
73
|
+
`data`/`data.<key>` probe and the `looksLikeGroup` `group`-discriminator check were both removed —
|
|
74
|
+
see the group-nesting gotcha below for why.) Returns `{}` for
|
|
68
75
|
unmigrated screens (`enabled:false`), so a non-roster client does no fetch.
|
|
69
76
|
- **`componentRegistry`** (`src/surface/componentRegistry.tsx`) — the **presentation sibling** of
|
|
70
77
|
`actionRegistry`, both keyed by the surface meta's `action.key`. `actionRegistry` maps key→behavior
|
|
@@ -154,6 +161,17 @@ three bespoke renderers `detailSection`/`locationCard`/`totalsCard` are untouche
|
|
|
154
161
|
`getDetailSections` are untouched — **only the SOURCE of section config moves JSON → Surface.**
|
|
155
162
|
Business logic stays in code: the recurring-lease `_totalLease` gate in the grid and approval
|
|
156
163
|
gating are **not** moved. (Registered-renderer seam: *config describes, code decides.*)
|
|
164
|
+
- **Full adapter-seam data path (verified this session, no direct `<SurfaceSection>`):**
|
|
165
|
+
`useFetchSurfaceMetaGroup(SALES_ORDER_SURFACE_SLUGS)` → `surfaceBundleToTenantFields(surfaces)` →
|
|
166
|
+
merged into `tenantFields` (roster clients only; `clientSlug` is the **uppercased** `getHostname()`
|
|
167
|
+
result matched against the roster) → **`buildPatchedTenantFields`** (which only overrides
|
|
168
|
+
`recordActionFields`, so it **preserves all surface section keys**) → `SalesOrderView` →
|
|
169
|
+
**`SalesOrderSummaryGrid`** (renders by `section.type` / `cardType`:
|
|
170
|
+
`detailSection`/`locationCard`/`totalsCard`, where `cardType` comes from `surface.config.cardType`)
|
|
171
|
+
+ **`getDetailSections`** (reads `section.isVisible` for the `displayToggle` sections, which carry a
|
|
172
|
+
`sectionVisibility` marker element). **Decision (confirmed by the developer this session): KEEP the
|
|
173
|
+
adapter approach — do NOT replace it with a direct `<SurfaceSection>` swap.** The bespoke grid
|
|
174
|
+
renderers and the `TenantFields` seam are the deliberate integration point.
|
|
157
175
|
- **Roster gate — `SALES_ORDER_SURFACE_MIGRATED_CLIENTS = [COMPASS, COMPASSCANADA, QUAD]`** (an
|
|
158
176
|
in-code roster in `surfaceBundleToTenantFields.ts`; the view-model
|
|
159
177
|
`useSalesOrderRecordModalLayoutModel.tsx` consults it). The view-model resolves sections from
|
|
@@ -182,6 +200,27 @@ seeding NYCHH/Prudential/SPGlobal is deferred; the Client-DB prod cross-cluster
|
|
|
182
200
|
bundle → the action bar renders nothing. Fix: `looksLikeBundle` must require **both** `elements` AND
|
|
183
201
|
a `surface` descriptor; use recursive `findBundle` + slug-preferring `extractBundle` (see How it
|
|
184
202
|
works). The durable backend follow-up is to confirm/stabilize the single-meta envelope contract.
|
|
203
|
+
- **The GROUPED meta envelope double-nests the slug-keyed `surfaces` map under an OUTER route-keyed
|
|
204
|
+
`surfaces` slot — match by value shape, never grab the first `surfaces`.** The meta-group V2 envelope
|
|
205
|
+
mirrors single-meta's bundle nesting: the real SLUG-keyed map sits one level deeper under a ROUTE-keyed
|
|
206
|
+
outer slot — `{ surfaces: { "meta-group": { group, surfaces: { "order-details": bundle, … } } },
|
|
207
|
+
elements: [] }`. The old `extractGroup` grabbed the **first** `surfaces` it saw (the outer route-keyed
|
|
208
|
+
one, whose values are *group* objects, not bundles), so `surfaces["order-details"]` was `undefined`
|
|
209
|
+
and `surfaceBundleToTenantFields` produced `{}` — every migrated-client SalesOrders section vanished.
|
|
210
|
+
Fix: recursive **`findSurfaceMap(raw, depth=5)`** returns the `surfaces` map whose **values look like
|
|
211
|
+
bundles** (or null/empty), uniquely identifying the inner slug-keyed map regardless of nesting or
|
|
212
|
+
`data` wrapping. Verified by trace across 6 envelope shapes (double-nested, resolver-return-at-top,
|
|
213
|
+
simple, data-wrapped, null-child, empty). **Note:** an earlier attempt that required a top-level
|
|
214
|
+
`group` discriminator on the probed level (`looksLikeGroup`) broke the fetch (returned `{}`) because
|
|
215
|
+
the V2 envelope carries no top-level `group` at that level — `looksLikeGroup` was removed entirely.
|
|
216
|
+
- **For migrated clients, a dropped (`surface: null`) display section silently vanishes — there is no
|
|
217
|
+
JSON fallback left.** Once a client's JSON section blocks are deleted (roster migration), a section
|
|
218
|
+
whose surface resolves to `surface: null` (a seed/ACL gap, e.g. `additional-order-details` /
|
|
219
|
+
`admin-notes` / `notes`) is dropped by `surfaceBundleToTenantFields` and the section disappears with
|
|
220
|
+
no fallback. That is a backend seed/ACL gap, **not** an FE bug. Separately, two sections are
|
|
221
|
+
intentionally gated in `getDetailSections`: `shipments` is **permanently hidden** (the view-model
|
|
222
|
+
hardcodes `shipmentsData: []` and `useShipments` is commented out; the section gates on
|
|
223
|
+
`shipmentsData.length > 0`), and `invoices` is data-gated on `invoiceData.length > 0`.
|
|
185
224
|
- **The surface `record` shape must match the rule field namespace — wrap as `{ order }`.** Seeded
|
|
186
225
|
Tier-1 rules namespace their field as `order._status`, so `getByPath(record, "order._status")` only
|
|
187
226
|
resolves if the host passes `record={{ order }}`. Passing the bare order object resolves to
|
|
@@ -210,6 +249,19 @@ seeding NYCHH/Prudential/SPGlobal is deferred; the Client-DB prod cross-cluster
|
|
|
210
249
|
treat type-checking as pending. Runtime `GET /v2/surfaces/{slug}/meta` also not yet exercised.
|
|
211
250
|
|
|
212
251
|
## Change history
|
|
252
|
+
- 2026-06-30 — Follow-up: fixed the GROUPED meta fetch returning `{}` (every migrated-client
|
|
253
|
+
SalesOrders section had vanished). The meta-group envelope double-nests the slug-keyed `surfaces`
|
|
254
|
+
map under an outer ROUTE-keyed `surfaces` slot; the old `extractGroup` grabbed the first (outer,
|
|
255
|
+
group-valued) `surfaces`. Replaced it with recursive `findSurfaceMap(depth=5)` that returns the
|
|
256
|
+
`surfaces` map whose values are bundle-shaped (verified across 6 envelope shapes). **Reverted the
|
|
257
|
+
earlier same-session `group`-discriminator `looksLikeGroup` change** — requiring a top-level `group`
|
|
258
|
+
broke the fetch; `looksLikeGroup` removed entirely. Verified the SalesOrders section migration is
|
|
259
|
+
fully wired via the adapter seam (`useFetchSurfaceMetaGroup` → `surfaceBundleToTenantFields` →
|
|
260
|
+
`buildPatchedTenantFields` (overrides only `recordActionFields`) → `SalesOrderSummaryGrid` /
|
|
261
|
+
`getDetailSections`) and confirmed the decision to KEEP the adapter over a direct `<SurfaceSection>`.
|
|
262
|
+
Documented why some sections stay hidden (`shipments` hardcoded empty; `invoices` data-gated; and a
|
|
263
|
+
dropped `surface: null` display section vanishes with no JSON fallback for migrated clients — a
|
|
264
|
+
seed/ACL gap). (apeterson)
|
|
213
265
|
- 2026-06-30 — Migrated the SalesOrders record-modal sections onto Surface for the roster clients
|
|
214
266
|
[COMPASS, COMPASSCANADA, QUAD] via the registered-renderer seam: added `useFetchSurfaceMetaGroup`
|
|
215
267
|
(order-insensitive grouped `/surfaces/meta-group` fetch) + `surfaceBundleToTenantFields()` (adapts
|
|
@@ -221,7 +273,7 @@ seeding NYCHH/Prudential/SPGlobal is deferred; the Client-DB prod cross-cluster
|
|
|
221
273
|
- 2026-06-30 — Debugged + extended the client-side action-bar renderer (no client-specific work).
|
|
222
274
|
Fixed three rendering bugs: (1) the single-meta envelope's **decoy `elements: []`** masked the real
|
|
223
275
|
nested bundle — `looksLikeBundle` now requires `elements` AND a `surface` descriptor, with recursive
|
|
224
|
-
`findBundle(depth=4)` + slug-preferring `extractBundle` +
|
|
276
|
+
`findBundle(depth=4)` + slug-preferring `extractBundle` +
|
|
225
277
|
safe empty `messages`/`theme`/`vocabularies` fallback (`useFetchSurfaceMeta.ts`); (2) Tier-1 rules
|
|
226
278
|
always false because the host passed the bare order object instead of the `{ order }` envelope the
|
|
227
279
|
`order.*` field namespace requires (`SalesOrderTopBar.tsx`); (3) every action icon rendered as
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
| [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 |
|
|
14
14
|
| [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 |
|
|
15
15
|
| [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 |
|
|
16
|
-
| [NetSuite
|
|
16
|
+
| [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 |
|
|
17
17
|
| [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 |
|
|
18
18
|
| [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 |
|
|
19
19
|
| [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, _underscore/Model/Client/EmailTemplate.php |
|
|
@@ -1,16 +1,18 @@
|
|
|
1
1
|
---
|
|
2
|
-
title: NetSuite
|
|
2
|
+
title: NetSuite ↔ ClickUp / TOGA Opportunity Sync (API Message Queue + worker2 webhook)
|
|
3
3
|
framework: "2.0"
|
|
4
4
|
repo: worker2
|
|
5
5
|
project: Worker
|
|
6
6
|
client: shared
|
|
7
7
|
type: feature
|
|
8
8
|
status: active
|
|
9
|
-
updated: 2026-06-
|
|
9
|
+
updated: 2026-06-30
|
|
10
10
|
owners: ["dfranks"]
|
|
11
11
|
files:
|
|
12
12
|
- worker2/Worker/Netsuite.php
|
|
13
13
|
- worker2/Worker/Netsuite/Opportunity.php
|
|
14
|
+
- worker2/Worker/Clickup.php
|
|
15
|
+
- worker2/Worker/Clickup/Opportunity.php
|
|
14
16
|
- worker2/Controller/Index.php
|
|
15
17
|
- _underscore/Worker.php
|
|
16
18
|
- test/@dave/NetSuite/api-message-queue/lib_amq_queue.js
|
|
@@ -38,7 +40,10 @@ Outbound sync from NetSuite to TOGA for the record types the Forecast2 importer
|
|
|
38
40
|
record ("API Message Queue"), a scheduled SuiteScript drains it to
|
|
39
41
|
`webhook.togahub.com/netsuite`, and worker2 routes it to a per-recordType handler that upserts
|
|
40
42
|
into the `Forecast` DB. The legacy NetSuite→ClickUp opportunity-task creation is preserved as a
|
|
41
|
-
second, independently-gated concern in the same handler.
|
|
43
|
+
second, independently-gated concern in the same handler. As of 2026-06-30 (TRUE-79181) the ClickUp leg
|
|
44
|
+
is **bidirectional for field + stage**: ClickUp task edits flow back to the linked NetSuite opportunity
|
|
45
|
+
(`_Worker_Clickup_Opportunity`, update-only), and NS→CU now drives the ClickUp task **status** — see
|
|
46
|
+
the "CU→NS direction" section.
|
|
42
47
|
|
|
43
48
|
## Key files / entry points
|
|
44
49
|
|
|
@@ -364,29 +369,106 @@ None — platform-wide Forecast sync.
|
|
|
364
369
|
(doubled-prefix ids — see the doubled-id gotcha), which are **SuiteQL-queryable** for after-the-fact
|
|
365
370
|
diagnosis even though the calling-script log rolls off.
|
|
366
371
|
|
|
367
|
-
## CU→NS direction (
|
|
372
|
+
## CU→NS direction (BUILT 2026-06-30, TRUE-79181): reverse sync
|
|
368
373
|
|
|
369
|
-
The
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
before comparing-to / writing-to NetSuite:
|
|
374
|
+
The integration is now **bidirectional** for **field + stage**. ClickUp task edits flow back to the
|
|
375
|
+
linked NetSuite opportunity via a new handler, and NS→CU now drives the ClickUp task **status** (not
|
|
376
|
+
just description text).
|
|
373
377
|
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
-
|
|
379
|
-
`NetSuite Internal ID:` line. Company / Amount / Expected Close / Stage are NS-derived display, not
|
|
380
|
-
ClickUp-authored — parse them out and ignore.
|
|
381
|
-
- **Custom fields** are already discrete — no extraction.
|
|
378
|
+
**Handler:** `_Worker_Clickup_Opportunity` (`worker2/Worker/Clickup/Opportunity.php`) — an abstract
|
|
379
|
+
static-class worker, dispatched by `_Worker_Clickup::Webhook` via
|
|
380
|
+
`_Worker::runTask('Clickup/Opportunity/Process', {taskId,event,data})`. Routing is gated by the ClickUp
|
|
381
|
+
**LIST id** (Presales Qualification list `901111987449`) for `taskUpdated`/`taskStatusUpdated` events;
|
|
382
|
+
non-opportunity lists fall through to the legacy sprint handling in `Clickup.php`.
|
|
382
383
|
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
`
|
|
387
|
-
|
|
384
|
+
**CU→NS is UPDATE-ONLY** (never create/delete):
|
|
385
|
+
1. Resolve the linked NS opportunity from the task's `Opportunity #` custom field
|
|
386
|
+
(`a5529cdc-8dbb-4165-8bc5-9f5cc691796a`, = NS `tranId`) via a one-row SuiteQL
|
|
387
|
+
`SELECT id FROM transaction WHERE type='Opprtnty' AND tranid='<n>'`.
|
|
388
|
+
2. GET the opportunity with `expandSubResources=true` — this also supplies the **current** NS values
|
|
389
|
+
for the diff.
|
|
390
|
+
3. **No match → skip.**
|
|
391
|
+
4. PATCH **only** the NS-owned fields that actually differ (currently `title`, `stage`). A no-op write
|
|
392
|
+
fires no NS event, so the write cannot re-trigger NS→CU. **Echo prevention is the difference-check
|
|
393
|
+
alone** — there is no actor-identity / "last-synced-from" suppression field (that hardening was
|
|
394
|
+
explicitly deferred; see Decisions).
|
|
395
|
+
|
|
396
|
+
**Shared `STAGE_MAP` — single source of truth, declared on `_Worker_Netsuite_Opportunity`.** Maps NS
|
|
397
|
+
`entityStatus` internalId → ClickUp status on the Presales Qualification list (VERIFIED live 2026-06-30):
|
|
398
|
+
|
|
399
|
+
| NS internalId | NS stage | ClickUp status |
|
|
400
|
+
|---|---|---|
|
|
401
|
+
| 124 | Closed - Won | `opportunity won` |
|
|
402
|
+
| 14 | Closed Lost | `closed lost` |
|
|
403
|
+
| 125/126/122/123/128 | 10% / 25% / 50% / 75% / 95% | `in progress` |
|
|
404
|
+
| 130 | Alternate Quote | *(unmapped)* |
|
|
405
|
+
|
|
406
|
+
**CRITICAL non-1:1 gotcha:** the five probability stages all collapse to `in progress`, so the reverse
|
|
407
|
+
map `CU_STATUS_TO_NS_STAGE` only covers the **unambiguous terminal** stages (won/lost). CU→NS leaves a
|
|
408
|
+
non-terminal status alone rather than guessing a percentage. The percentage-reverse mapping is an
|
|
409
|
+
**unresolved stakeholder (Aaron) decision** — do not invent one.
|
|
410
|
+
|
|
411
|
+
**NS→CU now drives the ClickUp task STATUS** from the NS stage via `STAGE_MAP` (previously the stage was
|
|
412
|
+
only rendered in the description text). Applied on both create and update; the update PUT carries
|
|
413
|
+
**only** the changed fields (`name`/`description`/`status`) to stay echo-safe.
|
|
414
|
+
|
|
415
|
+
**Outbound NS calls** use the `Logs.Api` two-save pattern (`_Model_Core_Logs_Api`, `DB_LOGS`,
|
|
416
|
+
`DIRECTION_OUT`: save the request row, then save the response/failure row), with
|
|
417
|
+
`\Sentry\captureException($e); throw $e;` on failure so `WorkerJobs` records `isSuccess=0`. Logged
|
|
418
|
+
payloads are length-capped. NS access is the **2.0 adapter `_Component_Api_Netsuite`** (GET/PATCH +
|
|
419
|
+
SuiteQL POST with `Prefer: transient`); the **1.0 `App_Api_Netsuite_Rest` / SOAP toolkit are
|
|
420
|
+
deprecated** for production opportunity code.
|
|
421
|
+
|
|
422
|
+
### CU→NS gotchas
|
|
423
|
+
|
|
424
|
+
- **SuiteQL over `_Component_Api_Netsuite` has NO placeholder binding.** When a SuiteQL filter value
|
|
425
|
+
comes from an **external** source (here a ClickUp custom-field `tranId`, not a local PK), sanitize
|
|
426
|
+
with an **allowlist-and-REJECT-on-mismatch**, not strip-and-use:
|
|
427
|
+
`$safe = preg_replace('/[^A-Za-z0-9_-]/','',$v); if ($safe!==$v) return null;`. Reject-on-mismatch is
|
|
428
|
+
the firewall; silently stripping characters is not enough.
|
|
429
|
+
- **Validate `task_id` format at the webhook entry.** ClickUp native task ids are alphanumeric. The
|
|
430
|
+
legacy `_Worker_Clickup::Webhook` interpolates the raw `$payload->task_id` into Team-DB SQL at ~20
|
|
431
|
+
sites (a **pre-existing** SQL-injection exposure, NOT introduced here). A single input-boundary
|
|
432
|
+
format guard at the top of `Webhook()` closes that class for the raw payload id without rewriting
|
|
433
|
+
each query. **FLAG:** the broader legacy `$taskId`/`$customTaskId` raw-SQL interpolation throughout
|
|
434
|
+
`Clickup.php` is real pre-existing debt that warrants a dedicated security ticket.
|
|
435
|
+
- **ClickUp opportunity field ids:** list `901111987449` ("Presales Qualification");
|
|
436
|
+
`Opportunity #` = `a5529cdc-8dbb-4165-8bc5-9f5cc691796a`,
|
|
437
|
+
`Customer #` = `170dc118-b3fa-413b-826b-2753e9405851`.
|
|
438
|
+
|
|
439
|
+
### Deferred (documented follow-up)
|
|
440
|
+
|
|
441
|
+
- **Notes & attachments (CU→NS)** were intentionally deferred — they require NetSuite custom fields
|
|
442
|
+
(`custentity_cu_synced_notes` / `custentity_cu_synced_files`) + a File Cabinet folder that **do not
|
|
443
|
+
exist yet**, plus unconfirmed NS REST note/file shapes. Field + stage sync is bidirectional;
|
|
444
|
+
notes/attachments remain a documented follow-up.
|
|
445
|
+
- **Actor-identity echo hardening** deferred — the difference-check is the current (and sufficient for
|
|
446
|
+
field/stage) echo-prevention mechanism. A "drop events authored by our own integration user" gate is
|
|
447
|
+
the planned hardening if/when no-op-safe writes can't fully cover a future field.
|
|
448
|
+
- **Percentage reverse-mapping** (which NS percentage stage a CU `in progress` should map back to) is
|
|
449
|
+
blocked on the Aaron stakeholder decision noted above.
|
|
388
450
|
|
|
389
451
|
## Change history
|
|
452
|
+
- 2026-06-30 — **Built the CU→NS reverse sync + NS→CU stage drive (TRUE-79181) — integration now
|
|
453
|
+
bidirectional for field + stage.** New `_Worker_Clickup_Opportunity` handler
|
|
454
|
+
(`worker2/Worker/Clickup/Opportunity.php`), dispatched by `_Worker_Clickup::Webhook` via
|
|
455
|
+
`_Worker::runTask('Clickup/Opportunity/Process', …)`, gated by ClickUp **list id `901111987449`** for
|
|
456
|
+
`taskUpdated`/`taskStatusUpdated`. CU→NS is **update-only**: resolve the linked opp by the task's
|
|
457
|
+
`Opportunity #` (= `tranId`) via one-row SuiteQL, GET it (`expandSubResources=true`) for the current
|
|
458
|
+
values, PATCH only the differing NS-owned fields (`title`, `stage`). **Echo prevention = difference-check
|
|
459
|
+
only** (actor-identity hardening explicitly deferred). Added a shared `STAGE_MAP` on
|
|
460
|
+
`_Worker_Netsuite_Opportunity` (NS entityStatus internalId → ClickUp status; VERIFIED live: 124→won,
|
|
461
|
+
14→lost, 125/126/122/123/128→`in progress`, 130 unmapped) — its **five-probability-stages-collapse-to-
|
|
462
|
+
one** non-1:1 means the reverse map covers only the terminal stages; the percentage reverse-mapping is
|
|
463
|
+
an open Aaron decision. NS→CU now also drives the ClickUp task **status** from the stage (was
|
|
464
|
+
description-text only), PUT carrying only changed fields to stay echo-safe. Outbound NS calls use the
|
|
465
|
+
`Logs.Api` two-save OUT pattern + `Sentry::captureException;throw` on failure; NS access via the 2.0
|
|
466
|
+
`_Component_Api_Netsuite` adapter (1.0 `App_Api_Netsuite_Rest`/SOAP deprecated for prod). Gotchas
|
|
467
|
+
recorded: **SuiteQL has no placeholder binding** → external filter values need allowlist-**reject-on-
|
|
468
|
+
mismatch** sanitize; **validate `task_id` format at the webhook boundary** (the legacy `Clickup.php`
|
|
469
|
+
interpolates the raw payload id into Team-DB SQL at ~20 sites — pre-existing injection debt flagged for
|
|
470
|
+
a dedicated security ticket). Notes/attachments CU→NS deferred (need NetSuite custom fields +
|
|
471
|
+
File-Cabinet folder that don't exist yet). (dfranks)
|
|
390
472
|
- 2026-06-25 — **Fixed the Opportunity duplicate-row amplification + added a unique-index guard.**
|
|
391
473
|
Root cause: `_Model::load()` returns TRUE only on an **exactly-one** match (FALSE for 0 *and* 2+),
|
|
392
474
|
so once an nsId had 2 rows the `load()`-then-upsert handler could never re-find it and every
|
package/package.json
CHANGED