toga-ai 1.0.614 → 1.0.616
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/2.0/apps/toga2-commerce/INDEX.md +2 -0
- package/knowledge/2.0/apps/toga2-commerce/features/cart-order-total-computation.md +57 -0
- package/knowledge/2.0/apps/toga2-commerce/features/expedited-shipping-gating.md +6 -2
- package/knowledge/2.0/apps/toga2-commerce/features/shipping-cost-waiver-gating.md +78 -0
- package/knowledge/INDEX.md +1 -1
- package/knowledge/sessions/2026-08-17-Department-Revert-sking.md +101 -0
- package/package.json +1 -1
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
| [TOGa Commerce (toga2-commerce / commerce2-react) Architecture](architecture.md) | `toga2-commerce` (npm package name **`commerce2-react`**, product name **TOGa Commerce**) is the customer-facing **B2B commerce storefront** of the 2.0 platform | src/main.tsx, src/App.tsx, src/routes.tsx, src/contexts/AuthContext.tsx, src/contexts/helpers/getLoginSettings.ts, src/api/axiosInstance.ts, src/stores/, src/themeConfig/ThemeContext.tsx, src/fieldsConfig/index.ts, src/hooks/useAssignClientFields.ts, vite.config.ts, package.json |
|
|
6
6
|
| [Cart Bundle Submission & the bundleUuid Identity Contract](features/cart-bundle-submission-and-identity.md) | How cart **bundles** (kits) are turned into `SalesOrderItems` when a cart is submitted or an existing order is edited, and the **identity-field contract** every | src/api/syncSalesOrderItemsFromLocalStorageCartToApi.ts, src/utils/formatSalesOrderBundlesFromApi.ts, src/stores/useCartStoreZu.ts, src/pages/OrderDetails/helpers/formatSalesOrderDataFromLocalStorage.ts, src/pages/OrderDetails/view/components/OrderItems.tsx |
|
|
7
7
|
| [Cart Notification Emails — duplicate prevention](features/cart-notification-emails.md) | On the cart "Notifications" section a user can add CC email addresses to an order. | src/pages/Cart/CartPage.tsx, src/pages/Cart/view/cartForm/CartForm.tsx, src/stores/useEmailOptionsStore.ts, src/stores/useCartSalesQuoteZu.ts, src/pages/Cart/viewModel/FIELDS/*/*/*/CARTPAGE.ts |
|
|
8
|
+
| [Cart Order-Total & Shipping Computation](features/cart-order-total-computation.md) | The Cart summary section (subtotal / shipping / tax / total) is **data-driven** from `cartData`. | toga2-commerce/src/pages/Cart/viewModel/useCartViewModel.ts, toga2-commerce/src/pages/Cart/CartPage.tsx, toga2-commerce/src/pages/Cart/view/cartForm/CartForm.tsx, toga2-commerce/src/pages/Cart/helpers/shippingOptionGates.ts, toga2-commerce/src/pages/Cart/api/CartApi.ts |
|
|
8
9
|
| [Cart Page — config-driven form architecture (current state + planned refactor)](features/cart-page-config-architecture.md) | The Cart page (`src/pages/Cart/`) is the most config-heavy page in `toga2-commerce`. | src/pages/Cart/CartPage.tsx, src/pages/Cart/view/cartForm/CartForm.tsx, src/pages/Cart/view/cartForm/CartFormSection.tsx, src/pages/Cart/view/cartForm/CartFormRenderer.tsx, src/pages/Cart/view/EditCart.tsx, src/pages/Cart/view/EditOrder.tsx, src/pages/Cart/viewModel/useEditOrderOrEditCartViewModel.ts, src/pages/Cart/viewModel/FIELDS/*/*/*/CARTPAGE.ts, src/hooks/useAssignClientFields.ts |
|
|
9
10
|
| [Catalog cache freshness — the 24h persisted query cache, and how to opt a query out of it](features/catalog-cache-freshness.md) | TOGa Commerce runs a **single `QueryClient` with a 24-hour default `staleTime`**, and persists it to **`localStorage["commerce"]`** through `PersistQueryClientP | toga2-commerce/src/App.tsx, toga2-commerce/src/contexts/AuthContext.tsx, toga2-commerce/src/pages/ItemsView/viewModel/useItemDetailsViewModel.ts |
|
|
10
11
|
| [Category Tile Order (AssortmentItems.sortOrder) — merchandising a storefront category](features/category-tile-sort-order.md) | **"Move item X to the front of category Y" is a DATA change, not a code change.** The order of item tiles on a storefront category page is driven by exactly one | src/pages/Filter/api/FilterApi.ts, src/pages/Filter/viewModel/useFilterViewModel.ts, api2/Component/Api/V2/V2.php, toga2-supply/src/pages/Items/api/itemsApi.ts |
|
|
@@ -14,6 +15,7 @@
|
|
|
14
15
|
| [Inactive-item purchase gating (standalone lines only — kits are exempt by design)](features/inactive-item-purchase-gating.md) | An item with **`Items.isActive = 0`** must not be viewable, addable to a cart, or orderable **as a standalone line** on the storefront — but the **same flag is | toga2-commerce/src/utils/checkIsItemPurchasable.ts, toga2-commerce/src/api/fetchItemsActiveStatus.ts, toga2-commerce/src/hooks/useCartReconciliation.ts, toga2-commerce/src/stores/useCartStoreZu.ts, toga2-commerce/src/pages/ItemsView/api/ItemsApi.ts, toga2-commerce/src/pages/ItemsView/viewModel/useItemDetailsViewModel.ts, toga2-commerce/src/pages/ItemsView/ItemViewPage.tsx, toga2-commerce/src/components/AuthLayout/AuthLayout.tsx, toga2-commerce/src/pages/Cart/CartPage.tsx, toga2-commerce/src/pages/Cart/view/cartTable/CartTableBundleItem.tsx, toga2-commerce/src/api/syncSalesOrderFromApiToLocalStorage.ts, _underscore/Model/Compass/SalesOrder.php, dbchanges2/Client_Compass/2026-08-14a - AddSalesOrderInactiveStandaloneItemPreInterceptors.sql, dbchanges2/Client_CompassCanada/2026-08-14a - AddSalesOrderInactiveStandaloneItemPreInterceptors.sql |
|
|
15
16
|
| [Multi-Tenant Resolution & Theming](features/multi-tenant-theming.md) | `toga2-commerce` serves multiple clients from one codebase. | src/themeConfig/themes.json, src/themeConfig/ThemeContext.tsx, src/themeConfig/types.ts, src/components/ThemeSwitcher/ThemeSwitcher.tsx, src/components/AuthLayout/AuthLayout.tsx, src/api/axiosInstance.ts, src/contexts/AuthContext.tsx, tailwind.config.js |
|
|
16
17
|
| [Order-submit sync sequencing (useSubmitOrder) — why these calls must not run in parallel](features/order-submit-sync-sequencing.md) | Submitting an order from the cart fires **two independent sync routines** — one for the sales-order header (`syncSalesOrderData`) and one for the line items (`s | toga2-commerce/src/pages/OrderDetails/hooks/useSubmitOrder.ts, toga2-commerce/src/api/syncSalesOrdersDataFromLocalStorageCartToApi.ts, toga2-commerce/src/api/syncSalesOrderItemsFromLocalStorageCartToApi.ts |
|
|
18
|
+
| [Config-Driven Shipping Cost Waiver (Standard Ground free for computer kits)](features/shipping-cost-waiver-gating.md) | On the toga2-commerce **Cart** page, a shipping option's **cost** can be waived by config using the same `PrimaryItemShippingRule` vocabulary that drives expedi | toga2-commerce/src/pages/Cart/helpers/shippingOptionGates.ts, toga2-commerce/src/pages/Cart/viewModel/FIELDS/shared/shippingOptionGates.ts, toga2-commerce/src/pages/Cart/CartPage.tsx |
|
|
17
19
|
| [AWS Amplify Build & Deploy (non-prod environments)](workflows/amplify-build-and-deploy.md) | How `toga2-commerce` (React + Vite, "commerce2-react") builds and deploys on **AWS Amplify**. | toga2-commerce/amplify.yml, toga2-commerce/.gitattributes, toga2-commerce/package.json, toga2-commerce/.github/workflows/sync-stage-environments.yml |
|
|
18
20
|
| [Cart e2e — Cypress conventions & harness (toga2-commerce)](workflows/cypress-testing.md) | The Cypress **e2e** convention set for `toga2-commerce`, and the first **active** e2e coverage for the **Cart** page (`cartV2.cy.ts`, slice 1 — 12 tests, verifi | toga2-commerce/cypress/e2e/cartPage/cartV2.cy.ts, toga2-commerce/cypress/fixtures/cart/fetchSingleUserAdmin.json, toga2-commerce/cypress/fixtures/cart/fetchLocations.json, toga2-commerce/cypress/fixtures/cart/fetchUserShippingMethods.json, toga2-commerce/cypress/support/commands.ts, toga2-commerce/cypress/support/e2e.ts, toga2-commerce/src/pages/Cart/CartPage.tsx, toga2-commerce/src/pages/Cart/view/cartForm/CartForm.tsx, toga2-commerce/src/pages/Cart/view/cartForm/CartFormSection.tsx, toga2-commerce/src/pages/Cart/view/cartTable/CartContentsTable.tsx, toga2-commerce/src/pages/Cart/view/cartTable/CartTableItem.tsx, toga2-commerce/src/components/Inputs/AdvancedInput.tsx, toga2-commerce/src/components/BaseButton/BaseButton.tsx |
|
|
19
21
|
| [Diagnosing ERR_HTTP2_PROTOCOL_ERROR (one client fails, everyone else is fine)](workflows/http2-protocol-error-diagnosis.md) | When a Chromium browser (Chrome / Edge) shows **`ERR_HTTP2_PROTOCOL_ERROR`** loading a `*.togacommerce.com` tenant for **one client/network but works for the TO | |
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Cart Order-Total & Shipping Computation
|
|
3
|
+
framework: "2.0"
|
|
4
|
+
repo: toga2-commerce
|
|
5
|
+
project: TOGa Commerce
|
|
6
|
+
client: shared
|
|
7
|
+
type: feature
|
|
8
|
+
status: active
|
|
9
|
+
updated: 2026-08-19
|
|
10
|
+
owners: [apeterson]
|
|
11
|
+
files:
|
|
12
|
+
- toga2-commerce/src/pages/Cart/viewModel/useCartViewModel.ts
|
|
13
|
+
- toga2-commerce/src/pages/Cart/CartPage.tsx
|
|
14
|
+
- toga2-commerce/src/pages/Cart/view/cartForm/CartForm.tsx
|
|
15
|
+
- toga2-commerce/src/pages/Cart/helpers/shippingOptionGates.ts
|
|
16
|
+
- toga2-commerce/src/pages/Cart/api/CartApi.ts
|
|
17
|
+
related:
|
|
18
|
+
- 2.0/apps/toga2-commerce/features/shipping-cost-waiver-gating.md
|
|
19
|
+
- 2.0/apps/toga2-commerce/features/expedited-shipping-gating.md
|
|
20
|
+
- 2.0/apps/toga2-commerce/features/cart-page-config-architecture.md
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
## Summary
|
|
24
|
+
The Cart summary section (subtotal / shipping / tax / total) is **data-driven** from
|
|
25
|
+
`cartData`. `cartData.total` is computed in `useCartViewModel` as `subtotal + tax + shipping`,
|
|
26
|
+
where `shipping` is read from `salesOrder.shipping` in the zustand cart/sales-quote store —
|
|
27
|
+
**not** directly from the selected dropdown option. A `CartPage` effect is the bridge: it
|
|
28
|
+
watches the selected shipping method, looks up the matching fetched option, reads its `c_cost`,
|
|
29
|
+
applies waiver cost-gates, and calls `setShipping()` to write `salesOrder.shipping`.
|
|
30
|
+
|
|
31
|
+
## How it works
|
|
32
|
+
1. `CartForm.tsx` renders the summary purely from `cartData` (subtotal/shipping/tax/total).
|
|
33
|
+
2. `useCartViewModel.ts` computes `cartData.total = subtotal + tax + shipping`, with `shipping`
|
|
34
|
+
derived from `salesOrder.shipping` (zustand store).
|
|
35
|
+
3. `CartPage.tsx` has an effect watching the selected `shippingMethod`: it finds the matching
|
|
36
|
+
fetched option, reads `c_cost`, applies waiver cost-gates (see
|
|
37
|
+
[shipping-cost-waiver-gating](shipping-cost-waiver-gating.md)), then `setShipping()` writes
|
|
38
|
+
`salesOrder.shipping`. So the dropdown never feeds the total directly — it flows through the
|
|
39
|
+
store.
|
|
40
|
+
|
|
41
|
+
## Gotchas
|
|
42
|
+
- **`c_cost` is ACL-gated per tenant, and a missing grant silently zeroes shipping.** Only the
|
|
43
|
+
**Compass USA** CARTPAGE configs request fields `["uuid","name","c_cost"]` in
|
|
44
|
+
`fetchShippingMethod`. **Compass Canada** and **Quad** request only `["uuid","name"]`, so
|
|
45
|
+
their fetched options carry no `c_cost`; `Number(undefined) → NaN → "0"`, and shipping falls
|
|
46
|
+
back to `"0"` with no error. This is a **latent gap** for those tenants — if they are meant to
|
|
47
|
+
charge shipping, their CARTPAGE `fetchShippingMethod` field list must include `c_cost`.
|
|
48
|
+
- **Shipping lives in the store, not the dropdown.** Reading the selected option's cost alone
|
|
49
|
+
will not tell you the order total — always trace through `setShipping()` →
|
|
50
|
+
`salesOrder.shipping` → `useCartViewModel`.
|
|
51
|
+
|
|
52
|
+
## Change history
|
|
53
|
+
- 2026-08-19 — Documented the previously-undocumented cart total/shipping computation path
|
|
54
|
+
while fixing the Compass USA Standard Ground waiver. Recorded the `c_cost` ACL-gating latent
|
|
55
|
+
gap for Compass Canada and Quad (their fetched options have no `c_cost`, so shipping silently
|
|
56
|
+
resolves to "0"). (apeterson)
|
|
57
|
+
</content>
|
|
@@ -6,8 +6,8 @@ project: TOGa Commerce
|
|
|
6
6
|
client: shared
|
|
7
7
|
type: feature
|
|
8
8
|
status: active
|
|
9
|
-
updated: 2026-
|
|
10
|
-
owners: [tcox]
|
|
9
|
+
updated: 2026-08-19
|
|
10
|
+
owners: [tcox, apeterson]
|
|
11
11
|
files:
|
|
12
12
|
- toga2-commerce/src/pages/Cart/helpers/shippingOptionGates.ts
|
|
13
13
|
- toga2-commerce/src/pages/Cart/viewModel/FIELDS/shared/shippingOptionGates.ts
|
|
@@ -103,6 +103,10 @@ behavior is expressed in config, not in code branches.
|
|
|
103
103
|
on that doc's slice-2 work-list.
|
|
104
104
|
|
|
105
105
|
## Change history
|
|
106
|
+
- 2026-08-19 — No change to expedited visibility. Noted that the shared shipping-rule type
|
|
107
|
+
gained an optional `requireNoOtherItemsInKit` flag (+ `bundleHasOtherItems()` helper) used by
|
|
108
|
+
the separate Standard Ground cost waiver — see
|
|
109
|
+
[shipping-cost-waiver-gating](shipping-cost-waiver-gating.md). (apeterson)
|
|
106
110
|
- 2026-07-27 — No behavior change. Noted that a cart Cypress e2e harness now exists and a
|
|
107
111
|
gating + guardrail-modal spec is feasible (the `cart/fetchUserShippingMethods.json` fixture
|
|
108
112
|
already ships the gated names with no `c_cost`); it is on the slice-2 work-list. Linked
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Config-Driven Shipping Cost Waiver (Standard Ground free for computer kits)
|
|
3
|
+
framework: "2.0"
|
|
4
|
+
repo: toga2-commerce
|
|
5
|
+
project: TOGa Commerce
|
|
6
|
+
client: shared
|
|
7
|
+
type: feature
|
|
8
|
+
status: active
|
|
9
|
+
updated: 2026-08-19
|
|
10
|
+
owners: [apeterson]
|
|
11
|
+
files:
|
|
12
|
+
- toga2-commerce/src/pages/Cart/helpers/shippingOptionGates.ts
|
|
13
|
+
- toga2-commerce/src/pages/Cart/viewModel/FIELDS/shared/shippingOptionGates.ts
|
|
14
|
+
- toga2-commerce/src/pages/Cart/CartPage.tsx
|
|
15
|
+
related:
|
|
16
|
+
- 2.0/apps/toga2-commerce/features/expedited-shipping-gating.md
|
|
17
|
+
- 2.0/apps/toga2-commerce/features/cart-order-total-computation.md
|
|
18
|
+
- ../../../../clients/compass-usa/profile.md
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
## Summary
|
|
22
|
+
On the toga2-commerce **Cart** page, a shipping option's **cost** can be waived by config
|
|
23
|
+
using the same `PrimaryItemShippingRule` vocabulary that drives expedited-option visibility.
|
|
24
|
+
The **Compass USA** carts attach a cost gate `STANDARD_GROUND_FREE_FOR_COMPUTER_KITS` to the
|
|
25
|
+
*Shipping Method* field so that **Standard Ground** ships free for a bare computer kit. The
|
|
26
|
+
waiver is config-driven (no `if (clientSlug === ...)` branching) and is referenced only by the
|
|
27
|
+
four Compass USA role configs (USER/ADMIN/MANAGER/SUPERUSER).
|
|
28
|
+
|
|
29
|
+
## Key files / entry points
|
|
30
|
+
- `src/pages/Cart/helpers/shippingOptionGates.ts` — `PrimaryItemShippingRule` type,
|
|
31
|
+
`evaluateShippingOptionRule`, and the `bundleHasOtherItems()` helper.
|
|
32
|
+
- `src/pages/Cart/viewModel/FIELDS/shared/shippingOptionGates.ts` — the
|
|
33
|
+
`STANDARD_GROUND_FREE_FOR_COMPUTER_KITS` gate constant (primary item
|
|
34
|
+
`itemCategory.name === "COMPUTERS"`, with `requireNoOtherItemsInKit: true`).
|
|
35
|
+
- `src/pages/Cart/CartPage.tsx` — the effect that reads the selected option's `c_cost` and
|
|
36
|
+
applies waiver cost-gates before calling `setShipping()` (see
|
|
37
|
+
[cart-order-total-computation](cart-order-total-computation.md)).
|
|
38
|
+
|
|
39
|
+
## How it works
|
|
40
|
+
1. A cost gate matches a bundle when its **primary item** (`bundleItemGroup.slug === "primary"`)
|
|
41
|
+
satisfies the `{ item, path, operator, value }` rule — here
|
|
42
|
+
`item.itemCategory.name === "COMPUTERS"`.
|
|
43
|
+
2. The **optional `requireNoOtherItemsInKit` flag** on `PrimaryItemShippingRule` narrows the
|
|
44
|
+
match: when set, the bundle qualifies only if the primary item matches **and** the kit has
|
|
45
|
+
no other items. `bundleHasOtherItems()` returns true when `bundleProgressContents` contains
|
|
46
|
+
any entry whose `bundleItemGroup.slug !== "primary"` (fees are already excluded from
|
|
47
|
+
`bundleProgressContents` upstream).
|
|
48
|
+
3. `evaluateShippingOptionRule` qualifies a bundle only when the primary item matches AND, if
|
|
49
|
+
`requireNoOtherItemsInKit` is set, the kit is bare. Existing cart-wide "any qualifying
|
|
50
|
+
bundle" semantics for multi-bundle carts are preserved.
|
|
51
|
+
4. When the selected method qualifies, the effect in `CartPage` waives its `c_cost` to 0 before
|
|
52
|
+
writing `salesOrder.shipping`.
|
|
53
|
+
|
|
54
|
+
Resulting Compass USA behavior: bare computer kit (primary computer only) → Standard Ground
|
|
55
|
+
free; computer kit **with** other items → charged $10; all non-computer kits → charged;
|
|
56
|
+
expedited methods unchanged (still priced).
|
|
57
|
+
|
|
58
|
+
## Gotchas
|
|
59
|
+
- **The waiver only means what the flag says.** Before this fix the gate had no
|
|
60
|
+
`requireNoOtherItemsInKit`, so Standard Ground (the default selection) was waived for **any**
|
|
61
|
+
cart whose primary bundle item was in `COMPUTERS` — computer kits loaded at $0 shipping and
|
|
62
|
+
stayed there regardless of what else was in the kit. Always set `requireNoOtherItemsInKit`
|
|
63
|
+
when a waiver is meant only for a bare kit.
|
|
64
|
+
- **`bundleProgressContents` is the source of truth for "other items"** — it already excludes
|
|
65
|
+
fees, so a fee line does not make a kit count as "having other items." If that upstream
|
|
66
|
+
exclusion ever changes, the waiver's narrowing changes with it.
|
|
67
|
+
- **Waiver correctness depends on `COMPUTERS` categorization**, same as the expedited gate —
|
|
68
|
+
a computer kit mis-categorized in the catalog will neither waive nor gate.
|
|
69
|
+
|
|
70
|
+
## Change history
|
|
71
|
+
- 2026-08-19 — Fixed an over-broad Standard Ground waiver for Compass USA computer-kit carts:
|
|
72
|
+
added the optional `requireNoOtherItemsInKit` flag on `PrimaryItemShippingRule` and the
|
|
73
|
+
`bundleHasOtherItems()` helper, and set the flag on
|
|
74
|
+
`STANDARD_GROUND_FREE_FOR_COMPUTER_KITS`. Bare computer kits now ship free; kits with other
|
|
75
|
+
items are charged. Config-driven, Compass USA only, no client branching. No test harness in
|
|
76
|
+
the repo (no vitest/jest); verified by review. (apeterson)
|
|
77
|
+
</content>
|
|
78
|
+
</invoke>
|
package/knowledge/INDEX.md
CHANGED
|
@@ -29,7 +29,7 @@ _Auto-generated by `knowledge.js index`. Do not hand-edit._
|
|
|
29
29
|
- **talos** (TOGa IQ) — 7 doc(s) → [2.0/apps/talos/INDEX.md](2.0/apps/talos/INDEX.md)
|
|
30
30
|
- **voice-to-voice** (TOGa Voice) — 4 doc(s) → [2.0/apps/voice-to-voice/INDEX.md](2.0/apps/voice-to-voice/INDEX.md)
|
|
31
31
|
- **ai-bdr** (AI-BDR) — 9 doc(s) → [2.0/apps/ai-bdr/INDEX.md](2.0/apps/ai-bdr/INDEX.md)
|
|
32
|
-
- **toga2-commerce** (TOGa Commerce) —
|
|
32
|
+
- **toga2-commerce** (TOGa Commerce) — 19 doc(s) → [2.0/apps/toga2-commerce/INDEX.md](2.0/apps/toga2-commerce/INDEX.md)
|
|
33
33
|
- **toga25-supply** (TOGa 2.5 Supply) — 11 doc(s) → [2.0/apps/toga25-supply/INDEX.md](2.0/apps/toga25-supply/INDEX.md)
|
|
34
34
|
- **toga-blox** (TOGa Blox) — 9 doc(s) → [2.0/apps/toga-blox/INDEX.md](2.0/apps/toga-blox/INDEX.md)
|
|
35
35
|
- **bdr** (BDR) — 0 doc(s) → [2.0/apps/bdr/INDEX.md](2.0/apps/bdr/INDEX.md)
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
---
|
|
2
|
+
type: session
|
|
3
|
+
slug: Department-Revert
|
|
4
|
+
title: NYC DOE ticket department and technician revert loop
|
|
5
|
+
author: sking
|
|
6
|
+
repos: [worker, library, dbchanges]
|
|
7
|
+
framework: "1.0"
|
|
8
|
+
client: nycdoe
|
|
9
|
+
status: active
|
|
10
|
+
created: 2026-08-17
|
|
11
|
+
updated: 2026-08-17
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
# Session: Department-Revert
|
|
15
|
+
**Date:** 2026-08-17
|
|
16
|
+
**Project/Repo:** worker + library + dbchanges (1.0)
|
|
17
|
+
**Task:** Root-cause why NYC DOE repair order 838745 (ServiceNow INC2234900) kept reverting its assigned technician and department in both TogaDesk and ServiceNow, and prevent it for all tickets.
|
|
18
|
+
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
## What WORKED
|
|
22
|
+
|
|
23
|
+
- **Root cause found via production DB forensics, not code reading.** `repair_order_history` for repair 838745 showed the *same* correction applied four times: `Department: DOE Calls Borough -> DOE Manhattan Borough` plus `Technician unassigned: CJackson64@schools.nyc.gov`. The department can only move *from* Calls Borough repeatedly if something silently reset it *to* Calls Borough in between — and that something wrote no history row. This framing is what cracked it.
|
|
24
|
+
- **Three defects identified in `worker/crons/sync/nycdoe/process_tickets.php`** (inbound cron, `*/15`, registered `worker/schedules/cron.worker.sync.json:606`):
|
|
25
|
+
1. Department overwritten unconditionally on every inbound update, with no comparison to local state — unlike status, which already had an anti-downgrade rank guard directly below it.
|
|
26
|
+
2. `$ticketDepartment` never reset between `do/while` iterations, so a ticket with an empty / unmapped / `SD QA` `assignment_group` silently inherited the **previous ticket's** department *and* `contractId`.
|
|
27
|
+
3. Technicians resolved against `people.name` while ServiceNow sends an **email** (`u_technician`). The lookup could therefore only match a row whose *name* was an email — an orphan the cron itself auto-created. It only ever INSERTed, never removed, and used `fetchOne()` where it needed every technician on the order.
|
|
28
|
+
- **Newest-edit-wins guard verified against live data.** SQL returned `guard_allows_overwrite = 0` for 838745 (local `dtUpdated` epoch 1786558258 vs SNOW `sys_updated_on` 1786541919 — local ~4.5h newer). Post-deploy re-check: `ticketDepartmentId = 60` held, and **0** department history rows since 2026-08-13 (it had been flipping ~daily).
|
|
29
|
+
- **Timezone assumption verified before relying on it.** DB session and global `time_zone` are `US/Central`; framework `_.php` forces `America/Chicago`. Same zone, so the epoch comparison is valid. A mismatch here would have made the guard wrong by hours.
|
|
30
|
+
- **Loop-reset contract validated by reimplementation** — pre-fix logic produces `[60, 60]` across two tickets, post-fix produces `[60, null]`. Confirms the cross-ticket contamination was real, not theoretical.
|
|
31
|
+
- **Closed-status prefix matching validated against every real status.** `WORK_IN_PROGRESS_DIAGNOSTIC_COMPLETE` contains "COMPLETE" but `LIKE 'COMPLETE%'` correctly excludes it. A substring match would have frozen 551 in-progress orders. Now pinned by a test.
|
|
32
|
+
- **Orphan scale measured:** 105 orphan `people` rows (name IS an email, no email, no sys_id); 394 assigned technicians with no `referenceId`; 161 resolvable; 58 missing email. Reference case: people **56271** (`CJackson64@schools.nyc.gov`, 190 orders) vs real Calvin Jackson **9781** (`cjackson@togatech.com`, sys_id `775fafea…d43ed`, 799 orders), with duplicate **10225** sharing that sys_id.
|
|
33
|
+
- **Phase 1 + 2 shipped and merged:** dbchanges#264, library#861, worker#1696. Migration has been RUN — `people.serviceNowEmail` exists (varchar, nullable, `MUL`), `referenceId` now indexed, `email` still `UNI` and untouched, and the migration's own acceptance criterion met exactly: **0 populated of 54,528 rows**.
|
|
34
|
+
- **Orphan spread has stopped.** Newest order carrying an orphan is `2026-08-13 08:45:03`; totals fell 10,111 -> 9,934 orders and 1,024 -> 822 open assignments. Remaining orphan links are leftover state, not active damage.
|
|
35
|
+
- **Schema-guard PRs opened:** library#863 (`App_Database::columnExists()`) and worker#1697 (both crons degrade to `people.email`).
|
|
36
|
+
|
|
37
|
+
## What did NOT work — DO NOT RETRY THESE
|
|
38
|
+
|
|
39
|
+
- **`php -l` cannot be run on this machine.** `which php` -> `php not found`; no `docker` either. Every PHP file in this work is **syntax-unverified**. Do not claim lint passed. Structural brace/paren balance checks were used as a weak substitute.
|
|
40
|
+
- **Naive brace-balance checking produces false positives.** `library/app/api/nycdoev2.php` reports `curly=1` and `library/app/database.php` reports `paren=1` — both are **pre-existing at HEAD**, artifacts of the comment/string stripper, not real imbalances. Always diff the imbalance against `git show HEAD:<file>` before believing it.
|
|
41
|
+
- **Grouping a classification `CASE` by its alias returns duplicate group labels.** The first orphan-classification query returned `MISSING_REFERENCE_ID` twice with different counts and zero `ORPHAN_EMAIL_AS_NAME`, because the `repair_order_technicians` join fans out per assignment. Fix: dedupe per `people.id` in a derived table first, then aggregate. Correct numbers only came out after that.
|
|
42
|
+
- **`git checkout -b <x> origin/_main` fails: `fatal: couldn't find remote ref _main`.** These repos use **`_production`** as the default branch, not `_main`, despite the team git-workflow rule naming `_main`. `origin/HEAD -> origin/_production`.
|
|
43
|
+
- **Branching from a stale `origin/_production` caused a conflicted stash pop** (`UU` on both cron files). `git show origin/_production:…| grep -c serviceNowEmail` returned `0` even though worker#1696 was merged. **Always `git fetch origin` before cutting a branch off a recently merged base.** Recovery was `git reset --hard origin/_production` + re-apply (stash preserved throughout).
|
|
44
|
+
- **`COALESCE(serviceNowEmail, email)` was the wrong shape** — it makes the two addresses mutually exclusive, so a technician resolvable today could stop resolving. Replaced with a two-pass build that indexes BOTH keys, ServiceNow address winning on collision.
|
|
45
|
+
- **Wrong claim, corrected: "zero technicians have a schools.nyc.gov address."** 14,162 `people` rows DO have one in `email` — they are DOE **requesters**, not technicians. Among *assigned technicians*: 0 in `email`, 41 in `name`. Always state which column.
|
|
46
|
+
- **Wrong claim, corrected: "182 queued is safely below `MAX_TICKETS = 200`."** That guard tests `$res->num_rows > 200` against a `SELECT COUNT(...)` query, which always returns **1 row** — so it never fires at any queue depth. Dead code, not a safety valve.
|
|
47
|
+
- **A code deploy ahead of its migration takes the sync down silently.** worker#1696 merged **11:02 CDT**; last successful `process_tickets` run **11:01:36 CDT**. `Unknown column 'people.serviceNowEmail'` every 15 min for **2h34m**, 182 incidents queued. `import_inc` kept succeeding the whole time, so nothing looked broken from outside. Merging a dbchanges PR is NOT the same as running the migration.
|
|
48
|
+
|
|
49
|
+
## Not tried yet (candidates for next session)
|
|
50
|
+
|
|
51
|
+
- **Run `worker/crons/sync/nycdoe/backfill_technician_identities.php`** — dry run, review CSV, then `--apply`. Requires the worker host (PHP + ServiceNow credentials). Not runnable from a dev box.
|
|
52
|
+
- **History logging for cron-driven department/technician changes** — highest-leverage remaining mitigation; the absence of it is why this bug survived weeks.
|
|
53
|
+
- **Queue-depth alerting** on `NYCDOETickets.dtProcessed` staleness, and alerting on the new `unmatched u_technician email` / `no ServiceNow sys_id` log lines.
|
|
54
|
+
- **Fix the `MAX_TICKETS` `num_rows` bug** in `process_tickets.php`.
|
|
55
|
+
- **Model-level column guard** — `App_Model` builds SELECT/UPDATE from declared fields, so `App_Model_TogaDesk_People` would still break if `serviceNowEmail` were absent. `columnExists()` only guards raw SQL.
|
|
56
|
+
- **`referenceId` backfill** for the 132 technicians with no sys_id (outbound cannot name them to SNOW).
|
|
57
|
+
- **`togadesk/desk/includes/controllers/actions/central/assignDepartment.php:5-16`** — silently deletes ALL technicians whenever a department is set, with no history row.
|
|
58
|
+
- **`bulkEditOnsiteServiceTickets.php:51-53`** — calls `->save()` where it means `->delete()`, so bulk unassignment silently no-ops.
|
|
59
|
+
- **`togadesk/desk/includes/controllers/data/central/view.php`** — interpolates `$_GET['id']` raw into SQL at L22, L45, L105, L182, L343.
|
|
60
|
+
- **Rotate the ServiceNow OAuth credentials** hardcoded in plaintext at `library/app/api/nycdoev2.php:36-50` (client id / secret / refresh token for both stage and production). Pre-existing, committed, untouched by this work. Treat as compromised per the team security rule.
|
|
61
|
+
|
|
62
|
+
## Current file state
|
|
63
|
+
|
|
64
|
+
| File | Status | Notes |
|
|
65
|
+
|------|--------|-------|
|
|
66
|
+
| `worker/crons/sync/nycdoe/process_tickets.php` | MERGED (#1696) + PR #1697 | Newest-wins dept guard, per-tick state reset, email-based technician matching, no auto-create. #1697 adds the `columnExists` fallback. |
|
|
67
|
+
| `worker/crons/sync/nycdoe/send_ticket_updates.php` | MERGED (#1696) + PR #1697 | Compares against `serviceNowEmail` w/ `email` fallback; no longer blanks `u_technician` when sys_id missing (logged no-op). |
|
|
68
|
+
| `worker/crons/sync/nycdoe/report_technician_identities.php` | MERGED (#1696), NEVER RUN | Read-only classifier. Manual, unscheduled. |
|
|
69
|
+
| `worker/crons/sync/nycdoe/backfill_technician_identities.php` | MERGED (#1696), NEVER RUN | Dry-run default, `--apply` writes. Needs worker host. |
|
|
70
|
+
| `worker/crons/sync/nycdoe/test_assignment_revert_guard.php` | MERGED (#1696), NEVER RUN | Asserts A–F. Read-only, exits non-zero on failure. |
|
|
71
|
+
| `library/app/model/togadesk/people.php` | MERGED (#861) | Declares `$serviceNowEmail`. |
|
|
72
|
+
| `library/app/api/nycdoev2.php` | MERGED (#861) | Adds `getUserByEmail()`, mirroring the zero-caller `getUserSysIdByName()`. |
|
|
73
|
+
| `library/app/database.php` | PR #863 OPEN | Adds `App_Database::columnExists()`, request-cached. |
|
|
74
|
+
| `dbchanges/TOGaDeskSupport/SK/2026-08-12-add-people-servicenow-email.sql` | MERGED (#264) + **RUN in prod** | Column + 2 indexes. Verified: 0 populated of 54,528. |
|
|
75
|
+
|
|
76
|
+
Untouched and NOT mine, left alone deliberately: `library/app/api/carrier/ups.php` (uncommitted on `hotfix/nycdoe-duplicate-unit-creation`), `worker/crons/sync/nycdoe/test_po168215_receipt_resolution.php` (untracked).
|
|
77
|
+
|
|
78
|
+
## Decisions made
|
|
79
|
+
|
|
80
|
+
- **Newest-edit-wins** for department/technician conflict. Rejected "ServiceNow authoritative" (would require removing the fields from the TogaDesk UI or techs keep losing edits) and "TogaDesk authoritative" (drifts when DOE legitimately reroutes). Mirrors the existing status anti-downgrade guard. 60s clock-skew margin because the two timestamps come from different clocks.
|
|
81
|
+
- **Dedicated `people.serviceNowEmail` column.** Rejected overwriting `people.email` — it is UNIQUE, gates login/password-reset on `count(...) == 1` (a collision locks the account out), addresses ~10 notification send sites, and the AD sync **rewrites it every run** so the value would not persist. Rejected reusing `referenceId` (already overloaded: SNOW sys_id for DOE, TOGA2 contact UUID in `toga2.php`) and `ldap_user`.
|
|
82
|
+
- **Index BOTH email columns rather than `COALESCE`.** Guarantees no technician who resolves today stops resolving, before any backfill. Pinned by `test_technician_still_resolves_via_people_email_when_column_is_null()`.
|
|
83
|
+
- **Non-unique index** on `serviceNowEmail` — a UNIQUE index would break the AD sync insert path the same way `email` already does.
|
|
84
|
+
- **ServiceNow `sys_id` as the mapping source**, matched locally on `referenceId`. Rejected name/surname heuristics: `CJackson64` fits four real Jacksons (Calvin, Cecilia, Cari, Charles). Backfill refuses to act on ambiguity rather than guessing.
|
|
85
|
+
- **Repoint open orders only** (968 at planning, 822 now). Closed orders (9,143) keep their historical technician — already invoiced and signed off.
|
|
86
|
+
- **Deactivate orphans, never delete** — history rows still reference them.
|
|
87
|
+
- **Outbound no longer blanks `u_technician`** when a technician has no sys_id; that wipe is the outbound half of the same reversion bug. Two-line revert if reviewers disagree.
|
|
88
|
+
- **Guard degrades rather than fails fast** — falling back to `people.email` keeps the sync alive; failing fast would have produced the same 2h34m outage with a nicer message.
|
|
89
|
+
|
|
90
|
+
## Blockers
|
|
91
|
+
|
|
92
|
+
- **No PHP runtime on the dev machine.** `php -l` has never run against any file in this work, and none of the three new scripts have been executed. Stated explicitly in every PR.
|
|
93
|
+
- **Backfill cannot be run from a dev box** — needs the worker host with ServiceNow credentials.
|
|
94
|
+
- **library#863 must merge before worker#1697** (the worker code calls `App_Database::columnExists()`).
|
|
95
|
+
|
|
96
|
+
## Exact next step
|
|
97
|
+
|
|
98
|
+
> Merge **library#863** first, then **worker#1697**. Then on the **worker host** run `php crons/sync/nycdoe/backfill_technician_identities.php` (dry run — no `--apply`), read the CSV written to the cache folder as `<date>-nycdoe-technician-backfill-dryrun.csv`, and check the `NO_LOCAL_PERSON` and `AMBIGUOUS_LOCAL_MATCH` buckets — not just `RESOLVED` — before re-running with `--apply`.
|
|
99
|
+
|
|
100
|
+
---
|
|
101
|
+
_Saved by /session-save on 2026-08-17_
|
package/package.json
CHANGED