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.
@@ -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.** `Client_<Id>` directory selects the DB — no `USE` statement (dbchanges2 rule).
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 `'2025-01-01 00:00:00'` (or chosen backfill start).
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 — Added AIG + Growrk thin wrappers (the two clients from the original array that
109
- never got one), registered both in `cron.worker.sync.json`, and added their dbchanges2
110
- Parameters seeds (`Client_Aig`, `Client_Growrk`). Verified the other 13 clients' Parameters
111
- against prod. (dfranks)
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-15
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`; return `false` immediately if `!isActive`.
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.
@@ -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) — 3 doc(s) → [2.0/apps/api2/INDEX.md](2.0/apps/api2/INDEX.md)
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toga-ai",
3
- "version": "1.0.119",
3
+ "version": "1.0.121",
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",