toga-ai 1.0.793 → 1.0.795

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: API
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-09-01
9
+ updated: 2026-09-10
10
10
  owners: ["mhammontree", "dfranks", "bala", "jcardinal"]
11
11
  files:
12
12
  - api2/Component/Api/V2/V2.php
@@ -21,6 +21,7 @@ related:
21
21
  - ./environment-variable-drives-underscore-branch.md
22
22
  - ../../_underscore/features/error-reporting-issue-event.md
23
23
  - ../../../../clients/aig/features/entitlement-intake.md
24
+ - ../../../../clients/prudential/features/service-request-auth-rejection-alert.md
24
25
  - ../../../../clients/compass-usa/features/mits-po-to-so-item-linking.md
25
26
  - ../../../../clients/compass-usa/workflows/order-lifecycle-and-data-integrity.md
26
27
  ---
@@ -53,6 +54,24 @@ In `api2/Component/Api/V2/V2.php` (~L2168) every request is logged and routed:
53
54
  - There is also a **config-gated branch** (~L2166): when `_Config::api('log_filepath')` is set,
54
55
  the log entry is written to a **file** on the host instead of the DB.
55
56
 
57
+ ### 401 lands in base `Logs`; 403 lands in `Logs_<Client>`
58
+
59
+ The pre-scope/post-scope split above has a sharp, practical edge on the two auth codes:
60
+
61
+ - **401** (`EN-1`..`EN-6`, e.g. an expired token) is decided in the auth chain in `V2.php`
62
+ (~L2088, inside `execute()`) **before routing**, so the client is not resolved and the row can
63
+ only go to the **shared base `Logs`**.
64
+ - **403** happens **after** the client IS known, so that row lands in **`Logs_<Client>`**.
65
+
66
+ Verified on Prudential for 2026-08-31 – 09-01: `Logs_Prudential.Api` held **56** rows for
67
+ `/v2/service-requests`, **all 201**, while every one of the **36 401s** sat in base `Logs`.
68
+
69
+ **A 401 therefore can never reach a model interceptor** — no `prePost`/`postPost` hook can alert on
70
+ it, because routing and the model are never reached. Anything that must react to refused inbound
71
+ traffic has to **scan the base log**, not hook the model. The caller is still identifiable: decode
72
+ the Bearer token in `requestHeaders` and read `id.client.uuid`. See
73
+ [Prudential refused-REQ alert](../../../../clients/prudential/features/service-request-auth-rejection-alert.md).
74
+
56
75
  ### A rejected request (HTTP 400) is NOT unlogged
57
76
 
58
77
  `api2/Controller/Index.php` (L369-377) handles a non-success response by **committing** the
@@ -359,6 +378,13 @@ The working pattern, in order:
359
378
  > a field its response echo omitted (see the [V2 query contract](v2-rest-query-contract.md) PUT-echo
360
379
  > gotcha).
361
380
 
381
+ > **What is actually indexed (checked with `SHOW INDEX`, 2026-09-10):** `dtStamp` **IS** indexed
382
+ > — `Api_dtStamp_IDX` — in **both** the base `Logs` (~3.67M rows) and `Logs_Prudential` (~7.86M
383
+ > rows). `route` and `responseCode` are **NOT** indexed. So a *tight* `dtStamp` window is cheap
384
+ > (a 15-minute slice is trivial), but a **wide** `dtStamp` range combined with a `route` filter
385
+ > still times out — a 2.5-month grouped query aborted at 300 s. Narrow the time window first;
386
+ > the other predicates do no work for you.
387
+
362
388
  1. **Narrow with indexed / non-text columns only** — a `dtStamp` range plus `route`,
363
389
  `method`, `responseCode`, `hostname`. A
364
390
  `GROUP BY hostname, route, method, responseCode` over one day is cheap and is the fastest way
@@ -400,6 +426,16 @@ re-send.
400
426
  > replay operationally, not by pasting payloads into the KB.
401
427
 
402
428
  ## Change history
429
+ - 2026-09-10 — Sharpened the client-vs-core routing on the auth codes: a **401 always lands in the
430
+ base `Logs`** (decided in the auth chain ~L2088 before routing, client unresolved) while a **403
431
+ lands in `Logs_<Client>`** (client already known) — verified on Prudential 31 Aug–1 Sep, where
432
+ `Logs_Prudential.Api` held 56 rows for `/v2/service-requests` (all 201) and all **36 401s** were
433
+ in base `Logs`. Consequence: **no model interceptor can ever fire for a 401**, so alerting on
434
+ refused inbound traffic must scan the base log; the caller is recoverable by decoding
435
+ `id.client.uuid` from the Bearer token. Also corrected the index picture with `SHOW INDEX`:
436
+ `Logs.Api.dtStamp` **is** indexed (`Api_dtStamp_IDX`) in both base `Logs` (~3.67M rows) and
437
+ `Logs_Prudential` (~7.86M rows), but `route`/`responseCode` are **not** — a tight window is cheap,
438
+ a wide window plus a route filter still aborts at 300 s. No code change. (bala)
403
439
  - 2026-09-01 — **Fixed the duplicate-`transactionId` 500** (`Logs.Issue CT`, `TRUE-81056` /
404
440
  `db94a26`): the log-save at the end of `execute()` did a **check-then-insert**, which cannot work
405
441
  under concurrency because the rival copy's row is still uncommitted and therefore invisible to the
@@ -5,7 +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 |
6
6
  | [Bundle item visibility & selectability (the three flags, and why they are enforced nowhere but the client)](features/bundle-item-visibility-and-selectability.md) | Which components of a kit a shopper can see and choose is decided **entirely in the browser**. |
7
7
  | [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 |
8
- | [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. |
8
+ | [Cart Notification Emails — auto-add/remove lifecycle + duplicate prevention](features/cart-notification-emails.md) | On the cart "Notifications" section a user can add CC email addresses to an order. |
9
9
  | [Cart Order-Total & Shipping Computation](features/cart-order-total-computation.md) | The Cart summary section (subtotal / shipping / tax / total) is **data-driven** from `cartData`. |
10
10
  | [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`. |
11
11
  | [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 |
@@ -1,16 +1,18 @@
1
1
  ---
2
- title: Cart Notification Emails — duplicate prevention
2
+ title: Cart Notification Emails — auto-add/remove lifecycle + duplicate prevention
3
3
  framework: "2.0"
4
4
  repo: toga2-commerce
5
5
  project: TOGa Commerce
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-07-27
9
+ updated: 2026-09-10
10
10
  owners: ["bala", "tcox"]
11
11
  files:
12
12
  - src/pages/Cart/CartPage.tsx
13
13
  - src/pages/Cart/view/cartForm/CartForm.tsx
14
+ - src/pages/Cart/view/cartForm/EditCart.tsx
15
+ - src/pages/Cart/view/cartForm/EditOrder.tsx
14
16
  - src/stores/useEmailOptionsStore.ts
15
17
  - src/stores/useCartSalesQuoteZu.ts
16
18
  - src/pages/Cart/viewModel/FIELDS/*/*/*/CARTPAGE.ts
@@ -21,12 +23,19 @@ related:
21
23
  ---
22
24
 
23
25
  ## Summary
24
- On the cart "Notifications" section a user can add CC email addresses to an order. Adding the
25
- same address twice (often with different casing) used to slip through to the API and blow up on
26
- submit with a MySQL 1062 duplicate-key error on the `SalesOrderEmailAddresses.salesOrderId_emailAddress`
27
- unique index (whose collation `utf8mb4_0900_ai_ci` is case-insensitive). This feature blocks
28
- duplicates case-insensitively in the frontend and shows a sapphire info message instead of letting
29
- the error reach the user.
26
+ On the cart "Notifications" section a user can add CC email addresses to an order. Two jobs live
27
+ here:
28
+
29
+ 1. **Duplicate prevention.** Adding the same address twice (often with different casing) used to
30
+ slip through to the API and blow up on submit with a MySQL 1062 duplicate-key error on the
31
+ `SalesOrderEmailAddresses.salesOrderId_emailAddress` unique index (collation
32
+ `utf8mb4_0900_ai_ci` is case-insensitive). The frontend blocks duplicates case-insensitively and
33
+ shows a sapphire info message instead of letting the error reach the user.
34
+ 2. **Auto-add/remove lifecycle.** Picking an **order-for user** or a **Delegate Manager**
35
+ auto-adds their notification emails. **Changing the pick must remove the previous person's
36
+ auto-added emails.** Until 2026-09-10 it did not: the old person and their manager stayed in the
37
+ list, still checked, and still in the submit payload — so notifications went to the wrong
38
+ people unless the buyer noticed and unchecked them.
30
39
 
31
40
  ## Key files / entry points
32
41
  - `CartPage.tsx` — `handleAddEmail`: validates the email regex, then on success calls
@@ -35,10 +44,23 @@ the error reach the user.
35
44
  - `CartForm.tsx` — owns the duplicate UX. `handleAddEmailWithDuplicateCheck` wraps the passed-in
36
45
  `handleAddEmail`: it case-insensitively checks the existing `emails` list and, on a match, shows
37
46
  the message instead of adding. The **Add Email** button calls this wrapper.
38
- - `useEmailOptionsStore.ts` — `addEmailOption` de-dupes the checkbox list.
39
- - `useCartSalesQuoteZu.ts` `addEmail` de-dupes the actual `salesOrderEmailAddresses` payload.
47
+ - `CartPage.tsx` — also owns the **Delegate Manager** lifecycle:
48
+ `advancedSelectConfig.delegateManager.onSelect` / `.onClearInput`, the
49
+ `lastDelegateManagerEmailRef` (useRef), the `removeDelegateManagerEmail` guard, the
50
+ `watch("hasDelegateManager")` and `watch("delegateManager")` effects, and `handleClearUser`.
51
+ - `EditCart.tsx` — cart-checkout mode. The order-for change effect (`onSuccess` of the user fetch)
52
+ owns add **and** remove of the order-for user's emails, plus the local `ensureEmailSelected`
53
+ helper and the `orderForUserChanged` flag.
54
+ - `EditOrder.tsx` — edit-order mode. Still has the **original add-only** order-for pattern.
55
+ - `useEmailOptionsStore.ts` — `addEmailOption` de-dupes the checkbox list; `removeEmailOption`
56
+ removes case-insensitively; `selectEmailOption(email)` marks an option checked (idempotent —
57
+ never toggles).
58
+ - `useCartSalesQuoteZu.ts` — `addEmail` de-dupes and `removeEmail` removes from the actual
59
+ `salesOrderEmailAddresses` payload, both case-insensitively.
40
60
 
41
61
  ## How it works
62
+
63
+ ### Duplicate prevention
42
64
  1. **Before (manual add):** clicking Add Email runs `handleAddEmailWithDuplicateCheck` in
43
65
  `CartForm`. It compares `(e.email ?? "").trim().toLowerCase()` against the normalized input. On a
44
66
  match it sets `duplicateEmailMessage` and returns (does not add).
@@ -53,6 +75,41 @@ the error reach the user.
53
75
  5. A `useEffect` clears the message when the input, selected user (`orderForUser?.uuid`), or email
54
76
  list (`emails.length`) changes.
55
77
 
78
+ ### Order-for user — add AND remove (`EditCart.tsx`, cart-checkout mode only)
79
+ Auto-added set per user: **their email**, `supervisorUser.email`, and
80
+ `c_supportedByUserId.email`.
81
+
82
+ 1. On success of the order-for user fetch, read the **previous** user from
83
+ `useSelectedUserZu.getState().selectedUser` — the store, **not** the render closure (the closure
84
+ is stale inside the query callback).
85
+ 2. If the previous uuid differs from the new one (`orderForUserChanged`), remove that person's three
86
+ auto-added addresses from **both** `useEmailOptionsStore` and
87
+ `useCartSalesQuoteZu.salesOrder.salesOrderEmailAddresses`. **Never remove the logged-in user's
88
+ own address** even if it matches one of the three.
89
+ 3. Re-seed the new user's addresses through the local **`ensureEmailSelected(email)`** helper:
90
+ `addEmailOption` → `selectEmailOption` → `addEmail`. Idempotent, so re-running it is safe.
91
+ 4. The seeding block runs when `salesOrderEmailAddresses` is empty **or** when
92
+ `orderForUserChanged`. Before the fix it ran only on empty, so the second pick's emails were
93
+ added **un-checked** and never reached the payload.
94
+
95
+ ### Delegate Manager email lifecycle (`CartPage.tsx`, cart-checkout mode)
96
+ "Associate" in the Compass UI = Delegate Manager. Its email is tracked by
97
+ **`lastDelegateManagerEmailRef`** (a `useRef`), because the form value cannot be used (see
98
+ Gotchas). The email is parsed out of the option label, which has the shape
99
+ `"First Last (email)"`.
100
+
101
+ | Action | What happens |
102
+ |---|---|
103
+ | Pick a manager | `onSelect` removes the *previous* ref email if different, adds the new one checked, sets the ref |
104
+ | Click the field's **X** | `onClearInput` removes the ref email and clears the ref |
105
+ | Un-check "has delegate manager" | the `watch("hasDelegateManager")` effect removes the ref email and empties the field |
106
+ | Clear the order-for user | `handleClearUser` also un-checks the box, empties the field, and clears the ref |
107
+ | Restore a saved cart | the `watch("delegateManager")` effect seeds the ref; a restored name has no `"(email)"`, so it falls back to `salesOrder.approvalUser.email` |
108
+
109
+ **`removeDelegateManagerEmail` never removes an address that is also** the order-for user's, their
110
+ supervisor's, their support tech's, or the logged-in user's — those are owned by the order-for
111
+ lifecycle above.
112
+
56
113
  ## Data model
57
114
  Frontend only. The payload list maps to the `SalesOrderEmailAddresses` table (api2/backend), which
58
115
  has a case-insensitive unique index on `(salesOrderId, emailAddress)`.
@@ -67,19 +124,57 @@ the message resolves to `undefined` and renders blank.
67
124
  - The real source of the 1062 was `useCartSalesQuoteZu.addEmail` comparing with `===`
68
125
  (case-sensitive). Both stores must compare case-insensitively; fixing only the UI list is not
69
126
  enough because `addEmail` builds the payload.
127
+ - **Removes must be case-insensitive too, not just adds.** Both stores' *adds* were already
128
+ case-insensitive while their *removes* used exact match, so a hand-typed `Bob@X.com` survived a
129
+ remove of `bob@x.com` and shipped in the payload. `removeEmailOption` and `removeEmail` are now
130
+ case-insensitive.
131
+ - **`handleEmailChange` TOGGLES — never use it to seed.** Re-running it on an already-checked
132
+ address **un-checks** it. Use the idempotent `ensureEmailSelected` /
133
+ `selectEmailOption` path for any auto-seeding.
134
+ - **`CartFormSection` overwrites the form value BEFORE your handler runs.** It calls
135
+ `field.onChange(value)` and *then* `config.onSelect(value, valueKey)` (same for
136
+ `field.onChange(null)` before `config.onClearInput`). So
137
+ `formMethods.getValues("delegateManager")` inside those handlers already holds the **new** value
138
+ — you **cannot** read the previous pick from the form. Keep the previous value in a `useRef`.
139
+ - **Read the previous order-for user from the store, not the closure.** Inside the user-fetch
140
+ `onSuccess` the render closure's `selectedUser` is stale; use
141
+ `useSelectedUserZu.getState().selectedUser`.
142
+ - **`delegateManager` defaults to a truthy object** (`{uuid:"",name:""}`) now that it is a typed
143
+ form field, so `if (formValues.delegateManager)` is always true. Check
144
+ `formValues.delegateManager?.uuid` instead (3 call sites: `EditCart.tsx` ×2, `EditOrder.tsx` ×1).
145
+ - **`EditOrder.tsx` (edit-order mode) still has the add-only order-for pattern** — the same
146
+ wrong-recipient bug very likely exists there. Left alone deliberately: edit-order has different
147
+ manager rules (`assignedTo`). Fix it as its own task.
70
148
  - Normalize with `(x ?? "").trim().toLowerCase()`, not `x?.trim().toLowerCase()` — both are
71
149
  nullish-safe (optional chaining short-circuits the whole chain, it does not throw), but `(x ?? "")`
72
150
  guarantees a string and avoids an `undefined === undefined` match edge.
73
151
  - Adding the label to only some `CARTPAGE.ts` files leaves other client/role/language users with a
74
152
  blank message.
75
153
  - Do not use a toaster or `setError` (red) for this — product wants the sapphire info style.
76
- - **Now pinned by e2e.** `cypress/e2e/cartPage/cartV2.cy.ts` covers both the auto-population
77
- (order-for user + supervisor emails, protected rows unremovable) and the case-insensitive
78
- duplicate block surfacing the *"This email has already been added"* banner. These are exactly
79
- the parity behaviors the config-cart refactor spike silently lost, so keep the spec green when
80
- touching this flow (see [cypress-testing](../workflows/cypress-testing.md)).
154
+ - **e2e coverage is branch-dependent check before you rely on it.** On `_production`,
155
+ `cypress/e2e/cartPage/cartV2.cy.ts` covers both the auto-population (order-for user + supervisor
156
+ emails, protected rows unremovable) and the case-insensitive duplicate block surfacing the
157
+ *"This email has already been added"* banner the exact parity behaviors the config-cart
158
+ refactor spike silently lost, so keep it green (see
159
+ [cypress-testing](../workflows/cypress-testing.md)). But on `#sprint86` that file **does not
160
+ exist** and every spec in `cypress/e2e/cartPage/cart.cy.ts` is **commented out**, so there is no
161
+ net for this flow there. Verify manually on such a branch.
162
+ - **ESLint cannot run in this repo checkout** (pre-existing): `.eslintrc.cjs` references
163
+ `eslint-plugin-react-compiler`, which is not installed. Type-check with
164
+ `npx tsc --noEmit -p tsconfig.app.json` instead.
81
165
 
82
166
  ## Change history
167
+ - 2026-09-10 — FIXED: changing the order-for user or the Delegate Manager left the previous
168
+ person's auto-added emails checked and in the submit payload, so notifications went to the wrong
169
+ people (reported by a Compass USA user on `compass.togacommerce.com`; fix is client-neutral).
170
+ Order-for path (`EditCart.tsx`) now removes the prior user's three auto-added addresses and
171
+ re-seeds via the idempotent `ensureEmailSelected` (replacing the toggling `handleEmailChange`),
172
+ and seeds on `orderForUserChanged` as well as on empty. Delegate Manager path (`CartPage.tsx`)
173
+ gained `lastDelegateManagerEmailRef` + `removeDelegateManagerEmail` covering pick / re-pick / X /
174
+ un-check / clear-user / restore. Stores: added `selectEmailOption`, made `removeEmailOption` and
175
+ `removeEmail` case-insensitive. `hasDelegateManager` / `delegateManager` are now typed form fields
176
+ with defaults, so delegate checks moved to `?.uuid`. Edit-order mode not fixed (different manager
177
+ rules). (tcox)
83
178
  - 2026-07-27 — No behavior change. This duplicate-prevention UX (email auto-population +
84
179
  case-insensitive duplicate banner) is now covered by cart e2e slice 1 (`cartV2.cy.ts`); linked
85
180
  the [cypress-testing](../workflows/cypress-testing.md) workflow doc. (tcox)
@@ -6,7 +6,7 @@ project: TOGa Commerce
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-07-27
9
+ updated: 2026-09-10
10
10
  owners: ["apeterson", "tcox"]
11
11
  files:
12
12
  - src/pages/Cart/CartPage.tsx
@@ -85,6 +85,17 @@ based on `inEditMode`. `CartFormRenderer.tsx` exists only on the `implement-conf
85
85
  Two mode-change `reset()` effects (~lines 267, 282) re-seed from that same union shape. This is
86
86
  wrong for per-client field sets: it registers fields a client never renders and bakes one client's
87
87
  default into all of them, and risks RHF controlled/uncontrolled warnings.
88
+ As of 2026-09-10 `hasDelegateManager` (`false`) and `delegateManager` (`{uuid:"",name:""}`) are
89
+ part of that union and are typed in `CartFormFieldNames` — previously they were undefined and
90
+ untyped. Because the default object is **truthy**, any check on the field must test
91
+ `delegateManager?.uuid`, not the object.
92
+ - **`CartFormSection` runs `field.onChange` BEFORE the config handler.** For an `advancedSelect` it
93
+ calls `field.onChange(value)` then `config?.onSelect(value, valueKey)` (and
94
+ `field.onChange(null)` then `config?.onClearInput(valueKey)`). So a config handler can never read
95
+ the field's **previous** value from the form — it is already overwritten. Handlers that need the
96
+ prior pick must keep it in a `useRef` (see
97
+ [cart-notification-emails](cart-notification-emails.md) for the Delegate Manager case). The
98
+ planned `hydrateCartConfig` (Phase 5) must preserve this ordering contract or document a change.
88
99
  - **Client differences are hardcoded, not config.** Examples observed: COMPASS renders
89
100
  `DuplicateKitGuardrail`, QUAD does not; COMPASS seeds the cart form (cost center / manager / emails)
90
101
  from the order-for user, QUAD does not. These are per-client conditionals in cart code today.
@@ -285,6 +296,11 @@ extension** of toga2.5's philosophy, not a literal copy.
285
296
  edit-order mode?
286
297
 
287
298
  ## Change history
299
+ - 2026-09-10 — Two gotchas added from cart-notification work: `hasDelegateManager` /
300
+ `delegateManager` are now typed form fields in the union `defaultValues` (truthy default → check
301
+ `?.uuid`), and documented that `CartFormSection` calls `field.onChange` **before** the
302
+ `advancedSelect` config's `onSelect`/`onClearInput`, so a handler cannot read the field's previous
303
+ value. No refactor progress. (tcox)
288
304
  - 2026-07-27 — Phase 0 oracle is now partly real: cart e2e slice 1 (`cartV2.cy.ts`, 12 tests)
289
305
  landed and pins the exact parity behaviors the prior spike lost (notification-email
290
306
  auto-population, duplicate-email surfacing) plus render/gating/clear/empty flows. Corrected the
@@ -5,7 +5,7 @@ _Auto-generated by `knowledge.js index`. Do not hand-edit._
5
5
  ## 1.0 framework
6
6
 
7
7
  - **library** (Library) _(framework core)_ — 25 doc(s) → [1.0/apps/library/INDEX.md](1.0/apps/library/INDEX.md)
8
- - **worker** (Worker) — 34 doc(s) → [1.0/apps/worker/INDEX.md](1.0/apps/worker/INDEX.md)
8
+ - **worker** (Worker) — 35 doc(s) → [1.0/apps/worker/INDEX.md](1.0/apps/worker/INDEX.md)
9
9
  - **dbchanges** (Database Changes) _(framework core)_ — 1 doc(s) → [1.0/apps/dbchanges/INDEX.md](1.0/apps/dbchanges/INDEX.md)
10
10
  - **worker1.5** (Worker 1.5) — 0 doc(s) → [1.0/apps/worker1.5/INDEX.md](1.0/apps/worker1.5/INDEX.md)
11
11
  - **togadesk** (TOGa Desk) — 13 doc(s) → [1.0/apps/togadesk/INDEX.md](1.0/apps/togadesk/INDEX.md)
@@ -8,6 +8,7 @@
8
8
  | [Prudential: OneUptime order-pipeline backlog monitors (Monitor/Prudential/*)](features/oneuptime-order-pipeline-monitors.md) | 2.0 | Three worker2 OneUptime push monitors (TRUE-80587) that watch the Prudential/Dell order pipeline for **stalls**, which were previously invisible — a stuck pipel |
9
9
  | [Prudential: order email PDF attachments (which guide goes on which email)](features/order-email-pdf-attachments.md) | 1.0 | Every customer-facing Prudential order email carries S3-hosted PDF guides, and **which** guide is attached is decided by the service request type. |
10
10
  | [Prudential: Service Request Regional Address Validation](features/service-request-address-validation.md) | 2.0 | The `prePost` interceptor on `_Model_Prudential_ServiceRequest` validates `deliverToAddress` fields differently depending on which Prudential regional customer |
11
+ | [Prudential: refused REQ alert (401/403 on POST /v2/service-requests)](features/service-request-auth-rejection-alert.md) | 1.0 | ServiceNow posts every Prudential REQ to `POST /v2/service-requests`. |
11
12
  | [Prudential: Service Request rejection alert email](features/service-request-rejection-alert-email.md) | 2.0 | When a Prudential ServiceNow→TOGa service-request submission (`POST /v2/service-requests`) is **rejected by validation**, TOGa now sends a real-time internal al |
12
13
  | [Prudential: Sales Orders CSV export (TOGa 2.5 Supply)](features/supply-orders-csv-export.md) | 2.0 | The **Export** button on the TOGa 2.5 Supply *Sales Orders* page is **Prudential-only**: it downloads a 22-column CSV (`prudential-orders-<YYYY-MM-DD>.csv`) cov |
13
14
  | [Prudential Order Shipped Email — transmit_ordershipped_updates_prudential.php](features/transmit-ordershipped-email.md) | 1.0 | Cron script that transmits "Order Shipped" updates to ServiceNow (RITM) and sends a shipped notification email to the end user. |
@@ -0,0 +1,156 @@
1
+ ---
2
+ title: "Prudential: refused REQ alert (401/403 on POST /v2/service-requests)"
3
+ framework: "1.0"
4
+ repo: worker
5
+ project: Worker
6
+ client: prudential
7
+ type: client-feature
8
+ status: active
9
+ updated: 2026-09-10
10
+ owners: ["bala"]
11
+ files:
12
+ - worker/crons/notifications/reports/prudential/prudential_auth_rejection_alert.php
13
+ - worker/schedules/cron.worker.notification.json
14
+ - worker/crons/notifications/reports/prudential_exception_report.php
15
+ - worker/crons/notifications/reports/prudential/prudential_api_500_retry.php
16
+ related:
17
+ - service-request-rejection-alert-email.md
18
+ - service-request-address-validation.md
19
+ - ../profile.md
20
+ - ../../../2.0/apps/api2/features/request-logging.md
21
+ - ../../../2.0/apps/api2/features/v2-api-error-codes.md
22
+ ---
23
+
24
+ ## Summary
25
+
26
+ ServiceNow posts every Prudential REQ to `POST /v2/service-requests`. When we **refuse** that
27
+ request at the door — **401** (login token bad or expired) or **403** (permissions) — no
28
+ ServiceRequest row is created, no interceptor runs, and **no existing report shows it**. The REQ
29
+ simply does not exist on our side and nobody is told. The cron
30
+ `worker/crons/notifications/reports/prudential/prudential_auth_rejection_alert.php` closes that
31
+ gap: every 15 minutes it reads refused inbound REQs straight from the API logs and emails them
32
+ with the full payload, so they can be resubmitted the same hour instead of being found weeks later.
33
+
34
+ This is the **auth-layer** sibling of the 2.0 validation alert
35
+ ([service-request-rejection-alert-email.md](service-request-rejection-alert-email.md)), which
36
+ covers 400s. The two never overlap: a 400 reaches the model, a 401 never does.
37
+
38
+ ## Why it exists (the 2026-08/09 incident)
39
+
40
+ REQ4492868 was reported missing. It **did** reach us on 2026-08-31 09:45:18 (POST
41
+ `/v2/service-requests`, BREAKFIX, RITM6763537) and came back **401 `EN-4` "The provided token has
42
+ expired"**. ServiceNow (sourceIp `199.91.136.12`) collected a JWT at 07:40:47 that expired at
43
+ 08:40:47 (1-hour life) and then kept reusing that same dead token for hours — every refused row
44
+ carries the **identical `exp`**. **36 REQs** were refused this way across 31 Aug and 1 Sep. 14 were
45
+ later resent and created fine (the Reclaims, replayed 1 Sep ~15:00); **22, including REQ4492868,
46
+ were never resent and are still missing.** The whole class of failure was silent for a week.
47
+
48
+ ## Why nothing else caught it
49
+
50
+ - `worker/crons/notifications/reports/prudential_exception_report.php` (daily, 9:00 AM) only queries
51
+ response codes **201, 400 and 500**, and only against **`Logs_Prudential`** (`db_prod_logs_prudential`).
52
+ - `prudential_api_500_retry.php` only queries **500**.
53
+
54
+ So **401 and 403 were the two codes nobody was looking at** — and a 401 is not even in the schema
55
+ those reports read. This is also why the new cron cannot double-report: no other job touches these
56
+ codes.
57
+
58
+ ## Where a refused request is actually logged (the load-bearing fact)
59
+
60
+ - **401 → the SHARED BASE `Logs` schema** (`db_prod_logs`), **not** `Logs_Prudential`. The
61
+ expired-token check runs in the auth chain in `api2/Component/Api/V2/V2.php` (~L2088, inside
62
+ `execute()`) **before** routing, so the client is not resolved yet and the row cannot be written
63
+ to the client log schema.
64
+ - **403 → `Logs_Prudential`.** By then the client **is** known.
65
+
66
+ Verified for 31 Aug – 1 Sep: `Logs_Prudential.Api` holds **56 rows** for `/v2/service-requests`,
67
+ **all 201**; every one of the **36 401s** is in base `Logs`.
68
+
69
+ **Consequence: a model `prePost` / interceptor can NEVER fire for a 401** — the request never
70
+ reaches routing or the model. That is why this alert had to be a log scan and not a hook. The
71
+ client is still identifiable: decode the expired Bearer token and read `id.client.uuid`.
72
+
73
+ ## How it works
74
+
75
+ 1. Floor the clock to the quarter hour and read exactly `[end - 15 min, end)`.
76
+ 2. Read **401s from base `Logs`** (`db_prod_logs`) and **403s from `Logs_Prudential`**
77
+ (`db_prod_logs_prudential`), both filtered to `direction = 'IN'`, `method = 'POST'`,
78
+ `route = '/v2/service-requests'`.
79
+ 3. Decode the Bearer token in `requestHeaders` to confirm the caller is Prudential
80
+ (`App_Api_Toga2::CLIENT_UUID_PRUDENTIAL`). A **missing** token yields no uuid — those rows are
81
+ **still reported**, deliberately, rather than dropped.
82
+ 4. Map the `EN-*` code from the response payload to a plain-English sentence
83
+ (`EN-1` no token sent · `EN-2` unreadable · `EN-3` not valid · `EN-4` expired · `EN-5` altered ·
84
+ `EN-6` address not allowed), and classify the row as **Login** (401) or **Permissions** (403).
85
+ 5. Email one grouped HTML table — REQ number, RITM number, type, time raised in ServiceNow, time it
86
+ reached us, reason — plus a **CSV attachment whose last column carries the full request
87
+ payload**, so the mail can be forwarded to Prudential as proof of what they sent.
88
+ 6. Send **nothing** on a quiet run. Cap of `MAX_ROWS_PER_RUN = 500` so one bad hour cannot produce a
89
+ giant email.
90
+
91
+ Schedule entry in `worker/schedules/cron.worker.notification.json`: `"*/15 * * * *"` →
92
+ `notifications/reports/prudential/prudential_auth_rejection_alert.php` (notification worker role).
93
+ Recipients: `vburks`, `rgirish`, `ADiamond`, `kmaramreddy` @togatech.com.
94
+
95
+ ## Design decision — no state table, no state file (clock-aligned window instead)
96
+
97
+ The first design kept a "last processed log id" marker. Both marker forms were rejected:
98
+
99
+ - **A file marker is wrong here.** The worker cache folder is `/var/www/cache`, **local to each
100
+ Elastic Beanstalk instance** and recreated on every deploy (see
101
+ `worker/ebs/setup_export_cache_folders.php`). The same rejections would be emailed once *per
102
+ instance*, and the marker is lost on every deploy.
103
+ - **A DB marker table** was drafted, then rejected as too much machinery for the value.
104
+
105
+ **Final design:** each run floors the clock to the quarter hour and reads `[end - 15 min, end)`.
106
+ Consecutive slices touch but never overlap, so no row can be read twice — **with no state at all.**
107
+
108
+ **Known trade-off, accepted:** a skipped run (deploy, restart) loses that 15-minute slice. Do not
109
+ "fix" this by re-adding a marker without re-reading the two reasons above.
110
+
111
+ ## Gotchas
112
+
113
+ - **Filter `method = 'POST'` or you get browser noise.** Unauthenticated **GET**s on this route from
114
+ human sourceIps (`99.179.145.25`, `107.139.5.190`) also log 401, with an **empty `requestPayload`**
115
+ — they show up as REQ "Unknown". Only a POST is a real ServiceNow submission.
116
+ - **`prudential_api_500_retry.php` is broken and has almost certainly never worked.** At lines 57
117
+ and 70 it references a bare constant **`PRUDENTIAL_CLIENT_UUID` that is defined nowhere** in any
118
+ repo (grep returns only its own two uses); it should be `App_Api_Toga2::CLIENT_UUID_PRUDENTIAL`.
119
+ On PHP 8 an undefined constant is a **fatal Error**, so the cron dies every run; on PHP 7 it
120
+ evaluates to the literal string and the client check never matches, so it silently retries
121
+ nothing. It also appears in **no** `schedules/*.json` file, so it may never have been registered.
122
+ **Not fixed as of 2026-09-10 — reported only.**
123
+ - **`Logs.Api.dtStamp` IS indexed** (`Api_dtStamp_IDX`) in both base `Logs` and `Logs_Prudential`,
124
+ which is what makes the 15-minute window read cheap. But `route` and `responseCode` are **not**
125
+ indexed, so a wide `dtStamp` range plus a route filter still times out (a 2.5-month grouped query
126
+ aborted at 300 s). Keep the window tight. See
127
+ [request-logging.md](../../../2.0/apps/api2/features/request-logging.md).
128
+
129
+ ## Telling "we refused it" from "they never sent it"
130
+
131
+ Two different failure shapes get reported the same way ("REQ is missing"):
132
+
133
+ - **Refused by us** — there IS an inbound POST row on `/v2/service-requests` with a 401/403 and a
134
+ full `requestPayload`. That is REQ4492868. Prudential must resubmit.
135
+ - **Never sent** — no inbound row at all. That is **REQ4398272**: absent from
136
+ `Client_Prudential.ServiceRequests` under both `c_reqNumber` and `c_ritmNumber`, and absent from
137
+ `Logs_Prudential.Api` (1 May – 15 Jul) and base `Logs.Api` (10–16 May) — no POST, no 401, no 400,
138
+ no 500. By the surrounding REQ numbers it would have been raised 2026-05-12 between 11:23 and
139
+ 12:53 (REQ4398185 landed 11:23, REQ4398357 landed 12:53). Base `Logs` retention reaches back to
140
+ Aug 2025, so the window is genuinely covered. The problem is on the ServiceNow side.
141
+
142
+ Always check **both** schemas before answering — a 401 will not be in `Logs_Prudential`.
143
+
144
+ ## Change history
145
+ - 2026-09-10 — Created. New 15-minute cron
146
+ `notifications/reports/prudential/prudential_auth_rejection_alert.php` alerting on Prudential REQs
147
+ refused with **401 (base `Logs`) or 403 (`Logs_Prudential`)**, with an HTML table plus a
148
+ full-payload CSV for forwarding to Prudential. Found after REQ4492868 was reported missing: it was
149
+ refused 2026-08-31 09:45:18 with `EN-4` (expired token), one of **36** refused across 31 Aug–1 Sep
150
+ from a JWT ServiceNow collected at 07:40:47 and reused past its 08:40:47 expiry — **22 still
151
+ missing**. Recorded why nothing caught it (the daily exception report reads only 201/400/500 and
152
+ only `Logs_Prudential`), why a `prePost` hook cannot work for a 401, the **no-state clock-aligned
153
+ window** decision (the `/var/www/cache` marker is per-instance and dies on deploy), the
154
+ **POST-only** filter that excludes browser 401 noise, that `dtStamp` is indexed while
155
+ `route`/`responseCode` are not, and that `prudential_api_500_retry.php` is dead on an undefined
156
+ `PRUDENTIAL_CLIENT_UUID` constant. (bala)
@@ -6,13 +6,14 @@ project: Worker
6
6
  client: prudential
7
7
  type: client-feature
8
8
  status: active
9
- updated: 2026-07-28
9
+ updated: 2026-09-10
10
10
  owners: ["bala"]
11
11
  files:
12
12
  - worker2/Worker/Client/Prudential/reports/ReqRejectionEmail.php
13
13
  - _underscore/Model/Prudential/ServiceRequest.php
14
14
  related:
15
15
  - service-request-address-validation.md
16
+ - service-request-auth-rejection-alert.md
16
17
  - ../../../2.0/apps/_underscore/features/email-send-pipeline.md
17
18
  - ../profile.md
18
19
  ---
@@ -79,11 +80,21 @@ known residual risk.
79
80
  validation block completes (e.g. the unguarded `serviceRequestUnits[0]->unit` access — see
80
81
  `service-request-address-validation.md`) produces a **500 and no alert**. Fix that guard to
81
82
  keep the alert reliable.
83
+ - **This alert only covers 400s — a 401/403 never reaches `prePost` at all.** An expired or bad
84
+ ServiceNow token is refused in the auth chain *before* routing, so the model never runs and no
85
+ interceptor can fire; the row is also logged to the **base `Logs`** schema, not `Logs_Prudential`.
86
+ Those refusals are alerted separately by the 1.0 worker cron in
87
+ [service-request-auth-rejection-alert.md](service-request-auth-rejection-alert.md). Do not extend
88
+ this interceptor to try to catch them.
82
89
  - **"Plain text" is transmitted HTML anyway.** `setIsHtml(false)` is honored by `_Email` when it
83
90
  queues the row, but the worker2 `Infrastructure/Email/Send` action calls `IsHTML(true)`
84
91
  unconditionally, so the message is sent HTML-mode regardless (see `email-send-pipeline.md`).
85
92
 
86
93
  ## Change history
94
+ - 2026-09-10 — Scoped: this alert fires only for **validation 400s**. A **401/403** is decided in
95
+ the auth chain before routing, so `prePost` never runs and this email never sends for it — those
96
+ refused REQs are covered by the separate 15-minute worker cron
97
+ `prudential_auth_rejection_alert.php`. No code change. (bala)
87
98
  - 2026-07-28 — Created: real-time internal alert on `POST /v2/service-requests` validation
88
99
  rejection. `prePost()` now collects all validation errors and enqueues the
89
100
  `ReqRejectionEmail/sendRejectionEmail` worker task (REQ# + reason + full payload) before
@@ -15,7 +15,7 @@ project: _Underscore
15
15
  client: prudential
16
16
  type: profile
17
17
  status: active
18
- updated: 2026-08-28
18
+ updated: 2026-09-10
19
19
  owners: ["jcardinal", "rgirish", "bala", "mhammontree"]
20
20
  files: []
21
21
  related:
@@ -23,10 +23,13 @@ related:
23
23
  - features/dell-asn-units-interceptor.md
24
24
  - features/service-request-address-validation.md
25
25
  - features/service-request-rejection-alert-email.md
26
+ - features/service-request-auth-rejection-alert.md
26
27
  - features/dell-lch-iop-transmissions.md
27
28
  - features/device-information-import-and-contact-linking.md
28
29
  - workflows/http2-alb-workaround.md
29
- - features/supply-orders-csv-export.md
30
+ - features/service-request-auth-rejection-alert.md — the 15-minute refused-REQ (401/403) alert cron,
31
+ where each code is logged, and how to tell "we refused it" from "they never sent it".
32
+ - features/supply-orders-csv-export.md
30
33
  - features/order-email-pdf-attachments.md
31
34
  ---
32
35
 
@@ -89,6 +92,13 @@ The order-created cron branches on this id; the shipped/delivered crons branch o
89
92
  (E_ALL→ErrorException), so `!== false` guards do not work — use the `safeFetchPdf()` pattern. See
90
93
  the exception report and the transmit-ordershipped-email feature doc.
91
94
 
95
+ - **Inbound REQs we refuse (401/403) are invisible in `Logs_Prudential`.** A 401 is logged to the
96
+ **shared base `Logs`** schema because the client is not resolved yet; only a 403 lands in
97
+ `Logs_Prudential`. The daily `prudential_exception_report.php` reads only 201/400/500 in the
98
+ client schema, so refused REQs went unseen for a week in Aug 2026 (36 refused, 22 never resent).
99
+ The 15-minute `prudential_auth_rejection_alert.php` cron now alerts on them — see
100
+ features/service-request-auth-rejection-alert.md.
101
+
92
102
  ## Monitoring
93
103
  - `Monitor/Prudential/{SalesOrderGenerationBacklog,DellPurchaseOrderTransmissionBacklog,OrderShippedUpdateBacklog}`
94
104
  (worker2 → OneUptime) watch the order pipeline for stalls. Thresholds are DB-tunable and the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toga-ai",
3
- "version": "1.0.793",
3
+ "version": "1.0.795",
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",