toga-ai 1.0.119 → 1.0.121
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/1.0/apps/worker/INDEX.md +1 -0
- package/knowledge/1.0/apps/worker/features/compass-partial-in-transit-delivered-emails.md +134 -0
- package/knowledge/1.0/apps/worker/features/netsuite-togasupply-per-client-sync.md +52 -6
- package/knowledge/2.0/apps/_underscore/features/email-template-sending.md +24 -3
- package/knowledge/2.0/apps/api2/INDEX.md +1 -0
- package/knowledge/2.0/apps/api2/features/scripted-api-post-body-args.md +90 -0
- package/knowledge/INDEX.md +1 -1
- package/package.json +1 -1
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
| Doc | Summary | Files |
|
|
4
4
|
|-----|---------|-------|
|
|
5
5
|
| [Worker (1.0 Framework) Architecture](architecture.md) | `worker` is the legacy (**1.0** `App_` framework) **background-job tier**. | worker/index.php, worker/_/app/framework.php, worker/crons/, worker/schedules/, worker/ebs/cron.worker.php, worker/.ebextensions/035_cron.worker.config |
|
|
6
|
+
| [Compass Partial In-Transit & Delivered Emails (per package)](features/compass-partial-in-transit-delivered-emails.md) | Compass USA and Compass Canada send a **per-package** in-transit email (and a matching delivered email) instead of one email listing the whole order. | worker/crons/toga2/compass/update_salesorder_status_from_odp.php, worker/crons/toga2/compass/workflow/test_partial_in_transit_email.php, worker/crons/toga2/compasscanada/workflow/3_update_salesorder_status_from_grand_and_toy.php, worker/crons/toga2/compasscanada/workflow/test_partial_in_transit_email.php |
|
|
6
7
|
| [Forecast2 ↔ NetSuite Reconciliation & Trueup Tooling](features/forecast2-netsuite-reconciliation.md) | CLI tools to **audit** and **repair** drift between the production `Forecast` DB (core2) and NetSuite. | test/@dave/reconcile_netsuite_totals.php, test/@dave/analyze_netsuite_forecast_diff.php, test/@dave/trueup_sales.php, test/@dave/trueup_open_orders.php, test/@dave/trueup_opportunities.php, test/@dave/probe_sales_gap_direct.php, worker/crons/toga2/forecast2/common_import_sales_from_netsuite.php |
|
|
7
8
|
| [NetSuite → TOGa Supply Per-Client Sync (thin wrappers)](features/netsuite-togasupply-per-client-sync.md) | Syncs NetSuite transactions (sales orders, purchase orders, invoices, item receipts, item fulfillments, inventory adjustments) into each TOGa Supply (2.0) clien | worker/crons/toga2/netsuite/common_sync_togasupply.php, worker/crons/toga2/netsuite/sync_togasupply_canon.php, worker/schedules/cron.worker.sync.json, dbchanges2/_modules/netsuite/2026-04-01 - Parameters.sql |
|
|
8
9
|
| [Prudential: Send Shipments for the Day report (daily cron)](features/send-shipments-for-the-day.md) | Daily cron (9:00 PM) that emails Prudential and Dell stakeholders an Excel report of all devices shipped that day, including tracking number, serial number, emp | worker/crons/notifications/reports/send_shipments_for_the_day.php |
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Compass Partial In-Transit & Delivered Emails (per package)
|
|
3
|
+
framework: "1.0"
|
|
4
|
+
repo: worker
|
|
5
|
+
project: Worker
|
|
6
|
+
client: shared
|
|
7
|
+
type: feature
|
|
8
|
+
status: active
|
|
9
|
+
updated: 2026-06-18
|
|
10
|
+
owners: ["bala"]
|
|
11
|
+
files:
|
|
12
|
+
- worker/crons/toga2/compass/update_salesorder_status_from_odp.php
|
|
13
|
+
- worker/crons/toga2/compass/workflow/test_partial_in_transit_email.php
|
|
14
|
+
- worker/crons/toga2/compasscanada/workflow/3_update_salesorder_status_from_grand_and_toy.php
|
|
15
|
+
- worker/crons/toga2/compasscanada/workflow/test_partial_in_transit_email.php
|
|
16
|
+
related: []
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
## Summary
|
|
20
|
+
|
|
21
|
+
Compass USA and Compass Canada send a **per-package** in-transit email (and a matching
|
|
22
|
+
delivered email) instead of one email listing the whole order. For each tracking number the
|
|
23
|
+
cron builds three zones: **Items in this shipment** (just that package), **Already shipped**
|
|
24
|
+
(earlier packages), and **Remaining items being prepared** (ordered minus what shipped in this
|
|
25
|
+
and earlier packages). The dynamic HTML is injected into a stored `EmailTemplates` body via the
|
|
26
|
+
`sendEmail` scripted API. Canada renders EN or FR per the user's language setting.
|
|
27
|
+
|
|
28
|
+
## Key files / entry points
|
|
29
|
+
|
|
30
|
+
- `worker/crons/toga2/compass/update_salesorder_status_from_odp.php` — Compass USA prod cron.
|
|
31
|
+
- `worker/crons/toga2/compasscanada/workflow/3_update_salesorder_status_from_grand_and_toy.php`
|
|
32
|
+
— Compass Canada prod cron.
|
|
33
|
+
- `*/workflow/test_partial_in_transit_email.php` — read-only prod test scripts for each client
|
|
34
|
+
(email goes only to a test recipient, no DB writes). Use these to preview rendering.
|
|
35
|
+
- Shared helper set in each cron: `getOrderFulfillmentData`, `buildItemRowsHtml`,
|
|
36
|
+
`buildSectionHeaderHtml`, `buildPackageBlockHtml`, `buildTrackingUrl`, `buildOrderItemsHtml`.
|
|
37
|
+
|
|
38
|
+
## How it works
|
|
39
|
+
|
|
40
|
+
1. **Driving query** finds shipped sales orders and their tracking numbers (`computedStatusSlug
|
|
41
|
+
= 'shipped'`, `c_dtInTransitEmailSent IS NULL`, date cutoff) via the ASN chain
|
|
42
|
+
(`AdvanceShippingNoticeItemUnits_TrackingNumbers`), one row per `TrackingNumbers.number`.
|
|
43
|
+
2. Per tracking number: skip if the carrier API already reports delivered.
|
|
44
|
+
3. **`getOrderFulfillmentData`** returns the order's packages (grouped by tracking number,
|
|
45
|
+
ordered by `dtCreated`) and ordered items, sourced from the **`ItemFulfillmentItems`** line
|
|
46
|
+
bridge (`ItemFulfillmentItems` → `ItemFulfillments` (scoped to the buyer SO) →
|
|
47
|
+
`ItemFulfillmentItems_TrackingNumbers`).
|
|
48
|
+
4. The current package's index determines which packages are "Already shipped" (earlier
|
|
49
|
+
`dtCreated`). "Remaining being prepared" is **cumulative as of this package**: ordered minus
|
|
50
|
+
what shipped in this and all earlier packages, so later-package items still show as remaining
|
|
51
|
+
in an earlier package's email.
|
|
52
|
+
5. Build `shipmentItemsHtml` + `additionalSectionsHtml` + `statusMessage`, then send via
|
|
53
|
+
`App_Api_Toga2::send(..., 'POST', '/email-templates/sendEmail', $payload, [], true)`.
|
|
54
|
+
6. On a successful (non-throwing) send, set `c_dtInTransitEmailSent = NOW()`.
|
|
55
|
+
|
|
56
|
+
### Tracking-URL building (`buildTrackingUrl`)
|
|
57
|
+
|
|
58
|
+
The "Track Shipment" button and each per-package link resolve in this precedence:
|
|
59
|
+
1. carrier `ShippingCarriers.urlLinkPrefix` + tracking number — **only when the prefix is an
|
|
60
|
+
append-template** (ends in `=`, or a non-root path ending in `/` like `/odn/`); this yields
|
|
61
|
+
the modern carrier URL.
|
|
62
|
+
2. else `TrackingNumbers.trackingUrl` (covers freight carriers whose prefix is a bare homepage).
|
|
63
|
+
3. else the homepage-style prefix as-is.
|
|
64
|
+
4. else `App_Misc::generateTrackingNumberLink()`.
|
|
65
|
+
The append-template guard (via `parse_url`) is what stops a bare `https://carrier.com/` from
|
|
66
|
+
getting a tracking number glued onto it. Both queries `LEFT JOIN ShippingCarriers` and select
|
|
67
|
+
`urlLinkPrefix AS carrierUrlPrefix`.
|
|
68
|
+
|
|
69
|
+
### POST body transport (HTTP 414 fix)
|
|
70
|
+
|
|
71
|
+
Scripted-API args ride in the URL query string by default
|
|
72
|
+
(`App_Api_Toga2::sendUsingAccessToken` → `assembleOptions`). A multi-package email's HTML
|
|
73
|
+
overflowed Apache's `LimitRequestLine` (~8190) → **HTTP 414**. The fix sends the variables in
|
|
74
|
+
the **POST body** (`$payload`, empty options) and relies on the api2 POST scripted-API body
|
|
75
|
+
merge. `throwExceptionOnApiError = true` so a failed send throws and the cron does **not** mark
|
|
76
|
+
the tracking number as emailed (it retries next run).
|
|
77
|
+
|
|
78
|
+
### Styling spec (matches Figma)
|
|
79
|
+
|
|
80
|
+
- Section headers (`buildSectionHeaderHtml`): all **18px**; first ("Items in this shipment")
|
|
81
|
+
dark `#1a1a1a` weight 700; muted ("Already shipped" / "Remaining items being prepared")
|
|
82
|
+
`#535662` weight 600, each preceded by a 30px / 1px divider / 30px block.
|
|
83
|
+
- Item rows (`buildItemRowsHtml`): title, P/N, and Qty all **14px**. Title medium (500) dark
|
|
84
|
+
`#111928`; P/N `#666666`; Qty regular dark `#111928`.
|
|
85
|
+
- **20px** gap between consecutive "Already shipped" packages (16px spacer + 4px heading top).
|
|
86
|
+
|
|
87
|
+
## Data model
|
|
88
|
+
|
|
89
|
+
- `TrackingNumbers`: `number`, `trackingUrl`, `shippingCarrierId`, `dtCreated`, `status`,
|
|
90
|
+
`c_dtInTransitEmailSent` (the idempotency flag the cron sets).
|
|
91
|
+
- `ShippingCarriers`: `urlLinkPrefix` (carrier URL template).
|
|
92
|
+
- `ItemFulfillmentItems` / `ItemFulfillmentItems_TrackingNumbers` — package contents (line
|
|
93
|
+
bridge). `ItemFulfillments.salesOrderId` ties a package to the buyer SO.
|
|
94
|
+
- `EmailTemplates` — the stored template body with `{placeholder}` tokens.
|
|
95
|
+
|
|
96
|
+
## Client variations
|
|
97
|
+
|
|
98
|
+
- **Compass USA** — English only; `update_salesorder_status_from_odp.php`.
|
|
99
|
+
- **Compass Canada** — EN/FR by `UserGlobalSettings.settingId = 2` (FR when the value starts
|
|
100
|
+
with `fr`); French section labels and a French (HTML-entity) status message; uses
|
|
101
|
+
`htmlentities` in the section header builder and a 7-param French-aware `buildPackageBlockHtml`.
|
|
102
|
+
|
|
103
|
+
## Gotchas / known issues
|
|
104
|
+
|
|
105
|
+
- **Canada now uses the `ItemFulfillmentItems` line bridge, not the ASN chain**, for
|
|
106
|
+
`getOrderFulfillmentData` — same query as Compass USA. Canada was originally on the ASN chain
|
|
107
|
+
because it had **zero `ItemFulfillmentItems`**; if that line-bridge data is not populated for a
|
|
108
|
+
Canada order, `getOrderFulfillmentData` returns no packages and the cron falls back to the
|
|
109
|
+
full email. Verify line-bridge data exists for Canada before relying on the partial path.
|
|
110
|
+
- **Line bridge is moving-forward only** — it began populating ~2026-06-12; pre-cutoff tracking
|
|
111
|
+
numbers are intentionally out of scope (driving-query date cutoff).
|
|
112
|
+
- The driving query still uses the ASN chain (to find shipped orders + tracking) for **both**
|
|
113
|
+
clients — that is correct and unrelated to the package-contents source.
|
|
114
|
+
- Test scripts only ever email a test recipient and never write `c_dtInTransitEmailSent`.
|
|
115
|
+
- **Not yet deployed.** These crons depend on the api2 POST scripted-API change and the
|
|
116
|
+
`RecordScripts`/`AclRecordScripts` registration (prod done, beta pending) — see related docs.
|
|
117
|
+
|
|
118
|
+
## Change history
|
|
119
|
+
|
|
120
|
+
- 2026-06-18 — Switched Canada `getOrderFulfillmentData` from the ASN chain to the
|
|
121
|
+
`ItemFulfillmentItems` line bridge (match Compass USA); item info all 14px. (bala)
|
|
122
|
+
- 2026-06-18 — Brought both prod crons to parity: per-package partial assembly, `buildTrackingUrl`
|
|
123
|
+
carrier-URL precedence, POST body transport + `throwExceptionOnApiError=true`, mark-sent only
|
|
124
|
+
on success; restored crons after a git stash/merge had duplicated helper functions. (bala)
|
|
125
|
+
- 2026-06-17 — Styling pass: 18px headers, medium dark item titles, regular dark Qty, 20px
|
|
126
|
+
between packages. (bala)
|
|
127
|
+
|
|
128
|
+
## Related docs
|
|
129
|
+
|
|
130
|
+
- `2.0/apps/_underscore/features/email-template-sending.md` — the `sendEmail` scripted API +
|
|
131
|
+
its silent-failure hardening.
|
|
132
|
+
- `2.0/apps/api2/features/scripted-api-post-body-args.md` — POST + body-args support that makes
|
|
133
|
+
the body transport work.
|
|
134
|
+
- `clients/compass-usa/profile.md`, `clients/compass-canada/profile.md`.
|
|
@@ -57,6 +57,9 @@ the window's upper bound after each successful section.
|
|
|
57
57
|
|
|
58
58
|
### Adding a new client (the full recipe)
|
|
59
59
|
|
|
60
|
+
A wrapper + schedule alone is **not enough** — the client must also be provisioned in three
|
|
61
|
+
places or the sync runs but imports nothing (or 403s). All five steps are required:
|
|
62
|
+
|
|
60
63
|
1. **Wrapper** — copy `sync_togasupply_canon.php` to `sync_togasupply_<client>.php`; set
|
|
61
64
|
`CLIENT_CONFIGURATION` (literal UUIDs, or `App_Api_Toga2::*` consts if defined). Keep the
|
|
62
65
|
rest identical.
|
|
@@ -64,8 +67,35 @@ the window's upper bound after each successful section.
|
|
|
64
67
|
`"cron": "toga2/netsuite/sync_togasupply_<client>.php"`). Deploys regenerate the crontab.
|
|
65
68
|
3. **Seed Parameters** — add `dbchanges2/Client_<Id>/<date> - NetsuiteSyncParameters.sql`
|
|
66
69
|
inserting the 12 keys. **Without this the sync 404s on the first `/parameters` GET and the
|
|
67
|
-
run aborts.**
|
|
70
|
+
run aborts.** Seed start date = the canonical **`2026-03-04`** (from
|
|
71
|
+
`_modules/netsuite/2026-04-01 - Parameters.sql`) unless backfilling earlier history for that
|
|
72
|
+
client. `Client_<Id>` directory selects the DB — no `USE` statement (dbchanges2 rule).
|
|
68
73
|
`lint`: `C:\xampp7\php\php.exe -l` (prod worker is PHP 7.2).
|
|
74
|
+
4. **Client→NetSuite customer link** (`Core.Clients.netsuiteCustomerInternalId`) — **MUST be
|
|
75
|
+
set** to the client's NetSuite customer internal id. The lookup phase calls
|
|
76
|
+
`listChildCustomers((int)$client->netsuiteCustomerInternalId)`; if it's `NULL` the walk
|
|
77
|
+
seeds off `0`, the customer→client map is empty, and **no order ever matches → silent
|
|
78
|
+
zero-import** (the sync "runs" and even advances windows, but writes nothing). Find the id
|
|
79
|
+
with a SuiteQL `customer` lookup (`test/@dave/find_aig_netsuite_customer.php` is a template);
|
|
80
|
+
set it via a `dbchanges2/Core/` migration. Verify the client *has* supply transactions on
|
|
81
|
+
that customer before bothering (see the AIG gotcha).
|
|
82
|
+
5. **Per-client custom fields on the synced records** — the shared engine hardcodes a fixed set
|
|
83
|
+
of `c_netsuite*` field names it reads/writes (see *Custom field contract* below). Each must
|
|
84
|
+
exist on the client's records as **column + `CustomRecordFields` row + `AclCustomFieldPermissions`
|
|
85
|
+
grant** (custom fields are per-client — see the 2.0 `_underscore` per-client-database doc). A
|
|
86
|
+
missing one → **403 EZ-2** ("not authorized to read the specified fields") aborting the run.
|
|
87
|
+
The most commonly-missing one is `c_netsuiteInternalCustomerId` on the **Customers** record
|
|
88
|
+
(`recordId 113`, `isIdentifier=1`).
|
|
89
|
+
|
|
90
|
+
### Custom field contract (hardcoded in `common_sync_togasupply.php`)
|
|
91
|
+
|
|
92
|
+
The engine requests these `c_` fields by literal name per route — every synced client must have
|
|
93
|
+
them provisioned: `/customers`→`c_netsuiteInternalCustomerId`; `/countries`→`c_netsuiteCountry`;
|
|
94
|
+
`/locations`→`c_netsuiteInternalCustomerId` + `c_netsuiteInternalLocationId` + the
|
|
95
|
+
`c_dtNetsuiteLast*Integration` timestamps; `/subsidiaries`→`c_netsuiteInternalSubsidiaryId`;
|
|
96
|
+
`/vendors`→`c_netsuiteInternalVendorId`; `/payment-terms`→`c_netsuiteInternalTermsId`;
|
|
97
|
+
`/shipping-methods`→`c_netsuiteInternalShipMethodId`. The names are a **convention**, not
|
|
98
|
+
discovered per-client.
|
|
69
99
|
|
|
70
100
|
## Data model
|
|
71
101
|
|
|
@@ -74,7 +104,8 @@ Per-client `Parameters` table (2.0 client DB, e.g. `Client_Canon.Parameters`; co
|
|
|
74
104
|
`/parameters`:
|
|
75
105
|
|
|
76
106
|
- `NETSUITE_LAST_SYNC_DATETIME_{SALES_ORDERS,PURCHASE_ORDERS,INVOICES,ITEM_RECEIPTS,ITEM_FULFILLMENTS,INVENTORY_ADJUSTMENTS}`
|
|
77
|
-
— seed `'
|
|
107
|
+
— seed `'2026-03-04 00:00:00'` (the canonical start; use an earlier date only to backfill a
|
|
108
|
+
client whose history predates it — e.g. AIG seeded `2023-01-01` to reach its 2023/2025 records).
|
|
78
109
|
- `NETSUITE_EXECUTION_MODE_{...same six...}` — seed `'864000-IDLE'`.
|
|
79
110
|
|
|
80
111
|
Parameters are stored **per client DB** but accessed **through the TOGa2 API**, not direct SQL.
|
|
@@ -102,13 +133,28 @@ Parameters are stored **per client DB** but accessed **through the TOGa2 API**,
|
|
|
102
133
|
`Client_<Id>.Parameters`: a healthy client advances to ~now every 5 min. As of 2026-06-17,
|
|
103
134
|
Quad was seeded but frozen at 2025-04/05 (not advancing) while the other 12 deployed clients
|
|
104
135
|
were current.
|
|
136
|
+
- **Silent zero-import = missing `netsuiteCustomerInternalId`.** If a client "runs" (sections
|
|
137
|
+
flip RUNNING→IDLE, windows advance) but imports nothing, check
|
|
138
|
+
`Core.Clients.netsuiteCustomerInternalId` — a NULL there empties the customer map so no order
|
|
139
|
+
matches. Distinct from the EZ-2 *abort* caused by a missing `c_netsuite*` field.
|
|
140
|
+
- **"Listed ≠ functioning" — the AIG case (2026-06-17).** AIG was in the original combined
|
|
141
|
+
array but had **never** imported anything in prod (0 SalesOrders/POs/Invoices/Locations/Customers)
|
|
142
|
+
and *couldn't* have: its `netsuiteCustomerInternalId` was NULL and its Customers record lacked
|
|
143
|
+
`c_netsuiteInternalCustomerId`. After provisioning all three (custom field, Parameters, customer
|
|
144
|
+
link → NetSuite customer **34902**), it still imports ~nothing because AIG's entire NetSuite
|
|
145
|
+
supply footprint is **1 sales order (2025-09-11) + 15 opportunities** — it's an
|
|
146
|
+
opportunity/entitlement account, not a procurement/supply customer. Lesson: before onboarding a
|
|
147
|
+
client, confirm it actually transacts supply orders in NetSuite (SuiteQL the `customer`'s
|
|
148
|
+
transaction history) — being in the array doesn't mean it belongs on this engine.
|
|
105
149
|
|
|
106
150
|
## Change history
|
|
107
151
|
|
|
108
|
-
- 2026-06-17 —
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
152
|
+
- 2026-06-17 — Documented the full onboarding prerequisites (client→NetSuite customer link +
|
|
153
|
+
per-client `c_netsuite*` custom fields) after AIG ran but imported nothing. Added AIG + Growrk
|
|
154
|
+
thin wrappers, schedule entries, and dbchanges2 Parameters seeds; built AIG's
|
|
155
|
+
`c_netsuiteInternalCustomerId` field migration + `Core.Clients` customer-link migration (id
|
|
156
|
+
34902). Confirmed AIG is not a real supply client; Growrk ships, AIG held. Canonical seed start
|
|
157
|
+
is `2026-03-04` (not `2025-01-01`). (dfranks)
|
|
112
158
|
|
|
113
159
|
## Related docs
|
|
114
160
|
|
|
@@ -6,8 +6,8 @@ project: _Underscore
|
|
|
6
6
|
client: shared
|
|
7
7
|
type: feature
|
|
8
8
|
status: active
|
|
9
|
-
updated: 2026-06-
|
|
10
|
-
owners: ["jcardinal"]
|
|
9
|
+
updated: 2026-06-18
|
|
10
|
+
owners: ["jcardinal", "bala"]
|
|
11
11
|
files:
|
|
12
12
|
- _underscore/Model/Client/EmailTemplate.php
|
|
13
13
|
- _underscore/Model/Client/EmailTemplateOutgoingEmailAddress.php
|
|
@@ -40,7 +40,8 @@ sends via `_Email`. The only client-specific input the send actually needs is th
|
|
|
40
40
|
|
|
41
41
|
## How it works
|
|
42
42
|
|
|
43
|
-
1. Load the template by `uuid
|
|
43
|
+
1. Load the template by `uuid` (`load(true)` throws if the uuid is not found); **throw** if
|
|
44
|
+
`!isActive` so a misconfigured/disabled template is not silently dropped.
|
|
44
45
|
2. Search `EmailTemplateOutgoingEmailAddress` for the template and append each stored
|
|
45
46
|
address to `$to` / `$cc` / `$bcc` by its `toCcBcc` value.
|
|
46
47
|
3. Build `_Email`, set the client identifier, add recipients, set From from the template's
|
|
@@ -51,6 +52,12 @@ sends via `_Email`. The only client-specific input the send actually needs is th
|
|
|
51
52
|
Both `sendEmail()` and `send()` are thin wrappers that forward to `dispatch()`, so the API
|
|
52
53
|
and non-API paths run identical code — no behavioral drift between them.
|
|
53
54
|
|
|
55
|
+
**Failure surfacing (as of 2026-06-18):** the send path no longer fails silently. `dispatch()`
|
|
56
|
+
throws on an inactive template, and `_Email::send()` throws when the underlying
|
|
57
|
+
`PHPMailer::Send()` returns false (it logs `isSuccess` + `error` on the CloudWatch event
|
|
58
|
+
first, then throws). A scripted-API caller running with `throwExceptionOnApiError=true` now
|
|
59
|
+
gets a real error instead of a `true`/`false` it would otherwise ignore.
|
|
60
|
+
|
|
54
61
|
## Data model
|
|
55
62
|
|
|
56
63
|
- `EmailTemplates` (client DB): `uuid`, `isActive`, `sendFromEmailAddress`, `sendFromName`,
|
|
@@ -75,9 +82,19 @@ can keep using `sendEmail($api, ...)`.
|
|
|
75
82
|
argument, so the first param must stay `&$api`. Do not "clean it up" by removing it.
|
|
76
83
|
- `_Email::send()` throws if the client identifier is empty — `send('')` will fail at send
|
|
77
84
|
time, not at call time.
|
|
85
|
+
- **A failed send now throws — callers that ignore the return value will see exceptions.**
|
|
86
|
+
As of 2026-06-18 an inactive template or an SMTP send failure raises an `Exception`
|
|
87
|
+
(previously the inactive case returned `false` and an SMTP failure was swallowed entirely).
|
|
88
|
+
Any fire-and-forget caller now propagates that exception, which is intended (so an email is
|
|
89
|
+
never silently marked as sent). Blast radius is every client, including the Compass/Quad
|
|
90
|
+
`SalesOrder`/`ApprovalDecision` order-placed emails — verify on beta before shipping.
|
|
78
91
|
|
|
79
92
|
## Change history
|
|
80
93
|
|
|
94
|
+
- 2026-06-18 — Hardened against silent failure: `dispatch()` throws on an inactive template
|
|
95
|
+
(was `return false`) and `_Email::send()` throws + logs `isSuccess`/`error` when
|
|
96
|
+
`PHPMailer::Send()` fails (was swallowed with no return/throw/log). Affects all clients;
|
|
97
|
+
not yet deployed. (bala)
|
|
81
98
|
- 2026-06-15 — Added non-API `send(clientIdentifier, …)` entry point + private `dispatch()`;
|
|
82
99
|
`sendEmail(&$api, …)` kept unchanged as a wrapper for backward compatibility. Migrated the
|
|
83
100
|
`worker2` Compass Report and AI-BDR NetSuite callers off the fake-`$api` hack. (jcardinal)
|
|
@@ -86,3 +103,7 @@ can keep using `sendEmail($api, ...)`.
|
|
|
86
103
|
|
|
87
104
|
- `2.0/standards/backend-php.md` — *Record Scripts* (the `&$api` contract).
|
|
88
105
|
- `2.0/apps/api2/architecture.md` — scripted-API dispatch in `V2.php`.
|
|
106
|
+
- `2.0/apps/api2/features/scripted-api-post-body-args.md` — accepting POST + JSON-body args
|
|
107
|
+
for scripted APIs, used to send large email payloads without hitting HTTP 414.
|
|
108
|
+
- `1.0/apps/worker/features/compass-partial-in-transit-delivered-emails.md` — a heavy
|
|
109
|
+
consumer of `sendEmail` via the scripted API.
|
|
@@ -3,5 +3,6 @@
|
|
|
3
3
|
| Doc | Summary | Files |
|
|
4
4
|
|-----|---------|-------|
|
|
5
5
|
| [API (api2 / TOGa API v2) Architecture](architecture.md) | `api2` is the backend powering the public **TOGa 2.0 API**. | api2/Controller/Index.php, api2/Component/Api/V2/V2.php, api2/Component/Api/Cxml/Cxml.php, api2/Component/Api/V2/Response/Response.php, api2/Config/ |
|
|
6
|
+
| [POST + JSON-body args for scripted APIs](features/scripted-api-post-body-args.md) | The V2 engine can run a Record Script (scripted API) for a **POST** request, and a scripted API can receive its arguments from the **JSON request body** instead | api2/Component/Api/V2/V2.php |
|
|
6
7
|
| [AWS CodePipeline Deployment via CodeConnections (GitHub → Elastic Beanstalk)](workflows/codepipeline-codeconnections-deploy.md) | 2.0 apps (`api2`, `_underscore`) are deployed through **AWS CodePipeline**. | |
|
|
7
8
|
| [New Environment Configuration & Provisioning (api2)](workflows/environment-configuration-and-provisioning.md) | What it takes for a 2.0 API environment (e.g. | api2/Config/<environment>.ini, api2/Controller/Index.php, dbchanges2/Core/2026-06-16a - DatabaseHosts for new QA QC stage demo environments.sql, _underscore/Route.php |
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: POST + JSON-body args for scripted APIs
|
|
3
|
+
framework: "2.0"
|
|
4
|
+
repo: api2
|
|
5
|
+
project: API
|
|
6
|
+
client: shared
|
|
7
|
+
type: feature
|
|
8
|
+
status: active
|
|
9
|
+
updated: 2026-06-18
|
|
10
|
+
owners: ["bala"]
|
|
11
|
+
files:
|
|
12
|
+
- api2/Component/Api/V2/V2.php
|
|
13
|
+
related: []
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
## Summary
|
|
17
|
+
|
|
18
|
+
The V2 engine can run a Record Script (scripted API) for a **POST** request, and a scripted
|
|
19
|
+
API can receive its arguments from the **JSON request body** instead of only the URL query
|
|
20
|
+
string. This lets callers send large argument values (e.g. fully rendered email HTML) in the
|
|
21
|
+
body and avoid Apache's `LimitRequestLine` (~8190 bytes) which otherwise produces **HTTP 414
|
|
22
|
+
URI Too Long**. Query-string values still win over body values, so existing GET-based scripted
|
|
23
|
+
calls are unaffected.
|
|
24
|
+
|
|
25
|
+
## Key files / entry points
|
|
26
|
+
|
|
27
|
+
- `api2/Component/Api/V2/V2.php` — the scripted-call dispatch inside `processRoutePairs()`:
|
|
28
|
+
- **READ branch (~line 3583)** — already ran scripted methods for GET; now also merges
|
|
29
|
+
body args.
|
|
30
|
+
- **CREATE branch (~line 3846)** — added a scripted-dispatch block so a POST to a route that
|
|
31
|
+
has a POST-registered Record Script runs the script (guarded by `$isUsingScriptedCall`)
|
|
32
|
+
instead of being treated as a record create.
|
|
33
|
+
- Args are built from `parse_str($_SERVER['QUERY_STRING'])`, then the decoded JSON body
|
|
34
|
+
(`$httpPayload`) is merged in. Keys `transactionId` and `api`, and any key already present
|
|
35
|
+
from the query string, are skipped (query string wins). `$args['api'] = $this` is always set
|
|
36
|
+
last per the Record Script contract.
|
|
37
|
+
|
|
38
|
+
## How it works
|
|
39
|
+
|
|
40
|
+
1. A scripted call is matched via `getRecordScriptPhpMethod(record, httpMethod, route, roleIds)`,
|
|
41
|
+
which looks up `RecordScripts` (by method + route) joined to the client's `AclRecordScripts`
|
|
42
|
+
for the caller's roles.
|
|
43
|
+
2. `$args` is parsed from the query string (minus `transactionId`).
|
|
44
|
+
3. If the JSON body is non-empty, each body key not already in `$args` (and not `transactionId`
|
|
45
|
+
/ `api`) is added to `$args`.
|
|
46
|
+
4. The method is invoked as `$model::$phpMethod(...$args)` with `$args['api'] = $this`. Extra
|
|
47
|
+
named args land in the method's `...$args` variadic.
|
|
48
|
+
|
|
49
|
+
## Data model
|
|
50
|
+
|
|
51
|
+
- `RecordScripts` (Core DB): `uuid`, `recordId`, `method` (e.g. `POST`), `route`, `phpMethod`.
|
|
52
|
+
- `AclRecordScripts` (per-client DB): `recordScriptId`, `roleId` — grants a role permission to
|
|
53
|
+
run the script.
|
|
54
|
+
|
|
55
|
+
To enable a scripted endpoint for POST you must register **both**: a `RecordScripts` row with
|
|
56
|
+
`method = 'POST'` and the matching `route`/`phpMethod`, and `AclRecordScripts` rows for each
|
|
57
|
+
allowed role. A GET-only script will not fire for a POST until its POST row exists.
|
|
58
|
+
|
|
59
|
+
## Client variations
|
|
60
|
+
|
|
61
|
+
None — this is engine behavior. Per-client access is controlled by `AclRecordScripts` rows.
|
|
62
|
+
|
|
63
|
+
## Gotchas / known issues
|
|
64
|
+
|
|
65
|
+
- **Normal CRUD is unaffected.** The POST scripted block only fires when a POST Record Script
|
|
66
|
+
is registered for the route; otherwise the request falls through to the usual create path
|
|
67
|
+
(`if (!$isUsingScriptedCall && empty($routePairs))` / `locateRecord`).
|
|
68
|
+
- **Registration is environment-specific.** The `RecordScripts` (Core) + `AclRecordScripts`
|
|
69
|
+
(client) rows must exist in every environment the API reads. As of this writing they were
|
|
70
|
+
run in **prod** for the email `sendEmail` script (Core + Client_Compass + Client_CompassCanada);
|
|
71
|
+
**beta still needs them** before the POST path dispatches there.
|
|
72
|
+
- **Deploys with `_underscore`.** api2 pulls `_underscore` at deploy; `http://api2` and
|
|
73
|
+
`api.beta.togahub.com` resolve to deployed boxes (`/var/app/current`), not a local checkout —
|
|
74
|
+
so testing the change requires deploying it, not just editing locally.
|
|
75
|
+
- **CI commit policy.** Subject must be `TRUE-<ticket>: <Subject>` (≤80 chars, capitalized,
|
|
76
|
+
imperative, no trailing period, more than one word).
|
|
77
|
+
|
|
78
|
+
## Change history
|
|
79
|
+
|
|
80
|
+
- 2026-06-18 — Added POST scripted-API dispatch (CREATE branch) and JSON-body arg merge (READ +
|
|
81
|
+
CREATE branches) so large scripted-API payloads avoid HTTP 414; query string still wins over
|
|
82
|
+
body. Written, not yet merged/deployed. (bala)
|
|
83
|
+
|
|
84
|
+
## Related docs
|
|
85
|
+
|
|
86
|
+
- `2.0/apps/api2/architecture.md` — the JSON engine and `processRoutePairs()`.
|
|
87
|
+
- `2.0/apps/_underscore/features/email-template-sending.md` — the `sendEmail` scripted API that
|
|
88
|
+
this enables to be POSTed with body args.
|
|
89
|
+
- `1.0/apps/worker/features/compass-partial-in-transit-delivered-emails.md` — the consumer that
|
|
90
|
+
hit the 414 and drove this change.
|
package/knowledge/INDEX.md
CHANGED
|
@@ -16,7 +16,7 @@ _Auto-generated by `knowledge.js index`. Do not hand-edit._
|
|
|
16
16
|
|
|
17
17
|
- **_underscore** (_Underscore) _(framework core)_ — 8 doc(s) → [2.0/apps/_underscore/INDEX.md](2.0/apps/_underscore/INDEX.md)
|
|
18
18
|
- **worker2** (Worker) — 8 doc(s) → [2.0/apps/worker2/INDEX.md](2.0/apps/worker2/INDEX.md)
|
|
19
|
-
- **api2** (API) —
|
|
19
|
+
- **api2** (API) — 4 doc(s) → [2.0/apps/api2/INDEX.md](2.0/apps/api2/INDEX.md)
|
|
20
20
|
- **dbchanges2** (Database Changes) _(framework core)_ — 1 doc(s) → [2.0/apps/dbchanges2/INDEX.md](2.0/apps/dbchanges2/INDEX.md)
|
|
21
21
|
- **toga2-supply** (TOGa Supply) — 3 doc(s) → [2.0/apps/toga2-supply/INDEX.md](2.0/apps/toga2-supply/INDEX.md)
|
|
22
22
|
- **saml** (SAML SSO Gateway) — 2 doc(s) → [2.0/apps/saml/INDEX.md](2.0/apps/saml/INDEX.md)
|
package/package.json
CHANGED