toga-ai 1.0.460 → 1.0.462

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.
@@ -5,7 +5,7 @@
5
5
  | [Library (1.0 Framework) Architecture](architecture.md) | `library` is the shared library repository for **all 1.0 (legacy) applications** — the `App_` framework. | library/_.php, library/app/, library/browser/ |
6
6
  | [App_Sso — Reusable 1.0 SSO Initiation (SP-initiated SAML via saml.togahub.com)](features/app-sso-initiation.md) | `App_Sso` (`library/app/sso.php`) is the **1.0 port of the 2.0 SAML gateway's SP-initiated SSO initiation**, packaged as a reusable, framework-level capability | library/app/sso.php, library/sso/togahub_private_key.key |
7
7
  | [Cron Execution Monitoring (App_Framework check-in/out → CronJobExecutions)](features/cron-execution-monitoring.md) | `App_Framework::cronInitialization()` / `App_Framework::cronFinished()` (in `library/app/framework.php`) give every 1.0 (`App_`) cron job a check-in/check-out l | library/app/framework.php |
8
- | [Diagnostic Dialog — View Recommended Services Routing](features/diagnostic-dialog-view-recommended-services.md) | `App_Model_Toga_Diagnostic::initializeDiagnosticDialog()` renders the device modal used across all TOGa service request views. | library/app/model/toga/diagnostic.php |
8
+ | [Diagnostic Dialog — View Recommended Services Routing](features/diagnostic-dialog-view-recommended-services.md) | Two "View Recommended Services" buttons exist in the TOGa Refresh 2026 SR view: 1. | library/app/model/toga/diagnostic.php, library/app/model/servicerequest.php |
9
9
  | [Elite Freshservice Sync (library)](features/elite-freshservice-sync.md) | `App_Api_Toga2` in `library/app/api/toga2.php` orchestrates bidirectional sync between TOGA 2 and TOGaDesk. | library/app/api/toga2.php |
10
10
  | [Branded HTML Email Templates (App_Email_Template)](features/email-templates.md) | `App_Email_Template` (`app/email/template.php`) is the base class for branded HTML emails in the 1.0 (`App_`) framework. | library/app/email/template.php, library/app/email/agilant.php |
11
11
  | [1.0 MVC Page Pattern & New-App Skeleton](features/mvc-page-pattern-and-app-skeleton.md) | This is the **reusable recipe for standing up a new 1.0 (`App_`) application** and for adding pages to one — the folder-based MVC routing, the page lifecycle, t | library/app/framework.php, library/app/frameworkindex.php, library/app/mvc.php, library/app/database.php, library/app/model.php, library/app/config.php |
@@ -6,44 +6,103 @@ project: Library
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-06-23
9
+ updated: 2026-07-28
10
10
  owners: [snaredla]
11
11
  files:
12
12
  - library/app/model/toga/diagnostic.php
13
+ - library/app/model/servicerequest.php
13
14
  related:
14
15
  - ../../../clients/office-depot/features/togarefresh2026-linkbuilder-routing.md
15
16
  ---
16
17
 
17
18
  ## Summary
18
- `App_Model_Toga_Diagnostic::initializeDiagnosticDialog()` renders the device modal
19
- used across all TOGa service request views. It includes a "View Recommended Services"
20
- button whose URL depends on whether the store is a TOGaRefresh 2026 store and whether
21
- the diagnostic passed or failed.
19
+ Two "View Recommended Services" buttons exist in the TOGa Refresh 2026 SR view:
20
+ 1. **Static button** — rendered on page load inside `view.php`, always visible when a
21
+ diagnostic has been completed on the SR.
22
+ 2. **Device dialog button** — inside the device click modal (`dialogDevice`), shown only
23
+ when that device has a completed diagnostic.
24
+
25
+ Both buttons route to either `serviceselection` (repair + subscription) or `techsupport`,
26
+ based on `resultCode` of the latest completed diagnostic. The routing logic is
27
+ **per-device** — each device's latest result is evaluated independently.
28
+
29
+ ## Key files / entry points
30
+ - `library/app/model/toga/diagnostic.php` — `initializeDiagnosticDialog()`: renders
31
+ `dialogDevice` dialog; device dialog button uses a JS global `_deviceBuilderUrl` set
32
+ dynamically per device click.
33
+ - `library/app/model/servicerequest.php` — `ajaxLoadDeviceDialog($deviceId,
34
+ $serviceRequestId)`: computes per-device routing and returns `builderUrl` in the AJAX
35
+ response.
36
+ - `toga/app/togarefresh2026/servicerequests/view.php` — static button routing block
37
+ (search for `LatestPerDevice`).
22
38
 
23
39
  ## How it works
24
- 1. Checks `$_SESSION['isTogaRefresh2023']` or `$_SESSION['isTogaRefresh2026']` to
25
- determine if this is a refresh store.
26
- 2. For refresh stores, casts `$serviceRequestId` to `(int)` then queries `Diagnostics`
27
- for the latest `pass` value for the service request.
28
- 3. Routes:
29
- - **Diagnostic pass** (`pass >= 1`) → `?content=togarefresh2026_servicerequests_techsupport&serviceRequestId=...`
30
- - **Diagnostic fail / no diagnostic** (`pass < 1`) → `?content=togarefresh2026_servicerequests_serviceselection&jobJacketId=1&showServices=195,101899&serviceRequestId=...`
31
- 4. For non-refresh stores, falls back to `App_Model_ServiceRequest::linkBuilder()`.
40
+
41
+ ### Static button (view.php)
42
+ Runs at page load. Queries the latest completed diagnostic **per device** on the SR using
43
+ a `MAX(id) GROUP BY deviceId` subquery. If **any** device's latest result is a
44
+ software/virus problem code → routes to repair + subscription. Otherwise → tech support.
45
+
46
+ SQL filter: `serviceRequestId = X AND status = 'COMPLETED' AND pass > 0`
47
+
48
+ ### Device dialog button (diagnostic.php + ajaxLoadDeviceDialog)
49
+ `initializeDiagnosticDialog` pre-renders the button with `window.location.href =
50
+ _deviceBuilderUrl`. When the user clicks a device, `loadDeviceDialog(deviceId)` fires
51
+ an AJAX call passing both `deviceId` and `serviceRequestId`. `ajaxLoadDeviceDialog`
52
+ queries the latest diagnostic for **that specific device** on **that specific SR**
53
+ (`deviceId + serviceRequestId + status='COMPLETED' + pass>0 ORDER BY id DESC LIMIT 1`),
54
+ checks `resultCode`, and returns `builderUrl` in the response. The JS
55
+ `loadDeviceDialogResponse` sets `_deviceBuilderUrl = resp['builderUrl']`.
56
+
57
+ ## Result codes that trigger repair routing
58
+
59
+ ### BITSTORM exact-match codes
60
+ ```
61
+ 'Has A/V, Software problem(s) found.'
62
+ 'No A/V, Software problem(s) found.'
63
+ 'No A/V, Quarantined item(s) found.'
64
+ 'Has A/V, Quarantined item(s) found.'
65
+ 'No A/V, Quarantined item(s) found, PUPS found.'
66
+ 'Has A/V, Quarantined item(s) found, PUPS found.'
67
+ 'Has A/V, PUP(s) found.'
68
+ 'No A/V, PUP(s) found.'
69
+ ```
70
+
71
+ ### STARTECH dynamic patterns (strpos)
72
+ - `'antivirus and firewall status failed'` — number of registry entries varies
73
+ - `'potentially unwanted programs'` — number varies
32
74
 
33
75
  ## Data model
34
76
  - Table: `Diagnostics` (db_toga)
35
- - Field read: `pass` (TINYINT boolean — 1 = passed, 0 = failed)
36
- - Query: latest record by `id DESC LIMIT 1` for the given `serviceRequestId`
77
+ - Fields read: `resultCode` (VARCHAR), `deviceId` (INT), `serviceRequestId` (INT),
78
+ `status` (VARCHAR = 'COMPLETED'), `pass` (TINYINT > 0)
37
79
 
38
80
  ## Gotchas / known issues
39
- - `$serviceRequestId` is cast to `(int)` before SQL interpolation — do not remove this cast (SQL injection prevention).
40
- - If no diagnostic record exists, `$diagnosticPass` defaults to `0` (fail path) — shows service selection with `showServices=195,101899`.
41
- - The same pass/fail routing logic exists in `toga/app/togarefresh2026/servicerequests/view.php` (lines 2358–2381) — keep both in sync.
42
- - `isTogaRefresh2023` is kept alongside `isTogaRefresh2026` for backward compatibility — do not remove it.
43
- - `$_SESSION['isTogaRefresh2026']` is set in `toga/_/app/frameworkindex.php` and `toga/app/home/action.php` at login time.
81
+ - **`pass=1` does NOT mean "no problems found"** — it means the scan ran successfully.
82
+ "No A/V, Software problem(s) found." has `pass=1`. Never use `pass` alone for routing;
83
+ always check `resultCode`.
84
+ - **Multi-device SRs**: if a second device is scanned clean after a first device showed
85
+ problems, a naive `ORDER BY id DESC LIMIT 1` across all devices picks the clean result
86
+ and hides repair. The static button uses `MAX(id) GROUP BY deviceId` (LatestPerDevice
87
+ subquery); the dialog button filters by `deviceId + serviceRequestId` directly.
88
+ - **`_deviceBuilderUrl` is a page-global JS var** — set fresh each time a device dialog
89
+ opens. If the dialog is opened, closed, and a different device opened, the var is
90
+ overwritten correctly.
91
+ - `$serviceRequestId` is cast to `(int)` in both PHP callers before SQL interpolation —
92
+ do not remove this cast.
93
+ - `isTogaRefresh2023` is kept alongside `isTogaRefresh2026` in the session check — do not
94
+ remove it.
95
+ - The same `$softwareVirusResultCodes` array and `strpos` checks appear in three places
96
+ (view.php, diagnostic.php, servicerequest.php) — keep all three in sync when adding new
97
+ result codes.
44
98
 
45
99
  ## Related docs
46
100
  - `clients/office-depot/features/togarefresh2026-linkbuilder-routing.md`
47
101
 
48
102
  ## Change history
49
- - 2026-06-23 — Initial doc. Diagnostic modal now routes pass→techsupport, fail→serviceselection with showServices=195,101899. (snaredla)
103
+ - 2026-07-28 — Replaced pass-based routing with resultCode-based per-device routing.
104
+ Static button uses LatestPerDevice subquery; device dialog button is now dynamic via
105
+ _deviceBuilderUrl JS global. ajaxLoadDeviceDialog now accepts serviceRequestId and
106
+ returns builderUrl. (snaredla)
107
+ - 2026-06-23 — Initial doc. Diagnostic modal routes pass→techsupport,
108
+ fail→serviceselection with showServices=195,101899. (snaredla)
@@ -13,6 +13,7 @@
13
13
  | [_Cloud S3 helpers (copy / get / delete / list)](features/cloud-s3-helpers.md) | `_Cloud` centralizes AWS SDK S3 usage for the 2.0 stack so the `S3Client` never leaks into workers or app code. | _underscore/Cloud.php |
14
14
  | [_Component_*/_Model_* project-namespace registration (autoloader) & backslash-qualify traps](features/component-model-namespace-registration.md) | Every **project-local** `_Component_*` and `_Model_*` class in a 2.0 app **must declare the project namespace** at the top of the file: ```php namespace <NAMESP | _underscore/Loader.php, worker2/_.php, api2/_.php, worker2/Component/Forecast/Db/Db.php, worker2/Component/Forecast/SaleImport/SaleImport.php, api2/Component/Api/Netsuite/Netsuite.php |
15
15
  | [Re-pointing a DB alias mid-request (_Database::register park/restore)](features/database-alias-repointing.md) | `_Database` keys **all live per-database runtime state by the connection ALIAS** (`Client` / `_underscore::DB_CLIENT`, `ClientLogs`, `Archive`), **not** by the | _underscore/Database.php, _underscore/Query.php, api2/Component/Api/V2/V2.php, api2/Component/Api/CrossClient/CrossClient.php |
16
+ | [2.0 Email Send Pipeline (queue + Send worker)](features/email-send-pipeline.md) | In 2.0, `_Email::send()` **does not transmit** — it queues the message. | _underscore/Email.php, worker2/Worker/Infrastructure/Email/Send.php |
16
17
  | [Client Email Template Sending](features/email-template-sending.md) | `_Model_Client_EmailTemplate` sends a stored, client-defined email template by UUID. | _underscore/Model/Client/EmailTemplate.php, _underscore/Model/Client/EmailTemplateOutgoingEmailAddress.php, _underscore/Email.php |
17
18
  | [Error Reporting — Issue/Event Aggregation (agreed POST-to-receiver design)](features/error-reporting-issue-event.md) | Platform-wide error-reporting infrastructure for TOGA 2.0, built around a two-table **Issue / Event** aggregation model in the shared **Core Logs DB**. | _underscore/Error.php, _underscore/Model/Core/Logs/Issue.php, _underscore/Model/Core/Logs/Event.php, dbchanges2/Logs/2026-07-06 - Issue and Event tables.sql |
18
19
  | [Record-Changed Event Publishing (_Event::publish to SQS)](features/event-publish-sqs.md) | `_Event::publish()` (in `_underscore/Event.php`) is the PHP side of the real-time event pipeline. | _underscore/Event.php |
@@ -0,0 +1,69 @@
1
+ ---
2
+ title: 2.0 Email Send Pipeline (queue + Send worker)
3
+ framework: "2.0"
4
+ repo: _underscore
5
+ project: _Underscore
6
+ client: shared
7
+ type: feature
8
+ status: active
9
+ updated: 2026-07-28
10
+ owners: ["bala"]
11
+ files:
12
+ - _underscore/Email.php
13
+ - worker2/Worker/Infrastructure/Email/Send.php
14
+ related:
15
+ - email-template-sending.md
16
+ ---
17
+
18
+ ## Summary
19
+
20
+ In 2.0, `_Email::send()` **does not transmit** — it queues the message. It writes a `PENDING`
21
+ row into `Logs_[Client].Email` (the `body` column is `mediumtext`) and stores any attachments as
22
+ BLOBs in `Logs_[Client].EmailAttachment`, then returns. A separate cron worker action,
23
+ `Infrastructure/Email/Send` (`_Worker_Infrastructure_Email_Send::Run`), scheduled in
24
+ `Core.CronJobs` at `* * * * *` (every minute), actually sends the `PENDING` rows via **AWS SES
25
+ SMTP** using PHPMailer, then flips each row to `SENT` or `FAILED`. So a 2.0 email lands within
26
+ ~1 minute of the `send()` call — **not instantly**.
27
+
28
+ ## Key files / entry points
29
+
30
+ - `_underscore/Email.php` — `_Email::send()` builds/validates the PHPMailer message, writes the
31
+ `PENDING` `Logs_[Client].Email` row (+ attachment BLOBs), and enqueues the send. It **throws**
32
+ unless both `setClientIdentifier(...)` and a From address (`fromEmailAddress`) are set. The
33
+ `Logs_[Client]` databases live on a separate cluster (`production-logs-*`), so `send()` points
34
+ `DB_CLIENT_LOGS` at the correct `Logs_[Client]` schema before inserting.
35
+ - `worker2/Worker/Infrastructure/Email/Send.php` — `Run()` fetches up to 100 `PENDING` rows for
36
+ the client, and `sendEmail()` transmits each via SES SMTP. Constants: `STATUS_PENDING`,
37
+ `STATUS_SENT`, `STATUS_FAILED`, `MAX_RETRIES`.
38
+
39
+ ## How it works
40
+
41
+ 1. A caller (model, interceptor, worker) builds `_Email`, sets client identifier + From, adds
42
+ recipients/subject/body, and calls `send()`.
43
+ 2. `send()` inserts a `PENDING` `Logs_[Client].Email` row (attachments → `EmailAttachment` BLOBs)
44
+ and returns — nothing is transmitted yet.
45
+ 3. Every minute, `Infrastructure/Email/Send::Run` selects up to 100 `PENDING` rows and sends each
46
+ through PHPMailer over SES SMTP.
47
+ 4. On success the row flips to `SENT`; on failure `retryCount` is incremented and the row is
48
+ retried on subsequent runs until `MAX_RETRIES` (3), after which it is marked `FAILED`.
49
+
50
+ ## Gotchas / known issues
51
+
52
+ - **~1-minute latency, not instant.** Anything that assumes an email is sent synchronously at the
53
+ `send()` call is wrong — transmission happens on the next Send-worker tick.
54
+ - **The Send worker forces HTML mode.** `sendEmail()` calls `$mailer->IsHTML(true)`
55
+ **unconditionally**, ignoring the sender's `setIsHtml(false)`. A caller that queued a
56
+ "plain-text" message is still transmitted HTML-mode. Author bodies accordingly.
57
+ - **⚠ SECURITY — hardcoded AWS SES SMTP credentials in `_underscore/Email.php`.** The
58
+ `SMTP_USERNAME` / `SMTP_PASSWORD` class constants hold **real** SES SMTP credentials committed
59
+ in source, violating the no-secrets-in-code rule. Documenting the **location only** — do not
60
+ copy the values. Remediation: treat as compromised, rotate in AWS SES, and move them into
61
+ `Config/[environment].ini` (read via `_Config`). Also tracked on
62
+ [`email-template-sending.md`](email-template-sending.md).
63
+
64
+ ## Change history
65
+ - 2026-07-28 — Created: documented that `_Email::send()` **queues** (writes a `PENDING`
66
+ `Logs_[Client].Email` row + attachment BLOBs) rather than transmitting, and that the worker2
67
+ `Infrastructure/Email/Send` cron (`Core.CronJobs` `* * * * *`) does the actual SES SMTP send
68
+ with `MAX_RETRIES` (3) → `SENT`/`FAILED`, giving ~1-minute delivery latency. Noted the Send
69
+ worker's unconditional `IsHTML(true)` override and the hardcoded SES SMTP creds location. (bala)
@@ -6,13 +6,14 @@ project: _Underscore
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-07-16
9
+ updated: 2026-07-28
10
10
  owners: ["jcardinal", "bala", "mhammontree"]
11
11
  files:
12
12
  - _underscore/Model/Client/EmailTemplate.php
13
13
  - _underscore/Model/Client/EmailTemplateOutgoingEmailAddress.php
14
14
  - _underscore/Email.php
15
15
  related:
16
+ - email-send-pipeline.md
16
17
  - ../../worker2/features/notification-email.md
17
18
  - ../../worker2/features/notification-email-template.md
18
19
  ---
@@ -86,6 +87,13 @@ can keep using `sendEmail($api, ...)`.
86
87
 
87
88
  ## Gotchas / known issues
88
89
 
90
+ - **`_Email::send()` QUEUES, it does not directly transmit.** `send()` writes a `PENDING` row into
91
+ `Logs_[Client].Email` (+ attachment BLOBs) and returns; the actual PHPMailer/SES SMTP send,
92
+ retries, and the `SENT`/`FAILED` outcome happen ~1 minute later in the worker2
93
+ `Infrastructure/Email/Send` cron. So an email is **not** sent synchronously at the `send()` call,
94
+ and the "throws when `PHPMailer::Send()` returns false" behavior described under *Failure
95
+ surfacing* below reflects the pre-queue path — final delivery success/failure is now decided by
96
+ the Send worker. See [`email-send-pipeline.md`](email-send-pipeline.md) for the full pipeline.
89
97
  - **Use `send()` from any non-API context (workers, cron, internal code).** Before
90
98
  2026-06-15 the only entry point was `sendEmail(&$api, ...)`, so callers with no API
91
99
  context faked one: `$api = (object)['client' => (object)['clientIdentifier' => …]]`.
@@ -138,6 +146,10 @@ worker method) in-process instead.
138
146
 
139
147
  ## Change history
140
148
 
149
+ - 2026-07-28 — Clarified that `_Email::send()` **queues** a `PENDING` `Logs_[Client].Email` row
150
+ rather than transmitting; the actual SES SMTP send/retry/`SENT`/`FAILED` happens in the worker2
151
+ `Infrastructure/Email/Send` cron (~1-min latency). Added the new
152
+ [`email-send-pipeline.md`](email-send-pipeline.md) feature doc and cross-linked it. (bala)
141
153
  - 2026-07-16 — Recorded the **hardcoded AWS SES SMTP credentials** security gotcha in
142
154
  `_Email` (`SMTP_USERNAME`/`SMTP_PASSWORD` constants; pre-existing — rotate + move to
143
155
  `Config`). Surfaced while fixing a production 500 (EO-1) whose root cause was a single
@@ -107,6 +107,15 @@ One ~2,000-line `execute()` then `processRoutePairs()`:
107
107
  5. **Transaction logging** — every request logged (to client/core Logs DB, or as a JSONL
108
108
  line shipped by CloudWatch when `[api] log_filepath` is set).
109
109
 
110
+ > **⚠ Auto-generated `Api.transactionId` collides under concurrency → 1062 → HTTP 500.**
111
+ > Separate from the *client-supplied* `transactionId` uniqueness check (EV-5, above): the inbound
112
+ > request-logger inserts its `Api` log row with a UNIQUE `transactionId` set to a
113
+ > millisecond-precision timestamp (`Y-m-d H:i:s.v`). Concurrent nested writes generated within the
114
+ > same millisecond collide on that UNIQUE key → MySQL **1062** → **HTTP 500**. This breaks ingestion
115
+ > for high-volume senders (seen on the Compass/Veyer ASN feed, `sourceIp 34.232.23.158`) and is
116
+ > platform-wide. Fix direction: make the logged `transactionId` unique-enough (uuid) or
117
+ > retry-on-1062. Until fixed, a burst of concurrent posts can intermittently 500 with no app-level cause.
118
+
110
119
  ## CRUD engine — `processRoutePairs()`
111
120
 
112
121
  **Metadata-driven** — routes/models/fields/permissions come from Core/Client DB tables, not
@@ -222,5 +231,6 @@ they are the known sharp edges. Do not re-discover these from scratch.
222
231
 
223
232
  ## Change history
224
233
  - 2026-07-28 — Added a consolidated **Known issues / accepted risks** section (8 items), absorbing the previously free-floating deferred raw-exception-disclosure follow-up as item 1, so the tier's sharp edges (unrotated committed secrets, pre-execute phase still outside the main guard, local Logs DB name mismatch, permissive CORS, unpinned `_underscore` build clone, untested `V2.php` monolith, JWT rotation overlap window) are in one place instead of scattered. Recorded that `DB_CACHE` is resolved by name (`Databases.name = 'Cache'`), never by a hardcoded id, which differs per Core instance. (jcardinal)
234
+ - 2026-07-28 — Added gotcha: the request-logger's auto-generated `Api.transactionId` (millisecond timestamp `Y-m-d H:i:s.v`, UNIQUE) collides under concurrent same-millisecond nested writes → MySQL 1062 → HTTP 500; platform-wide, observed on the Compass/Veyer ASN feed (`sourceIp 34.232.23.158`). Distinct from the client-supplied `transactionId`/EV-5 uniqueness contract. Fix direction: uuid the logged id or retry-on-1062. (bala)
225
235
  - 2026-07-27 — Sharpened the committed-secret note: the plaintext GitHub PAT lives in the per-env **`.ebextensions/git.*.json`** files (used by the `prebuild/git.sh` clone hook to pull `_underscore`), must be rotated and moved to SSM / EB env properties (location + remediation only, no value). (mhammontree)
226
236
  - 2026-07-23 — Documented the now-guarded Core/Logs DB bootstrap in the front controller: the pre-execute block runs before the `execute()` try/catch, the Core Logs schema name is resolved from a `Core.Database` row (`id = CORE_LOGS_DATABASE_ID`) so a name-mismatched local Logs DB reads as missing, and the failure is now wrapped in `try/catch (\Throwable)` returning `INVALID_CONFIGURATION` + Sentry instead of a fatal (guarded no-op rollback, `Database.php:219–226`). Added the deferred raw-getMessage/getTrace client-disclosure follow-up to the Security note. (jcardinal)
@@ -17,8 +17,8 @@ _Auto-generated by `knowledge.js index`. Do not hand-edit._
17
17
 
18
18
  ## 2.0 framework
19
19
 
20
- - **_underscore** (_Underscore) _(framework core)_ — 39 doc(s) → [2.0/apps/_underscore/INDEX.md](2.0/apps/_underscore/INDEX.md)
21
- - **worker2** (Worker) — 32 doc(s) → [2.0/apps/worker2/INDEX.md](2.0/apps/worker2/INDEX.md)
20
+ - **_underscore** (_Underscore) _(framework core)_ — 40 doc(s) → [2.0/apps/_underscore/INDEX.md](2.0/apps/_underscore/INDEX.md)
21
+ - **worker2** (Worker) — 33 doc(s) → [2.0/apps/worker2/INDEX.md](2.0/apps/worker2/INDEX.md)
22
22
  - **api2** (API) — 18 doc(s) → [2.0/apps/api2/INDEX.md](2.0/apps/api2/INDEX.md)
23
23
  - **dbchanges2** (Database Changes) _(framework core)_ — 3 doc(s) → [2.0/apps/dbchanges2/INDEX.md](2.0/apps/dbchanges2/INDEX.md)
24
24
  - **toga2-supply** (TOGa Supply) — 3 doc(s) → [2.0/apps/toga2-supply/INDEX.md](2.0/apps/toga2-supply/INDEX.md)
@@ -3,5 +3,5 @@
3
3
  | Doc | Framework | Summary | Files |
4
4
  |-----|-----------|---------|-------|
5
5
  | [ODP Tech Support Agent (Office Depot variant)](features/odp-tech-support.md) | 2.0 | ODP-specific customization of the shared TOGa Voice agent. | voice-to-voice/clients/odp/agent.py, voice-to-voice/clients/odp/agents/tech_support.py, voice-to-voice/clients/odp/prompts.py, voice-to-voice/clients/odp/config.yaml, voice-to-voice/clients/odp/.env.example |
6
- | [TOGaRefresh 2026 — linkBuilderRefresh URL Routing (ODP)](features/togarefresh2026-linkbuilder-routing.md) | 1.0 | In the TOGaRefresh 2026 UI, any button that navigates to the service builder must use `App_Model_ServiceRequest::linkBuilderRefresh()` (→ `togarefresh2026_servi | toga/app/togarefresh2026/servicerequests/review.php, toga/app/togarefresh2026/customers/view.php, library/app/model/toga/diagnostic.php |
6
+ | [TOGaRefresh 2026 — linkBuilderRefresh URL Routing (ODP)](features/togarefresh2026-linkbuilder-routing.md) | 1.0 | In the TOGaRefresh 2026 UI, any button that navigates to the service builder must use `App_Model_ServiceRequest::linkBuilderRefresh()` (→ `togarefresh2026_servi | toga/app/togarefresh2026/servicerequests/review.php, toga/app/togarefresh2026/customers/view.php, toga/app/togarefresh2026/servicerequests/view.php, library/app/model/toga/diagnostic.php |
7
7
  | [Office Depot](profile.md) | 2.0 | Office Depot is the first deployed tenant of the **TOGa Voice** (`voice-to-voice`) platform. | |
@@ -6,11 +6,12 @@ project: TOGa
6
6
  client: office-depot
7
7
  type: client-feature
8
8
  status: active
9
- updated: 2026-06-23
9
+ updated: 2026-07-28
10
10
  owners: [snaredla]
11
11
  files:
12
12
  - toga/app/togarefresh2026/servicerequests/review.php
13
13
  - toga/app/togarefresh2026/customers/view.php
14
+ - toga/app/togarefresh2026/servicerequests/view.php
14
15
  - library/app/model/toga/diagnostic.php
15
16
  related:
16
17
  - ../profile.md
@@ -22,6 +23,11 @@ In the TOGaRefresh 2026 UI, any button that navigates to the service builder mus
22
23
  `App_Model_ServiceRequest::linkBuilderRefresh()` (→ `togarefresh2026_servicerequests_serviceselection`)
23
24
  instead of the legacy `linkBuilder()` (→ `servicerequests_builder`).
24
25
 
26
+ The "View Recommended Services" button has two placements in the SR view: a static page-load
27
+ button and a per-device dialog button. Both route to either `serviceselection` (repair +
28
+ subscription) or `techsupport` based on the device's latest diagnostic `resultCode`.
29
+ See the library doc for full routing details.
30
+
25
31
  ## Key files / entry points
26
32
  - `App_Model_ServiceRequest::BUILDER` = `'servicerequests_builder'` (legacy)
27
33
  - `App_Model_ServiceRequest::BUILDERREFRESH` = `'togarefresh2026_servicerequests_serviceselection&jobJacketId=1'`
@@ -34,16 +40,21 @@ Files updated to use `linkBuilderRefresh`:
34
40
  |------|----------|---------|
35
41
  | `togarefresh2026/servicerequests/review.php` | line 217 | Quote submission POST URL for non-EXISTING job jackets |
36
42
  | `togarefresh2026/customers/view.php` | line 64 | "Create Work Order For This Customer" button |
37
- | `library/app/model/toga/diagnostic.php` | `initializeDiagnosticDialog()` | Device modal button — routes by diagnostic pass/fail (see library doc) |
43
+ | `togarefresh2026/servicerequests/view.php` | `LatestPerDevice` block | Static "View Recommended Services" button — resultCode-based per-device routing |
44
+ | `library/app/model/toga/diagnostic.php` | `initializeDiagnosticDialog()` | Device modal button — dynamic URL via `_deviceBuilderUrl` JS global |
38
45
 
39
46
  ## Gotchas / known issues
40
47
  - The old `servicerequests/view.php` (legacy non-refresh view) intentionally keeps `linkBuilder()` — 2026 stores do not route through it.
41
48
  - Do not change `App_Model_ServiceRequest::BUILDER` — legacy stores still depend on it.
42
- - The diagnostic modal routing is pass/fail conditional — see `1.0/apps/library/features/diagnostic-dialog-view-recommended-services.md` for full details.
43
- - `toga` app repo was not in registry.json before this session — it has been added (framework 1.0, role app, dependsOn library).
49
+ - The static button (view.php) uses a `MAX(id) GROUP BY deviceId` LatestPerDevice subquery — any device with a current problem code triggers repair for the whole SR.
50
+ - The device dialog button routes per-device: only the clicked device's latest result determines the URL.
51
+ - Routing uses `resultCode` (not `pass`) — `pass=1` appears on both problem and clean results for BITSTORM. See the library doc for full result code list.
52
+ - `toga` app repo was not in registry.json before 2026-06-23 — it has been added (framework 1.0, role app, dependsOn library).
44
53
 
45
54
  ## Related docs
46
55
  - `1.0/apps/library/features/diagnostic-dialog-view-recommended-services.md`
47
56
 
48
57
  ## Change history
58
+ - 2026-07-28 — Added view.php to files list. Updated routing description from pass-based
59
+ to resultCode-based per-device. Two-button architecture documented. (snaredla)
49
60
  - 2026-06-23 — Initial doc. All 2026 builder buttons now use linkBuilderRefresh; diagnostic modal uses pass/fail routing. toga added to registry.json. (snaredla)
@@ -3,9 +3,10 @@
3
3
  | Doc | Framework | Summary | Files |
4
4
  |-----|-----------|---------|-------|
5
5
  | [Prudential: Dell ASN units PRE/POST interceptor (legacy key + flat tracking)](features/dell-asn-units-interceptor.md) | 2.0 | After the tracking-number bridge migration, the ASN unit route was renamed (`advance-shipping-notice-units` → `advance-shipping-notice-item-units`), so the inhe | _underscore/Model/Prudential/AdvanceShippingNotice.php, dbchanges2/Client_Prudential/2026-06-10 - AsnUnitsInterceptor.sql |
6
- | [Prudential: Dell LCH IOP transmissions (LCHRequestV2)](features/dell-lch-iop-transmissions.md) | 1.0 | Outbound order transmissions from the 1.0 worker tier to Dell's Lifecycle Hub (LCH) ITSM integration. | library/app/api/delllch.php, library/app/apitransaction.php, worker/crons/toga2/prudential/transmissions_to_dell_usa.php, worker/crons/toga2/prudential/transmissions_to_dell_ireland.php, worker/crons/toga2/prudential/transmissions_to_dell_india.php |
6
+ | [Prudential: Dell LCH IOP transmissions (LCHRequestV2)](features/dell-lch-iop-transmissions.md) | 1.0 | Outbound order transmissions from the 1.0 worker tier to Dell's Lifecycle Hub (LCH) ITSM integration. | library/app/api/delllch.php, library/app/apitransaction.php, worker/crons/toga2/prudential/transmissions_to_dell_usa.php, worker/crons/toga2/prudential/transmissions_to_dell_ireland.php, worker/crons/toga2/prudential/transmissions_to_dell_india.php, worker/crons/toga2/prudential/generate_sales_and_purchase_orders_from_service_requests.php |
7
7
  | [Prudential: Device information import + unit→contact linking (import_device_information.php)](features/device-information-import-and-contact-linking.md) | 1.0 | Prudential's **device-sync** cron (`worker/crons/toga2/prudential/import_device_information.php`) pulls device/asset records (from ServiceNow / the Dell CMDB fe | worker/crons/toga2/prudential/import_device_information.php, worker/crons/toga2/prudential/backfill_unit_contacts.php, dbchanges2/Client_Prudential/2026-07-07 - Contact Dedup Merge.sql |
8
8
  | [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 | _underscore/Model/Prudential/ServiceRequest.php, _underscore/Test/Prudential/ServiceRequestTest.php |
9
+ | [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 | worker2/Worker/Client/Prudential/reports/ReqRejectionEmail.php, _underscore/Model/Prudential/ServiceRequest.php |
9
10
  | [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. | worker/crons/toga2/prudential/transmit_ordershipped_updates_prudential.php, worker/crons/toga2/prudential/transmit_closecomplete_updates_prudential.php, worker/crons/toga2/prudential/transmit_rejected_cancelled_updates_prudential.php, worker/crons/toga2/prudential/generate_sales_and_purchase_orders_from_service_requests.php, worker/crons/toga2/prudential_beta/generate_sales_and_purchase_orders_from_service_requests.php, worker/crons/notifications/reports/prudential_exception_report.php |
10
11
  | [Prudential Financial](profile.md) | 2.0 | Prudential is a TOGA client whose device-fulfillment flow is driven by **Dell** via the Dell API (`Client_Prudential.Apis.id = 2`). | |
11
12
  | [Prudential: Dell ASN failed POST backfill replay](workflows/dell-asn-backfill-replay.md) | 2.0 | When Dell ASN POSTs fail in bulk (e.g. | |
@@ -5,13 +5,14 @@ project: _Underscore
5
5
  client: prudential
6
6
  type: client-feature
7
7
  status: active
8
- updated: 2026-06-12
9
- owners: ["jcardinal", "rgirish"]
8
+ updated: 2026-07-28
9
+ owners: ["jcardinal", "rgirish", "bala"]
10
10
  files:
11
11
  - _underscore/Model/Prudential/AdvanceShippingNotice.php
12
12
  - dbchanges2/Client_Prudential/2026-06-10 - AsnUnitsInterceptor.sql
13
13
  related:
14
14
  - ../../../2.0/apps/_underscore/features/tracking-number-bridges.md
15
+ - transmit-ordershipped-email.md
15
16
  ---
16
17
 
17
18
  ## Summary
@@ -60,6 +61,24 @@ UPDATE Core.RecordFields SET childPolicy = 'MATCH_CREATE' WHERE id = 2198;
60
61
  found. `MATCH` (the default) errors when no row exists — that was the root cause of the Jun 2026
61
62
  `EV-12` outage.
62
63
 
64
+ ## Inbound endpoint & logging (where a Dell ASN lands)
65
+
66
+ - **Endpoint:** Dell ASNs arrive via inbound `POST /v2/advance-shipping-notices`; **201** =
67
+ accepted/created.
68
+ - **Success vs. failure log split.** Successful ASN POSTs are logged in `Logs_Prudential.Api`, but
69
+ **failures often land in the shared base `Logs` schema, not the client log** — so a "no trace in
70
+ `Logs_Prudential.Api`" ASN may have failed and been recorded in `Logs.Api` instead. Check both.
71
+ - **Filter by `sourceIp` to avoid conflating clients.** Prudential's ASN traffic comes from
72
+ **`13.86.101.210`** (apiId 2). A *different* client's ASN feed comes from **`34.232.23.158`** —
73
+ when auditing the shared `Logs` schema, filter on `sourceIp` or you will mix the two clients'
74
+ ASNs together.
75
+ - **`AdvanceShippingNotices.purchaseOrderId` links an ASN to its PO.** A NULL/unmatched
76
+ `purchaseOrderId` corresponds to a **400 `EV-12`** on the inbound POST (the ASN could not be
77
+ matched/created). A successful 201 with a matched PO is what then triggers the shipped-email
78
+ chain (`transmit_ordershipped_updates_prudential.php` stamps
79
+ `c_dtTransmittedOrderShippedUpdateToPrudential` + `c_dtEmailSentOrderShipped`) — see
80
+ `transmit-ordershipped-email.md`.
81
+
63
82
  ## Gotchas
64
83
  - Scoped to `apiId = 2` so other (internal) Prudential callers — which already send the new shape —
65
84
  are not double-transformed.
@@ -72,6 +91,11 @@ found. `MATCH` (the default) errors when no row exists — that was the root cau
72
91
  replay workflow doc.
73
92
 
74
93
  ## Change history
94
+ - 2026-07-28 — Documented the inbound ASN endpoint (`POST /v2/advance-shipping-notices`, 201 =
95
+ success), the success-vs-failure log split (successes in `Logs_Prudential.Api`, failures often
96
+ in shared base `Logs`), the `sourceIp` filter to separate Prudential (`13.86.101.210`) from
97
+ another client's ASN feed (`34.232.23.158`), and that `AdvanceShippingNotices.purchaseOrderId`
98
+ links the ASN to its PO (unmatched = 400 `EV-12`) and gates the shipped-email chain. (bala)
75
99
  - 2026-06-12 — Fixed interceptor bug: bridge row keys were `trackingNumber`/`returnTrackingNumber`
76
100
  instead of `trackingNumberId`/`returnTrackingNumberId`, causing EV-12 on all Dell ASN POSTs. Also
77
101
  set `Core.RecordFields childPolicy = MATCH_CREATE` on ids 1437 and 2198. Replayed 28 failed payloads
@@ -6,7 +6,7 @@ project: Worker
6
6
  client: prudential
7
7
  type: client-feature
8
8
  status: active
9
- updated: 2026-07-24
9
+ updated: 2026-07-28
10
10
  owners: ["rgirish", "bala"]
11
11
  files:
12
12
  - library/app/api/delllch.php
@@ -14,6 +14,7 @@ files:
14
14
  - worker/crons/toga2/prudential/transmissions_to_dell_usa.php
15
15
  - worker/crons/toga2/prudential/transmissions_to_dell_ireland.php
16
16
  - worker/crons/toga2/prudential/transmissions_to_dell_india.php
17
+ - worker/crons/toga2/prudential/generate_sales_and_purchase_orders_from_service_requests.php
17
18
  related:
18
19
  - ../profile.md
19
20
  - dell-asn-units-interceptor.md
@@ -57,9 +58,12 @@ WHERE PurchaseOrders.vendorId = 1 # Dell = Vendors.id 1
57
58
  ```
58
59
 
59
60
  - **Region lives on `ServiceRequests.customerId`**, resolved through the join to
60
- `Client_Prudential.Customers`. Region rows: **USA = id 3, uuid
61
- `b3f7a2c1-4e89-4d6a-9c3b-8f1e5d2a7b04`**; India and Ireland are separate `Customers` rows /
62
- uuids. This is NOT the SalesOrder customer — the SO customer is always
61
+ `Client_Prudential.Customers`. Region rows:
62
+ - **USA — id 3, uuid `b3f7a2c1-4e89-4d6a-9c3b-8f1e5d2a7b04`**
63
+ - **Ireland — id 5, uuid `9a4e7d2b-5f13-48c6-b8e1-3d6a9c2f7e85`**
64
+ - **India — id 4, uuid `e6d1c8f4-2a75-4b3e-a9d7-1c4f6e8b3a52`**
65
+
66
+ This is NOT the SalesOrder customer — the SO customer is always
63
67
  **"Agilant - Tech Hub" (id 1, uuid `36da53f8-38d2-404f-becf-f58f33c215d3`)**.
64
68
  - A REQ is only ever transmitted, validated, or marked once it is selected here. Unit/bundle/SO/PO
65
69
  creation happens elsewhere and does **not** send anything to Dell.
@@ -172,6 +176,17 @@ This is the established pattern for all Prudential script changes.
172
176
  stuck this way, all with `customerId` NULL. The 2.0-side guard added in
173
177
  `service-request-address-validation.md` stops NEW customer-less REQs; already-stuck ones need a
174
178
  `customerId` backfill from ship-to country.
179
+ - **Root cause is a TOGa-side mapping gap, not missing SNOW data.** The inbound ServiceNow
180
+ payload carries **no customer field** at all — `ServiceRequests.customerId` is assigned
181
+ *inside TOGa* by `generate_sales_and_purchase_orders_from_service_requests.php`, so a NULL
182
+ means the generation cron failed to derive/assign a region, not that SNOW omitted anything.
183
+ - **The country-split cutover (~2026-07-17) is when this started biting.** Before the split,
184
+ `transmissions_to_dell_v1.php` had **no customer gate**, so every REQ transmitted regardless.
185
+ The regional split files added `INNER JOIN Customers` + `WHERE Customers.uuid = '<region>'`,
186
+ which is what drops NULL-`customerId` REQs. ~38 REQs stranded this way since the cutover.
187
+ - **Remediation:** backfill `ServiceRequests.customerId = 3` (USA) for the stuck US-ship-to
188
+ REQs; **durable fix** = have the generation cron derive country/`customerId` from
189
+ `deliverToAddress` so `customerId` is never NULL.
175
190
  - **`dtSubmitted` is stamped without inspecting the response (latent bug, not yet fixed).** Every
176
191
  regional cron sets `PurchaseOrders.dtSubmitted = NOW()` immediately after the send without
177
192
  checking Dell's HTTP status, and `App_ApiTransaction::execute()` (`library/app/apitransaction.php`)
@@ -190,6 +205,14 @@ This is the established pattern for all Prudential script changes.
190
205
  do not reintroduce them.
191
206
 
192
207
  ## Change history
208
+ - 2026-07-28 — Recorded all three region rows (USA id 3, Ireland id 5, India id 4, with uuids) and
209
+ the root cause of NULL `ServiceRequests.customerId`: the inbound SNOW payload carries no customer
210
+ field, so `customerId` is assigned TOGa-side by
211
+ `generate_sales_and_purchase_orders_from_service_requests.php` — a NULL is a TOGa mapping gap.
212
+ The ~2026-07-17 regional-split cutover added the `INNER JOIN Customers` gate (the old
213
+ `transmissions_to_dell_v1.php` had none), stranding ~38 REQs. Durable fix = derive
214
+ country/`customerId` from `deliverToAddress` in the generation cron; interim = backfill
215
+ `customerId = 3` for stuck US-ship-to REQs. (bala)
193
216
  - 2026-07-24 — Documented the live-cron reality (production is the three regional split files under
194
217
  `crons/toga2/prudential/`; monolith `transmissions_to_dell.php` disabled, `_v1` dead), the
195
218
  selection query, region model on `ServiceRequests.customerId` (USA id 3), the `App_Api_Delllch`
@@ -6,7 +6,7 @@ project: _Underscore
6
6
  client: prudential
7
7
  type: client-feature
8
8
  status: active
9
- updated: 2026-07-24
9
+ updated: 2026-07-28
10
10
  owners: ["rgirish", "bala"]
11
11
  files:
12
12
  - _underscore/Model/Prudential/ServiceRequest.php
@@ -15,6 +15,7 @@ related:
15
15
  - clients/prudential/profile.md
16
16
  - clients/prudential/features/dell-asn-units-interceptor.md
17
17
  - clients/prudential/features/dell-lch-iop-transmissions.md
18
+ - clients/prudential/features/service-request-rejection-alert-email.md
18
19
  - ../../../2.0/apps/_underscore/features/model-interceptor-unit-testing.md
19
20
  ---
20
21
 
@@ -47,8 +48,11 @@ back to USA rules.
47
48
  `Client_Prudential.Customers` for the matching `name`. Returns `'India'`, `'Ireland'`, or
48
49
  `'USA'` (default for anything unrecognised or when uuid is null).
49
50
  3. A `match` expression dispatches to the correct validator.
50
- 4. On any validation failure, an `Exception` is thrown with a semicolon-delimited list of all
51
- errors — the API engine catches it and returns a 4xx to Dell.
51
+ 4. **Errors are collected, not fail-fast.** `prePost` runs all validators and accumulates every
52
+ error. On any failure it enqueues the internal rejection-alert worker task (see
53
+ `service-request-rejection-alert-email.md`) and then throws a single `_Exception_Validation`
54
+ carrying all errors — the API engine maps it to a 400 (behavior unchanged from the caller's
55
+ perspective; the change is that all errors are now reported at once and staff are alerted).
52
56
 
53
57
  ## Data model
54
58
 
@@ -109,8 +113,22 @@ with a space (e.g. `F92 FP83`). No numeric restriction.
109
113
  when Prudential starts including them.
110
114
  - **`line3` is India-only.** USA and Ireland validators do not check `line3` (it is not in
111
115
  their Dell spec). If Dell starts sending it for USA/Ireland it is silently ignored.
116
+ - **⚠ OPEN (pre-existing HIGH) — unguarded unit access after the validation block throws a 500.**
117
+ Code that runs *after* the validation block reads
118
+ `serviceRequestUnits[0]->unit->serialNumber` / `->assetTag` without guarding for a missing
119
+ `serviceRequestUnits`. A payload with no units throws an **uncaught non-validation
120
+ `_Exception` → HTTP 500** (which dumps globals in debug mode) and, because it is not an
121
+ `_Exception_Validation`, it **skips the rejection-alert email** entirely. Fix: guard the unit
122
+ access (or validate `serviceRequestUnits` presence inside the collected-error block) so it
123
+ fails as a 400 with an alert instead of a silent 500. Tracked as a follow-up.
112
124
 
113
125
  ## Change history
126
+ - 2026-07-28 — `prePost` now **collects all validation errors** (was fail-fast) and, on any
127
+ failure, enqueues the internal rejection-alert worker task before throwing one
128
+ `_Exception_Validation` with all errors (400 unchanged). See
129
+ `service-request-rejection-alert-email.md`. Also recorded the open HIGH: unguarded
130
+ `serviceRequestUnits[0]->unit` access after the validation block throws a 500 and skips the
131
+ alert. (bala)
114
132
  - 2026-07-24 — Added `validateCustomer()` as the first check in `prePost`: rejects a create when
115
133
  `customer.uuid` is missing/null/blank (`trim(... ?? '') === ''`) via `_Exception_Validation`
116
134
  (→ HTTP 400), stopping customer-less/unroutable REQs at the boundary. Previously a blank uuid
@@ -0,0 +1,94 @@
1
+ ---
2
+ title: "Prudential: Service Request rejection alert email"
3
+ framework: "2.0"
4
+ repo: worker2
5
+ project: Worker
6
+ client: prudential
7
+ type: client-feature
8
+ status: active
9
+ updated: 2026-07-28
10
+ owners: ["bala"]
11
+ files:
12
+ - worker2/Worker/Client/Prudential/reports/ReqRejectionEmail.php
13
+ - _underscore/Model/Prudential/ServiceRequest.php
14
+ related:
15
+ - service-request-address-validation.md
16
+ - ../../../2.0/apps/_underscore/features/email-send-pipeline.md
17
+ - ../profile.md
18
+ ---
19
+
20
+ ## Summary
21
+
22
+ When a Prudential ServiceNow→TOGa service-request submission (`POST /v2/service-requests`)
23
+ is **rejected by validation**, TOGa now sends a real-time internal alert email so staff know
24
+ the instant a REQ fails and can tell Prudential exactly what to resubmit. The
25
+ `_Model_Prudential_ServiceRequest::prePost()` interceptor collects **every** validation error
26
+ (not fail-fast), enqueues a worker task carrying the REQ number + reason + full payload, then
27
+ throws a single `_Exception_Validation` — the 400 returned to ServiceNow is unchanged. The
28
+ worker action `_Worker_Client_Prudential_reports_ReqRejectionEmail::sendRejectionEmail` sends
29
+ the alert.
30
+
31
+ ## Scope
32
+
33
+ - **Validation rejections only.** The MySQL 1062 duplicate-key race on `ServiceRequests.number`
34
+ (a duplicate SR-number re-POST) is deliberately **out of scope** — it is an internal defect
35
+ routed to engineering, not a "resubmit this" message for Prudential.
36
+
37
+ ## Key files / entry points
38
+
39
+ - `_underscore/Model/Prudential/ServiceRequest.php` — `prePost()` collects all validator errors
40
+ and, on any failure, enqueues the alert via
41
+ `_Worker::runTask('Client/Prudential/reports/ReqRejectionEmail/sendRejectionEmail', {reqNumber, reason, payload})`
42
+ before throwing one `_Exception_Validation` carrying all errors.
43
+ - `worker2/Worker/Client/Prudential/reports/ReqRejectionEmail.php` —
44
+ `sendRejectionEmail(string $reqNumber, string $reason, object|array $payload): ...` builds and
45
+ sends a **plain-text** email (REQ# + reason + full JSON payload) to a class-constant recipient
46
+ list (currently `vburks@togatech.com`, `adiamond@togatech.com`).
47
+
48
+ ## How it works
49
+
50
+ 1. `prePost()` runs every validator, **accumulating** errors instead of throwing on the first.
51
+ 2. If the error list is non-empty, it enqueues one `ReqRejectionEmail/sendRejectionEmail` worker
52
+ task with the REQ number, the joined reason string, and the full inbound payload.
53
+ 3. It then throws a single `_Exception_Validation` holding all errors → `Controller/Index.php`
54
+ maps it to **HTTP 400** to ServiceNow (unchanged behavior).
55
+ 4. The worker action assembles the plain-text body and sends it via `_Email`. Because sends run
56
+ through the 2.0 email-send pipeline, the message lands within ~1 minute (see
57
+ `email-send-pipeline.md`).
58
+
59
+ ## Send guards (why a queue failure can't turn the 400 into a 500)
60
+
61
+ - `setIsHtml(false)` — plain-text intent (see caveat below).
62
+ - `setClientIdentifier('Prudential')` and a fixed From of `noreply@togatech.com`.
63
+ - `json_encode(..., JSON_INVALID_UTF8_SUBSTITUTE)` with a false-fallback so a bad byte can't
64
+ fatal the encode; control characters stripped from `reqNumber`.
65
+ - The **whole dispatch is wrapped in a `\Throwable` catch** that `error_log`s an
66
+ identifier-only message (never PII) — so if the queue/email dispatch fails, the original
67
+ validation 400 is still what ServiceNow receives; the alert failure never escalates to a 500.
68
+
69
+ ## Accepted residual risk (data-owner decision)
70
+
71
+ The **full inbound payload (including PII) is emailed** and persisted to `Logs_Prudential.Email`.
72
+ This was an explicit data-owner decision to maximize the staff alert's usefulness, accepted as a
73
+ known residual risk.
74
+
75
+ ## Gotchas / known issues
76
+
77
+ - **A non-validation exception skips the alert.** The alert only fires for
78
+ `_Exception_Validation`. A payload that throws a *different* uncaught `_Exception` before the
79
+ validation block completes (e.g. the unguarded `serviceRequestUnits[0]->unit` access — see
80
+ `service-request-address-validation.md`) produces a **500 and no alert**. Fix that guard to
81
+ keep the alert reliable.
82
+ - **"Plain text" is transmitted HTML anyway.** `setIsHtml(false)` is honored by `_Email` when it
83
+ queues the row, but the worker2 `Infrastructure/Email/Send` action calls `IsHTML(true)`
84
+ unconditionally, so the message is sent HTML-mode regardless (see `email-send-pipeline.md`).
85
+
86
+ ## Change history
87
+ - 2026-07-28 — Created: real-time internal alert on `POST /v2/service-requests` validation
88
+ rejection. `prePost()` now collects all validation errors and enqueues the
89
+ `ReqRejectionEmail/sendRejectionEmail` worker task (REQ# + reason + full payload) before
90
+ throwing one `_Exception_Validation` (400 unchanged). Recipients are a class constant
91
+ (`vburks@`, `adiamond@`). Whole dispatch wrapped in `\Throwable` catch so a queue failure can't
92
+ turn the 400 into a 500. Scope: validation rejections only (the 1062 SR-number duplicate race
93
+ is routed to engineering). Accepted residual risk: full payload PII is emailed + persisted to
94
+ `Logs_Prudential.Email`. (bala)
@@ -7,17 +7,19 @@ apps:
7
7
  - dbchanges2
8
8
  - websocket
9
9
  - worker
10
+ - worker2
10
11
  - library
11
12
  project: _Underscore
12
13
  client: prudential
13
14
  type: profile
14
15
  status: active
15
- updated: 2026-07-07
16
+ updated: 2026-07-28
16
17
  owners: ["jcardinal", "rgirish", "bala"]
17
18
  files: []
18
19
  related:
19
20
  - features/dell-asn-units-interceptor.md
20
21
  - features/service-request-address-validation.md
22
+ - features/service-request-rejection-alert-email.md
21
23
  - features/dell-lch-iop-transmissions.md
22
24
  - features/device-information-import-and-contact-linking.md
23
25
  ---
@@ -41,6 +43,23 @@ order-status transmissions.
41
43
  POST to Dell's LCH `LCHRequestV2` endpoint via the 1.0 `App_Api_Delllch` client. See the
42
44
  Dell LCH IOP transmissions feature doc.
43
45
 
46
+ ## Reference data (enums)
47
+
48
+ `Client_Prudential.PurchaseOrders.purchaseOrderStageId` — PO lifecycle stage:
49
+
50
+ | id | Stage | id | Stage |
51
+ |----|---------------|----|---------------|
52
+ | 3 | Open | 4 | Configuration |
53
+ | 9 | Accepted | 5 | Ready To Ship |
54
+ | 7 | In Progress | 2 | Received |
55
+ | 6 | POD | 1 | Rejected |
56
+ | 8 | Cancelled | | |
57
+
58
+ - PO lifecycle is tracked by `purchaseOrderStageId`. The `dtAcknowledged` column is **unused
59
+ (always NULL)** — do not key logic on it.
60
+
61
+ `ServiceRequests.serviceRequestTypeId`: **1 = New Hire, 2 = Breakfix, 3 = Refresh, 5 = Reclaim**.
62
+
44
63
  ## Gotchas
45
64
  - Dell will not change their payload shape — see the Dell ASN units interceptor feature doc for the
46
65
  PRE/POST translation that keeps their feed working after the tracking-number bridge migration.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toga-ai",
3
- "version": "1.0.460",
3
+ "version": "1.0.462",
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",