toga-ai 1.0.560 → 1.0.562
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/library/INDEX.md +2 -0
- package/knowledge/1.0/apps/library/features/app-class-placement-base-contracts.md +75 -0
- package/knowledge/1.0/apps/library/features/service-request-toga2-provisioning.md +135 -0
- package/knowledge/1.0/apps/library/features/toga2-api-client-and-bridge.md +29 -0
- package/knowledge/1.0/apps/test/features/static-no-db-regression-harness.md +18 -0
- package/knowledge/1.0/apps/togadesk/workflows/standalone-test-scripts.md +16 -0
- package/knowledge/2.0/apps/_underscore/INDEX.md +1 -0
- package/knowledge/2.0/apps/_underscore/features/calculated-sql-fields.md +71 -0
- package/knowledge/2.0/apps/api2/INDEX.md +1 -1
- package/knowledge/2.0/apps/api2/features/api-payload-interceptors.md +74 -2
- package/knowledge/2.0/apps/worker2/INDEX.md +1 -0
- package/knowledge/2.0/apps/worker2/features/creating-worker-actions.md +25 -0
- package/knowledge/2.0/apps/worker2/features/netsuite-salesorder-outbound-push.md +14 -0
- package/knowledge/2.0/apps/worker2/features/service-request-sales-order-generation.md +213 -0
- package/knowledge/INDEX.md +4 -4
- package/knowledge/clients/elite/INDEX.md +6 -2
- package/knowledge/clients/elite/features/desk-service-request-creation.md +136 -0
- package/knowledge/clients/elite/features/salesorder-netsuite-push.md +63 -9
- package/knowledge/clients/elite/features/salesorder-status-togadesk-reply.md +97 -0
- package/knowledge/clients/elite/features/service-request-to-salesorder-pipeline.md +109 -0
- package/knowledge/clients/elite/features/togadesk-service-request-intake.md +232 -0
- package/knowledge/clients/elite/profile.md +27 -5
- package/package.json +1 -1
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
| Doc | Summary | Files |
|
|
4
4
|
|-----|---------|-------|
|
|
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
|
+
| [Where a new App_ class goes — the app/ folder IS a behavioral contract](features/app-class-placement-base-contracts.md) | In `library/app/`, choosing a folder is **not** a filing decision — the autoloader maps `App_<Folder>_<File>` to `app/<folder>/<file>.php`, and each folder's ba | library/app/model.php, library/app/client.php, library/app/api.php, library/app/api/servicerequest.php, library/app/api/volt.php, library/app/api/carrier/fedex.php |
|
|
6
7
|
| [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
8
|
| [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
9
|
| [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 |
|
|
@@ -15,5 +16,6 @@
|
|
|
15
16
|
| [NetSuite SuiteQL/REST API Reference](features/netsuite-suiteql-api-reference.md) | General working reference for the Agilant NetSuite integration: how to authenticate, how SuiteQL behaves, and the confirmed schema of the tables/columns/codes w | library/app/api/netsuite/rest.php, library/ssl/netsuite_ec_key.pem, test/@dave/Junk Drawer/nsq.php |
|
|
16
17
|
| [NetSuite SuiteQL/REST Shim — Field Semantics](features/netsuite-suiteql-rest-shim.md) | `App_Api_Netsuite_Rest` is the REST/SuiteQL replacement for the deprecated NetSuite SOAP toolkit. | library/app/api/netsuite/rest.php |
|
|
17
18
|
| [NetSuite Sync Alert Monitor (App_SystemMonitor_NetSuiteIntegration)](features/netsuite-sync-alert-monitor.md) | `App_SystemMonitor_NetSuiteIntegration` (`library/app/systemmonitor/netsuiteintegration.php`, title **"NetSuite Sync Alert"**) is a 1.0 system monitor that watc | library/app/systemmonitor/netsuiteintegration.php, worker/crons/infrastructure/system_monitors.php |
|
|
19
|
+
| [App_Api_ServiceRequest — provisioning a TOGa 2.0 Service Request from 1.0](features/service-request-toga2-provisioning.md) | `App_Api_ServiceRequest` (`library/app/api/servicerequest.php`) is the shared 1.0-side class that turns a posted form into a **TOGa 2.0 Service Request** — it v | library/app/api/servicerequest.php, library/app/api/toga2.php, togadesk/desk/includes/classes/class.ticket.php |
|
|
18
20
|
| [Startech PC Matic B2B Sync (library)](features/startech-pcmaticb2b-sync.md) | `library/app/api/toga2.php` handles bidirectional ticket sync for PC Matic B2B between TOGaDesk 1.0 and TOGA 2.0. | library/app/api/toga2.php, library/app/api/startechticket.php, worker/crons/toga2/startech/common_import_supporting_records.php |
|
|
19
21
|
| [App_Api_Toga2 — TOGa2 API Client & 1.0↔2.0 Sync Bridge](features/toga2-api-client-and-bridge.md) | `App_Api_Toga2` (`library/app/api/toga2.php`, ~8400 lines) is the **1.0-side client for the TOGa 2 (`_underscore`/api2) public API** *and* the home of the cross | library/app/api/toga2.php, worker/crons/toga2/aig/sync_togasupply_aig.php, worker/crons/toga2/wje/sync_togasupply_wje.php, test/@Mark/AIG/test_multi_email.php |
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Where a new App_ class goes — the app/ folder IS a behavioral contract
|
|
3
|
+
framework: "1.0"
|
|
4
|
+
repo: library
|
|
5
|
+
project: Library
|
|
6
|
+
client: shared
|
|
7
|
+
type: feature
|
|
8
|
+
status: active
|
|
9
|
+
updated: 2026-08-12
|
|
10
|
+
owners: [snaredla]
|
|
11
|
+
files:
|
|
12
|
+
- library/app/model.php
|
|
13
|
+
- library/app/client.php
|
|
14
|
+
- library/app/api.php
|
|
15
|
+
- library/app/api/servicerequest.php
|
|
16
|
+
- library/app/api/volt.php
|
|
17
|
+
- library/app/api/carrier/fedex.php
|
|
18
|
+
related:
|
|
19
|
+
- ../architecture.md
|
|
20
|
+
- ../../../clients/elite/features/togadesk-service-request-intake.md
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
## Summary
|
|
24
|
+
|
|
25
|
+
In `library/app/`, choosing a folder is **not** a filing decision — the autoloader maps
|
|
26
|
+
`App_<Folder>_<File>` to `app/<folder>/<file>.php`, and each folder's base class carries **real
|
|
27
|
+
inherited behavior**. Put a class in the wrong folder and it inherits a contract that does not
|
|
28
|
+
apply, silently. This cost three relocations of one class on 2026-08-12; the rule below is the
|
|
29
|
+
outcome.
|
|
30
|
+
|
|
31
|
+
**The rule:** a class that only makes HTTP calls to an external (or cross-framework) API belongs in
|
|
32
|
+
`app/api/` and should **extend nothing**.
|
|
33
|
+
|
|
34
|
+
## What each base class actually obliges you to
|
|
35
|
+
|
|
36
|
+
| Folder | Base | What it really means |
|
|
37
|
+
|---|---|---|
|
|
38
|
+
| `app/model/` | `App_Model` | Requires `const TABLENAME` **and** `const DATABASE`, and constructs **by primary key**. Only for a class backed by a real 1.0 table. |
|
|
39
|
+
| `app/client/` | `App_Client` | Fiscal calendars and retail store mappings — `getName()`, `convertToFiscalPeriod()`, `getFiscalPeriods()`, `parseStoresRegionsMappingFile()`, `isEnabledForStore()`. It is **not** "the folder for client-specific code", and the folder is **FLAT** (no per-client subfolders). |
|
|
40
|
+
| `app/api/` | `App_Api` | The 1.0 **fulfilment-vendor** base: `submitFromPending($aryServiceRequestItemStatusIds)`, `getDatesForServiceRequest()`. It declares **no abstract members**, so extending it compiles cleanly and silently grants your class a vendor-submission API it cannot honour. |
|
|
41
|
+
|
|
42
|
+
The `App_Api` case is the trap: nothing forces you to implement anything, so the mistake never
|
|
43
|
+
surfaces as an error — it surfaces later as a caller invoking `submitFromPending()` on a class that
|
|
44
|
+
was never a fulfilment vendor.
|
|
45
|
+
|
|
46
|
+
## Placement guidance
|
|
47
|
+
|
|
48
|
+
1. **Backed by a 1.0 table?** → `app/model/…`, extend `App_Model`, declare `TABLENAME` + `DATABASE`.
|
|
49
|
+
2. **Fiscal calendar / store-mapping behavior?** → `app/client/…` (flat), extend `App_Client`.
|
|
50
|
+
3. **A vendor that fulfils service-request items?** → `app/api/…`, extend `App_Api`.
|
|
51
|
+
4. **Anything else that just talks HTTP** (a REST client, a cross-framework provisioner) →
|
|
52
|
+
`app/api/…`, **extend nothing**. `App_Api_ServiceRequest` and `App_Api_Toga2` are both of this
|
|
53
|
+
kind.
|
|
54
|
+
|
|
55
|
+
**Nesting is fine and has precedent:** `app/api/volt.php` sits beside `app/api/volt/user.php`, and
|
|
56
|
+
`app/api/carrier/fedex.php` nests a carrier under a family folder. Grouping a family of API classes
|
|
57
|
+
in a subfolder does not require a shared base class.
|
|
58
|
+
|
|
59
|
+
## Gotchas / known issues
|
|
60
|
+
|
|
61
|
+
- **"Extends cleanly" is not evidence of correct placement** in 1.0 — the bases have no abstract
|
|
62
|
+
members, so the compiler will never tell you.
|
|
63
|
+
- **`app/client/` is flat.** Do not create `app/client/<client>/` — that is not how the autoloader
|
|
64
|
+
or the existing tree is organised.
|
|
65
|
+
- All folder and file names under `app/` must be **lowercase** (autoloader lowercases before
|
|
66
|
+
lookup) — see [library architecture](../architecture.md).
|
|
67
|
+
|
|
68
|
+
## Change history
|
|
69
|
+
|
|
70
|
+
- 2026-08-12 — Captured after `App_Api_ServiceRequest` was relocated three times (model → client →
|
|
71
|
+
api). Recorded what each base class actually carries — `App_Model` needs `TABLENAME`/`DATABASE`
|
|
72
|
+
and constructs by primary key; `App_Client` is fiscal-calendar/store-mapping behavior and its
|
|
73
|
+
folder is flat; `App_Api` is the fulfilment-vendor base whose lack of abstract members makes a
|
|
74
|
+
wrong `extends` silent — and the resulting rule: an HTTP-only class lives in `app/api/` and
|
|
75
|
+
extends nothing. (snaredla)
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: App_Api_ServiceRequest — provisioning a TOGa 2.0 Service Request from 1.0
|
|
3
|
+
framework: "1.0"
|
|
4
|
+
repo: library
|
|
5
|
+
project: Library
|
|
6
|
+
client: shared
|
|
7
|
+
type: feature
|
|
8
|
+
status: active
|
|
9
|
+
updated: 2026-08-12
|
|
10
|
+
owners: [snaredla]
|
|
11
|
+
files:
|
|
12
|
+
- library/app/api/servicerequest.php
|
|
13
|
+
- library/app/api/toga2.php
|
|
14
|
+
- togadesk/desk/includes/classes/class.ticket.php
|
|
15
|
+
related:
|
|
16
|
+
- ./toga2-api-client-and-bridge.md
|
|
17
|
+
- ./app-class-placement-base-contracts.md
|
|
18
|
+
- ../../../clients/elite/features/desk-service-request-creation.md
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
## Summary
|
|
22
|
+
|
|
23
|
+
`App_Api_ServiceRequest` (`library/app/api/servicerequest.php`) is the shared 1.0-side class that
|
|
24
|
+
turns a posted form into a **TOGa 2.0 Service Request** — it validates and normalises the posted
|
|
25
|
+
values, resolves the related 2.0 records it needs, refuses a duplicate, and POSTs the Service
|
|
26
|
+
Request with its nested units in a single request. All transport goes through
|
|
27
|
+
[`App_Api_Toga2::send()`](./toga2-api-client-and-bridge.md); this class holds no 1.0 table and does
|
|
28
|
+
no SQL.
|
|
29
|
+
|
|
30
|
+
First consumer is the Elite "New Service Request" button in TOGa Desk (see
|
|
31
|
+
[Elite desk Service Request creation](../../../clients/elite/features/desk-service-request-creation.md)),
|
|
32
|
+
but the class itself is client-agnostic — the caller passes the API endpoint in.
|
|
33
|
+
|
|
34
|
+
## Key files / entry points
|
|
35
|
+
|
|
36
|
+
`abstract class App_Api_ServiceRequest` — public:
|
|
37
|
+
|
|
38
|
+
- `buildFromPostedValues(array $postedValues, string $ticketUuid, ?string $overrideWithApiEndpoint = null): array`
|
|
39
|
+
— validates + normalises the modal post into the 2.0 payload array (throws on a validation
|
|
40
|
+
failure; the caller converts that into the UI message).
|
|
41
|
+
- `searchTicketFromToga2(string $ticketUuid, ?string $overrideWithApiEndpoint = null)` — the
|
|
42
|
+
**duplicate guard**: does a Service Request already exist for this ticket?
|
|
43
|
+
- `createServiceRequestInToga2(array $serviceRequest, ?string $overrideWithApiEndpoint = null)` —
|
|
44
|
+
the POST (parent + nested units).
|
|
45
|
+
- `describeApiFailureMessage($response): string` — turns a 2.0 failure response into an
|
|
46
|
+
agent-readable message.
|
|
47
|
+
|
|
48
|
+
Private: `isStateInCountry()`, `findContactUuidByEmailAddress()`, `cleanPostedValue()`.
|
|
49
|
+
|
|
50
|
+
## How it works
|
|
51
|
+
|
|
52
|
+
1. **Build** — `buildFromPostedValues()` trims/length-caps every posted value
|
|
53
|
+
(`cleanPostedValue()`), resolves the requester contact by email, checks the selected state
|
|
54
|
+
really belongs to the selected country, and assembles the parent payload plus one nested
|
|
55
|
+
`serviceRequestUnits` entry per line.
|
|
56
|
+
2. **Duplicate guard** — `searchTicketFromToga2()` looks the ticket uuid up in 2.0 before creating
|
|
57
|
+
anything.
|
|
58
|
+
3. **Create** — `createServiceRequestInToga2()` POSTs **parent + nested `serviceRequestUnits` in
|
|
59
|
+
ONE request**. Related records inside that payload resolve by **natural key** — the request type
|
|
60
|
+
by *name*, the item by *partNumber* — while **customer, state and ticket are matched by uuid**.
|
|
61
|
+
4. The caller (TOGa Desk) maps the outcome to its own status code and writes the ticket
|
|
62
|
+
history/reply.
|
|
63
|
+
|
|
64
|
+
## The failure-check contract — `empty($response->isSuccess)` is always safe
|
|
65
|
+
|
|
66
|
+
This is the load-bearing fact behind three bugs fixed on 2026-08-12, and it is not obvious from the
|
|
67
|
+
call site.
|
|
68
|
+
|
|
69
|
+
`App_Api_Toga2::send()` (`library/app/api/toga2.php`, ~L432–450) **always returns a response object
|
|
70
|
+
that carries `isSuccess`**:
|
|
71
|
+
|
|
72
|
+
- it returns early **only** when `isSuccess` is true;
|
|
73
|
+
- when `isSuccess` is false and `$throwExceptionOnApiError = false`, it falls through the retry loop
|
|
74
|
+
and ends at `return $response` — **a failure arrives as a return value, not an exception**;
|
|
75
|
+
- if the response is not an object, or lacks the `isSuccess` property at all, it **throws**.
|
|
76
|
+
|
|
77
|
+
Therefore anything `send()` hands back can be checked with `empty($response->isSuccess)` — including
|
|
78
|
+
GETs. There is no "successful response without the property" case to defend against.
|
|
79
|
+
|
|
80
|
+
**The bug class this prevents:** every helper here calls `send()` with
|
|
81
|
+
`$throwExceptionOnApiError = false`, so a network blip, an auth failure or a 500 comes back as a
|
|
82
|
+
*response*. Code that reads straight past it to `$response->data->{resource}` sees "no records" and
|
|
83
|
+
concludes **"the thing does not exist"** — the most dangerous possible misreading for a lookup.
|
|
84
|
+
|
|
85
|
+
## Fixed 2026-08-12 — three silent failures, one root cause
|
|
86
|
+
|
|
87
|
+
| Lookup | Old behavior on API failure | Consequence |
|
|
88
|
+
|---|---|---|
|
|
89
|
+
| duplicate Service Request | returned `null` | read as "no duplicate exists" → **a second Service Request on a ticket that already had one** |
|
|
90
|
+
| contact by email | returned `null` | read as "no contact exists" → **a duplicate contact created in 2.0** for someone 2.0 already held |
|
|
91
|
+
| state-belongs-to-country | returned `false` | a blip told the agent *"the selected state does not belong to the selected country"* — a **false accusation of bad input** |
|
|
92
|
+
|
|
93
|
+
`isStateInCountry()` now returns **`?bool` as a tri-state**: `true` confirmed, `false` a genuine
|
|
94
|
+
mismatch, **`null` unconfirmed** (exception or empty result). The submission is refused in both the
|
|
95
|
+
`false` and `null` cases — the difference is the **message**: only `false` blames the input, `null`
|
|
96
|
+
says the check could not be completed. Do not collapse this back into a `bool`.
|
|
97
|
+
|
|
98
|
+
## 2.0 API rules this class had to learn the hard way
|
|
99
|
+
|
|
100
|
+
- **Naming `fields` on `GET /states` SUPPRESSES the nested country.** A plain request returns the
|
|
101
|
+
nested `country`; adding a `fields` list drops it. If a nested relation vanishes, suspect the
|
|
102
|
+
field list before suspecting the data.
|
|
103
|
+
- **Post a state by UUID, never by code.** `WA` and `NT` each belong to **two** countries, and the
|
|
104
|
+
API matches on code alone. `States.countryId` has `isIdentifier = 0`, so the nested country cannot
|
|
105
|
+
disambiguate it either — the uuid is the only unambiguous handle.
|
|
106
|
+
- **Natural-key resolution inside a nested write:** type by name, item by partNumber. Only
|
|
107
|
+
customer / state / ticket go by uuid.
|
|
108
|
+
|
|
109
|
+
## Gotchas / known issues
|
|
110
|
+
|
|
111
|
+
- **A uuid is environment-scoped.** Any flow that reads uuids from one 2.0 environment and posts
|
|
112
|
+
them to another will fail or, worse, resolve to a different record. Read and write against the
|
|
113
|
+
**same** endpoint — see the hardcoded-endpoint gotcha in the
|
|
114
|
+
[Elite desk feature](../../../clients/elite/features/desk-service-request-creation.md).
|
|
115
|
+
- **This class extends nothing, deliberately.** Extending `App_Api` silently inherits the 1.0
|
|
116
|
+
fulfilment-vendor contract. See
|
|
117
|
+
[where a new App_ class goes](./app-class-placement-base-contracts.md).
|
|
118
|
+
- **Credentials.** Elite's `CLIENT_UUID_ELITE` / `API_UUID_ELITE` / `API_SECRET_ELITE` are class
|
|
119
|
+
constants on `App_Api_Toga2` (`library/app/api/toga2.php`). Read them from source; never
|
|
120
|
+
reproduce the values. An API secret living in a git-tracked constant is a standing concern — see
|
|
121
|
+
the credentials gotcha on [the transport doc](./toga2-api-client-and-bridge.md).
|
|
122
|
+
|
|
123
|
+
## Change history
|
|
124
|
+
|
|
125
|
+
- 2026-08-12 — TRUE-80497: created `App_Api_ServiceRequest` as the shared 1.0→2.0 Service Request
|
|
126
|
+
provisioner. Recorded the `send()` failure contract (**`empty($response->isSuccess)` is always a
|
|
127
|
+
safe check, including on GETs**) and fixed **three** silent failures that all came from reading
|
|
128
|
+
past a failure response returned under `$throwExceptionOnApiError = false`: the duplicate-SR
|
|
129
|
+
lookup and the contact lookup both reported "does not exist" (allowing a second SR and a
|
|
130
|
+
duplicate contact), and the state/country check reported a genuine mismatch for an exception or
|
|
131
|
+
empty result. `isStateInCountry()` now returns `?bool` — `null` = unconfirmed, with its own
|
|
132
|
+
message; both `null` and `false` still refuse. Also recorded the 2.0 rules: `fields` on
|
|
133
|
+
`GET /states` suppresses the nested country; states must be posted by uuid (`WA`/`NT` are
|
|
134
|
+
ambiguous across countries and `States.countryId` has `isIdentifier = 0`); parent + nested
|
|
135
|
+
`serviceRequestUnits` post in one request with type/item resolved by natural key. (snaredla)
|
|
@@ -23,6 +23,7 @@ related:
|
|
|
23
23
|
- ../../worker/features/netsuite-togasupply-per-client-sync.md
|
|
24
24
|
- ../architecture.md
|
|
25
25
|
- ./error-capture-1-0.md
|
|
26
|
+
- ../../../clients/elite/features/togadesk-service-request-intake.md
|
|
26
27
|
---
|
|
27
28
|
|
|
28
29
|
## Summary
|
|
@@ -78,6 +79,22 @@ belong to the NetSuite importer — documented in the per-client-sync doc, not h
|
|
|
78
79
|
- Returns the decoded response object; callers read `->data->{resource}`, `->meta->nextPage`,
|
|
79
80
|
`->isSuccess`, `->status`, `->messages[].code`.
|
|
80
81
|
|
|
82
|
+
> **`send()` ALWAYS returns something carrying `isSuccess` — so `empty($response->isSuccess)` is a
|
|
83
|
+
> safe failure check on anything it returns, GETs included** (verified 2026-08-12, ~L432–450). It
|
|
84
|
+
> returns early **only** when `isSuccess` is true; when `isSuccess` is false and
|
|
85
|
+
> `$throwExceptionOnApiError = false` it falls out of the retry loop to `return $response`; and if
|
|
86
|
+
> the response is not an object or lacks the property at all it **throws**. There is no
|
|
87
|
+
> "successful response without `isSuccess`" case to defend against.
|
|
88
|
+
>
|
|
89
|
+
> **The corollary is the dangerous part:** with `$throwOnError = false`, a network blip, an auth
|
|
90
|
+
> failure or a 500 arrives as a **response**, not an exception. Code that reads straight on to
|
|
91
|
+
> `->data->{resource}` sees an empty result and concludes **"the record does not exist"** — which,
|
|
92
|
+
> for a duplicate check or a contact lookup, is exactly the wrong conclusion (it creates a second
|
|
93
|
+
> record). Check `empty($response->isSuccess)` *first*, and keep "unknown" distinct from "absent".
|
|
94
|
+
> See [Elite Service Request intake](../../../clients/elite/features/togadesk-service-request-intake.md)
|
|
95
|
+
> for the bugs this
|
|
96
|
+
> caused and the `?bool` tri-state fix.
|
|
97
|
+
|
|
81
98
|
> **`authenticate()` is also 1.0's ambient-client choke point for error reporting** (added
|
|
82
99
|
> 2026-08-04). Every 1.0→2.0 cron passes through it holding its `UUID_CLIENT`, so it calls
|
|
83
100
|
> `App_Error_Capture::setCurrentClientUuid()` — **store only, no lookup**: this is a happy-path
|
|
@@ -300,6 +317,10 @@ enable flags** and an optional `$monitorTogadeskDepartmentIds[]`:
|
|
|
300
317
|
the `App_Model` layer; follow the surrounding escaping discipline when modifying.
|
|
301
318
|
- **Per-record error isolation** in the TOGaDesk sync is via `try/catch` → `\Sentry\captureException`;
|
|
302
319
|
a thrown exception elsewhere (transport, checkpoint read) still aborts the whole run.
|
|
320
|
+
- **Naming `fields` can SUPPRESS a nested relation that a plain request returns.** Observed
|
|
321
|
+
2026-08-12 on `GET /states`: a bare request comes back with the nested `country`, but adding a
|
|
322
|
+
`fields` list drops it. This is the mirror image of the reverse-hasMany rule below — when a nested
|
|
323
|
+
object you expected is missing, suspect the field list before suspecting the data.
|
|
303
324
|
- **A reverse hasMany collection is only returned if you add it to the fetch `fields` whitelist.**
|
|
304
325
|
The `/contacts` fetch requests an **explicit** `fields` list from the 2.0 metadata API; a reverse
|
|
305
326
|
hasMany collection (e.g. `contactEmailAddresses.emailAddress`) is **not** returned unless it is
|
|
@@ -365,6 +386,14 @@ enable flags** and an optional `$monitorTogadeskDepartmentIds[]`:
|
|
|
365
386
|
|
|
366
387
|
## Change history
|
|
367
388
|
|
|
389
|
+
- 2026-08-12 — Recorded the **`send()` return contract**: it always returns a response carrying
|
|
390
|
+
`isSuccess` (early-returns on true, falls through to `return $response` on false, throws when the
|
|
391
|
+
property is absent), so `empty($response->isSuccess)` is a safe failure check on anything it
|
|
392
|
+
returns — including GETs — and a `$throwOnError = false` failure must never be read as "the record
|
|
393
|
+
does not exist". Also recorded that naming `fields` on `GET /states` **suppresses** the nested
|
|
394
|
+
country a plain request returns. Surfaced building
|
|
395
|
+
[App_Api_ServiceRequest](../../../clients/elite/features/togadesk-service-request-intake.md)
|
|
396
|
+
(TRUE-80497). (snaredla)
|
|
368
397
|
- 2026-08-10 — TRUE-79401 re-synced with `_production` (`c64164fe`, pushed); the sole conflict was a
|
|
369
398
|
positional const-block collision, resolved by keeping both sides. Patch intact; deploy still
|
|
370
399
|
unverified. `STARTECH_TOGADESK_CLIENTS` (credential registry, add a row per client onboard) is
|
|
@@ -87,9 +87,27 @@ already editing the method.
|
|
|
87
87
|
write only in your own folder and ask before touching another developer's.
|
|
88
88
|
- Lint 1.0 code with your **PHP 7.2** CLI (`php -l <file>`) — the prod worker/library target is
|
|
89
89
|
7.2, so 7.4+/8.x syntax must not appear.
|
|
90
|
+
- **⚠ `php -l` with the default CLI PROVES NOTHING about 7.2.** On a normal dev box the `php` on
|
|
91
|
+
`PATH` is 8.x (8.1.10 on the machine where this was checked), and 8.x happily parses arrow
|
|
92
|
+
functions, typed properties, `??=` and `match` — the exact constructs that break the prod
|
|
93
|
+
worker. You must invoke a **real 7.2 binary** (e.g. the PHP 7.2.33 build bundled with Laragon;
|
|
94
|
+
per-machine paths are a developer-local detail and are deliberately not recorded here). Two
|
|
95
|
+
practical wrinkles when you do:
|
|
96
|
+
- if that build's own `php.ini` is broken (one was, a syntax error on line 1939), run it with
|
|
97
|
+
**`-n`** to skip the ini entirely;
|
|
98
|
+
- `-n` also drops **mbstring**, so anything using `mb_*` needs it re-added:
|
|
99
|
+
`-d extension_dir=<php>/ext -d extension=php_mbstring.dll`.
|
|
100
|
+
|
|
101
|
+
Targets to keep straight: **`library` (and its 1.0 consumers) is PHP 7.2+**, while
|
|
102
|
+
**`worker2` / `_underscore` enforce 8.1** — a helper shared between the two must satisfy 7.2.
|
|
90
103
|
|
|
91
104
|
## Change history
|
|
92
105
|
|
|
106
|
+
- 2026-08-12 — Sharpened the 7.2 lint gotcha into a real verification procedure: the default CLI is
|
|
107
|
+
PHP 8.x, so `php -l` there proves nothing; use an actual 7.2 binary, add `-n` if its `php.ini` is
|
|
108
|
+
broken, and re-add mbstring via `-d extension_dir=… -d extension=php_mbstring.dll` when `-n`
|
|
109
|
+
drops it. Recorded the split targets (library 7.2+, worker2/_underscore 8.1). (snaredla)
|
|
110
|
+
|
|
93
111
|
- 2026-08-03 — Documented the static no-DB 1.0 harness technique, built for TRUE-79401
|
|
94
112
|
(`test/@Mark/AIG/test_multi_email.php`, 12 cases, PHP 7.2.33). Key enabler recorded:
|
|
95
113
|
`App_Database::sqlEscape()` is a pure `str_replace` (the `mysqli_real_escape_string` path is
|
|
@@ -44,6 +44,22 @@ web stack. Example harness: `C:\WWW\test\@Mark\TOGaDeskSupport\test_derive_custo
|
|
|
44
44
|
output-buffer wrap (step 6).
|
|
45
45
|
- Scripts should stay read-only against shared/mirrored data unless explicitly doing a data
|
|
46
46
|
fix.
|
|
47
|
+
- **A desk method that calls `isOwner()` will REDIRECT AND EXIT under CLI unless you seed the
|
|
48
|
+
session globals.** `isOwner()` reads the `$isAdmin` / `$liu` globals and, when they are absent,
|
|
49
|
+
emits a `header('Location: …')` + `exit` — from a CLI harness that looks like the script simply
|
|
50
|
+
stopping with no output and no error. Seed both globals before calling in
|
|
51
|
+
(`$isAdmin`, `$liu` with at least `id`/`clientid`). **Be explicit about what that means:** the
|
|
52
|
+
harness then tests everything *after* the permission gate — it does **not** test the gate itself,
|
|
53
|
+
so a permission regression will pass. Hit while harnessing
|
|
54
|
+
`Ticket::createEliteServiceRequest()` (see the
|
|
55
|
+
[Elite desk Service Request feature](../../../clients/elite/features/togadesk-service-request-intake.md)).
|
|
56
|
+
- **Lint/run with the right PHP.** Desk + library code targets **7.2**, and the default CLI on a dev
|
|
57
|
+
box is 8.x — see the
|
|
58
|
+
[7.2 verification procedure](../../test/features/static-no-db-regression-harness.md).
|
|
47
59
|
|
|
48
60
|
## Change history
|
|
61
|
+
- 2026-08-12 — Added the `isOwner()` CLI trap: a desk method that calls it redirects + exits when
|
|
62
|
+
`$isAdmin`/`$liu` are unset, which reads as "the script did nothing"; seed the globals, and record
|
|
63
|
+
that doing so means the harness covers everything **after** the permission gate but not the gate.
|
|
64
|
+
Also cross-linked the PHP 7.2 verification procedure. (snaredla)
|
|
49
65
|
- 2026-06-12 — captured from deriveCustomerId test harness work (mhammontree)
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
| [_ApiRequest — JSON encode/decode & api-logging behavior](features/apirequest-json-content-type.md) | `_ApiRequest` is the 2.0 outbound HTTP client. | _underscore/ApiRequest.php |
|
|
11
11
|
| [Assortment Name Translation (AssortmentTranslations sidecar)](features/assortment-name-translation.md) | Serves Assortment (product-grouping) **names** in multiple languages by adding a per-language **sidecar** table `AssortmentTranslations`, reusing the platform's | _underscore/Model/Client/AssortmentTranslation.php, dbchanges2/Client/2026-06-26a - AssortmentTranslations.sql, dbchanges2/Core/2026-06-26a - AssortmentTranslationsRecord.sql, dbchanges2/Client/2026-06-26b - AssortmentTranslationsAcl.sql |
|
|
12
12
|
| [Asynchronous Query Execution (writes-only, via Worker)](features/async-query-execution.md) | `_Query` can run a **write** query asynchronously so a long/slow write does not hold a request-scoped DB connection open long enough to hit **"MySQL server has | _underscore/Query.php, worker2/Worker/Infrastructure/Database.php, worker2/Worker/Team/Transcripts.php |
|
|
13
|
+
| [FIELD_SQL calculated fields — the underscore-prefix + same-name-method contract](features/calculated-sql-fields.md) | A `FIELD_SQL` (calculated) field on a `_Model` is bound by a **two-part contract that `_Model` enforces by throwing at model-construction time**, not by convent | _underscore/Model.php |
|
|
13
14
|
| [Carrier Shipping Labels (UPS/FedEx) & NetSuite Item Fulfillment](features/carrier-shipping-labels.md) | Backend mechanics behind TOGa Supply's Fulfill & Ship: buying a carrier label (UPS/FedEx), persisting it, and creating the NetSuite Item Fulfillment with tracki | test/@Mark/true-80824-fedex-inflate-test.php, dbchanges2/Client_Growrk/2026-08-10e - GrowrkUpsServiceMethodCodes.sql, _underscore/Model/Client/ItemFulfillment.php, _underscore/Model/Client/TrackingNumber.php, _underscore/Model/Client/ItemFulfillments/TrackingNumber.php, _underscore/Component/Library/LabelPdf/LabelPdf.php, _underscore/Component/Library/Carriers/ShipmentRequest/ShipmentRequest.php, _underscore/Component/Library/Carriers/Ups/Ups.php, _underscore/Component/Library/Carriers/Fedex/Fedex.php, _underscore/Trait/Netsuite/ItemFulfillment.php, _underscore/Trait/Netsuite/SalesOrder.php, _underscore/Component/Library/NetSuite/NetSuite.php, _underscore/Model/Client/TrackingNumber.php, _underscore/Model/Client/ShippingMethod.php, _underscore/Model.php, _underscore/Cloud.php |
|
|
14
15
|
| [Running 2.0 code from a bare CLI script (bootstrap + transactions)](features/cli-script-bootstrap.md) | A throwaway CLI script (a data check, a backfill dry-run, a render harness) that wants the real 2.0 framework — `_Model`, `_Query`, `_Database` — is **not** the | _underscore/Database.php, _underscore/Environment.php, api2/Initialize.php |
|
|
15
16
|
| [_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 |
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: FIELD_SQL calculated fields — the underscore-prefix + same-name-method contract
|
|
3
|
+
framework: "2.0"
|
|
4
|
+
repo: _underscore
|
|
5
|
+
project: _Underscore
|
|
6
|
+
client: shared
|
|
7
|
+
type: feature
|
|
8
|
+
status: active
|
|
9
|
+
updated: 2026-08-12
|
|
10
|
+
owners: [snaredla]
|
|
11
|
+
files:
|
|
12
|
+
- _underscore/Model.php
|
|
13
|
+
related:
|
|
14
|
+
- ../architecture.md
|
|
15
|
+
- ./model-magic-field-access.md
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
## Summary
|
|
19
|
+
|
|
20
|
+
A `FIELD_SQL` (calculated) field on a `_Model` is bound by a **two-part contract that `_Model`
|
|
21
|
+
enforces by throwing at model-construction time**, not by convention:
|
|
22
|
+
|
|
23
|
+
1. the field name **must** begin with `_`;
|
|
24
|
+
2. a **`public static` method of the EXACT same name** must exist on the model.
|
|
25
|
+
|
|
26
|
+
Both are checked in `_underscore/Model.php` (~L130 and ~L132) while the field definitions are
|
|
27
|
+
parsed, so a violation is not a lint smell — the model is simply unusable.
|
|
28
|
+
|
|
29
|
+
## How it works
|
|
30
|
+
|
|
31
|
+
```php
|
|
32
|
+
public $_serialNumbers = [self::FIELD_SQL, self::FIELDOPT_SQL_TYPE => self::FIELD_CHAR];
|
|
33
|
+
|
|
34
|
+
public static function _serialNumbers(string $table = self::TABLE) {
|
|
35
|
+
return "(SELECT … FROM … WHERE …)";
|
|
36
|
+
}
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
The name must match exactly because the expression is invoked **by variable method name**
|
|
40
|
+
(`_underscore/Model.php:641`):
|
|
41
|
+
|
|
42
|
+
```php
|
|
43
|
+
$value = '(' . get_called_class()::$field($this::TABLE) . ')';
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
`$field` is the property name, so there is no mapping layer, no camelCase conversion and no
|
|
47
|
+
fallback — `_serialNumbers` the property requires `_serialNumbers()` the method.
|
|
48
|
+
|
|
49
|
+
Options: `FIELDOPT_SQL_TYPE` (defaults to `FIELD_CHAR`) and `FIELDOPT_SQL_STORED` (default `false`,
|
|
50
|
+
recalculated per query; `true` persists to a real column on save).
|
|
51
|
+
|
|
52
|
+
## Gotchas / known issues
|
|
53
|
+
|
|
54
|
+
- **The leading underscore is REQUIRED — it is not a camelCase-standard violation.** The TOGA
|
|
55
|
+
naming standard says database/class fields are camelCase, and a reviewer (human or agent) will
|
|
56
|
+
reflexively flag `_serialNumbers` and "fix" it to `serialNumbers`. Doing so **throws**:
|
|
57
|
+
*"All field which are of the type 'FIELD_SQL' must begin with an underscore"*. Calculated fields
|
|
58
|
+
are the documented exception; leave the underscore alone.
|
|
59
|
+
- **Renaming a calculated field is a two-line change.** Rename the property without renaming the
|
|
60
|
+
static method (or vice versa) and construction throws with *"A function returning a SQL
|
|
61
|
+
expression has not been defined…"*.
|
|
62
|
+
- **`FIELDOPT_SQL_STORED => true` needs a refresh after bulk migrations** — the stored value is
|
|
63
|
+
written on save, so rows changed by raw SQL keep a stale value.
|
|
64
|
+
|
|
65
|
+
## Change history
|
|
66
|
+
|
|
67
|
+
- 2026-08-12 — Documented the enforced `FIELD_SQL` contract after `_serialNumbers` was queried as a
|
|
68
|
+
camelCase violation: `_Model` **throws** both when the field lacks the `_` prefix
|
|
69
|
+
(`_underscore/Model.php` ~L130) and when a static method of the exact same name is missing
|
|
70
|
+
(~L132), because the expression is invoked as `get_called_class()::$field($this::TABLE)` (L641).
|
|
71
|
+
The underscore prefix is therefore required, not stylistic. (snaredla)
|
|
@@ -3,7 +3,7 @@
|
|
|
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
|
-
| [API Payload Interceptors (metadata-registered prePost/postPut model hooks)](features/api-payload-interceptors.md) | A `_Model`'s `prePost()` / `postPost()` / `prePut()` / `postPut()` hooks are **not** called by the model. | api2/Component/Api/V2/V2.php, api2/Controller/Index.php, _underscore/Model/Client/ItemFulfillment.php, _underscore/Model/Elite/SalesOrder.php, _underscore/Model/Compass/SalesOrder.php, _underscore/Model/Compass/SalesOrderItem.php, dbchanges2/Core/2026-06-30a - ItemFulfillmentStageDefaultInterceptor.sql |
|
|
6
|
+
| [API Payload Interceptors (metadata-registered prePost/postPut model hooks)](features/api-payload-interceptors.md) | A `_Model`'s `prePost()` / `postPost()` / `prePut()` / `postPut()` hooks are **not** called by the model. | api2/Component/Api/V2/V2.php, api2/Controller/Index.php, _underscore/Model/Client/ItemFulfillment.php, _underscore/Model/Elite/SalesOrder.php, _underscore/Model/Elite/ServiceRequest.php, _underscore/Model/Compass/SalesOrder.php, _underscore/Model/Compass/SalesOrderItem.php, dbchanges2/Core/2026-06-30a - ItemFulfillmentStageDefaultInterceptor.sql, dbchanges2/Core/2026-08-06a - Service request and sales order payload interceptors.sql |
|
|
7
7
|
| [Multi-Client (Cross-Client) Data Retrieval](features/cross-client-data-retrieval.md) | A single authenticated V2 GET listing can return records across **many** clients (designed for 1000+) that the caller is entitled to, honoring **each target cli | api2/Component/Api/CrossClient/CrossClient.php, api2/Component/Api/V2/V2.php, api2/Controller/Index.php, _underscore/Model/Cache/Table.php, _underscore/Model/Cache/Tables/Client.php, _underscore/Model/Core/Record.php, worker2/Worker/Platform/Cache.php, worker2/Controller/Index.php, worker2/_.php, dbchanges2/Cache/2026-06-30a - MultiClientCacheTables.sql, dbchanges2/Core/2026-06-30b - CacheClusterRegistrationAndRecordTtl.sql, dbchanges2/Core/2026-07-27a - PlatformCacheCleanCron.sql |
|
|
8
8
|
| [Encrypted-User-UUID Auth Handoff (/auth/encrypted-user-uuid)](features/encrypted-user-uuid-auth-handoff.md) | `POST /auth/encrypted-user-uuid` is the intended **cross-client / SSO-handoff identity mechanism**: given an encrypted `{client, user}` UUID pair, it mints a fr | api2/Component/Api/CrossClient/CrossClient.php |
|
|
9
9
|
| [ENVIRONMENT (not the EB environment name) decides the _underscore branch and Config file](features/environment-variable-drives-underscore-branch.md) | An api2 Elastic Beanstalk instance decides **which `_underscore` branch it clones** and **which `Config/<env>.ini` it loads** from the EB environment property * | api2/.ebextensions/git.php, api2/.ebextensions/php_include_underscore.config, api2/.ebextensions/git.sandbox-dev.json, api2/Config/beta.ini, api2/Config/sandbox-dev.ini, api2/Component/Api/V2/V2.php |
|
|
@@ -6,16 +6,18 @@ project: API
|
|
|
6
6
|
client: shared
|
|
7
7
|
type: feature
|
|
8
8
|
status: active
|
|
9
|
-
updated: 2026-08-
|
|
10
|
-
owners: ["mhammontree", "dfranks", "bala"]
|
|
9
|
+
updated: 2026-08-12
|
|
10
|
+
owners: ["mhammontree", "dfranks", "bala", "snaredla"]
|
|
11
11
|
files:
|
|
12
12
|
- api2/Component/Api/V2/V2.php
|
|
13
13
|
- api2/Controller/Index.php
|
|
14
14
|
- _underscore/Model/Client/ItemFulfillment.php
|
|
15
15
|
- _underscore/Model/Elite/SalesOrder.php
|
|
16
|
+
- _underscore/Model/Elite/ServiceRequest.php
|
|
16
17
|
- _underscore/Model/Compass/SalesOrder.php
|
|
17
18
|
- _underscore/Model/Compass/SalesOrderItem.php
|
|
18
19
|
- dbchanges2/Core/2026-06-30a - ItemFulfillmentStageDefaultInterceptor.sql
|
|
20
|
+
- dbchanges2/Core/2026-08-06a - Service request and sales order payload interceptors.sql
|
|
19
21
|
related:
|
|
20
22
|
- ./record-scripts.md
|
|
21
23
|
- ../architecture.md
|
|
@@ -70,6 +72,13 @@ and prod — so a validation added to `_Model_Compass_PurchaseOrder::prePost` ca
|
|
|
70
72
|
tenant's other APIs at all. See
|
|
71
73
|
[Compass MITS PO → SO Item Linking](../../../clients/compass-usa/features/mits-po-to-so-item-linking.md).
|
|
72
74
|
|
|
75
|
+
**One client / all APIs is the `Core` table with `clientId` set and `apiId` NULL** — it does not
|
|
76
|
+
have to live in `Client_<X>.ApiPayloadInterceptors`. Elite's service-request and sales-order rows
|
|
77
|
+
were registered that way in `Core` (`clientId 41`, `apiId NULL`, `minDepth 1`, records **35**
|
|
78
|
+
`service-requests` and **14** `sales-orders`) — see
|
|
79
|
+
[Elite SalesOrder → NetSuite push](../../../clients/elite/features/salesorder-netsuite-push.md).
|
|
80
|
+
**So "this client has no interceptors" is only true after you have checked BOTH tables.**
|
|
81
|
+
|
|
73
82
|
## The interceptor sees payload keys **verbatim** (no `c_` stripping, no rename)
|
|
74
83
|
|
|
75
84
|
**Verified empirically** by a live HTTP probe against local dev
|
|
@@ -90,6 +99,25 @@ tenant's other APIs at all. See
|
|
|
90
99
|
> result above is authoritative; the code path is misleading. Anyone tempted to reason it out
|
|
91
100
|
> from V2.php will reach the wrong answer.
|
|
92
101
|
|
|
102
|
+
### In a POST-processing interceptor, `$payload` is the RECORD — not a route-keyed envelope
|
|
103
|
+
|
|
104
|
+
A `postPost` / `postPut` hook receives **the record object itself, or an array of record objects**.
|
|
105
|
+
It is **not** wrapped under a route key, because api2 wraps the response into
|
|
106
|
+
`{ salesOrders: … }` **after** the post-processing interceptors have run. So the correct read is:
|
|
107
|
+
|
|
108
|
+
```php
|
|
109
|
+
$record = (is_array($payload) ? ($payload[0] ?? null) : $payload);
|
|
110
|
+
$uuid = (string)($record->uuid ?? '');
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
Two consequences worth internalising:
|
|
114
|
+
|
|
115
|
+
- **Never reassign `$payload` to the single record you pulled out.** `$payload` is by reference; a
|
|
116
|
+
multi-record response would lose everything after the first. Read into a local variable.
|
|
117
|
+
- Reaching for `$payload->salesOrders` (the HTTP-response shape) yields `null` and the hook
|
|
118
|
+
silently does nothing — the same class of failure as the `internalApiRequest` `data`-envelope
|
|
119
|
+
trap below.
|
|
120
|
+
|
|
93
121
|
## The dispatch is UNGUARDED — a row naming a method that does not exist hard-fatals the endpoint
|
|
94
122
|
|
|
95
123
|
The derived name (step 2 above) is **pure convention with nothing validating it**, and there is
|
|
@@ -147,6 +175,16 @@ when `$outData` is non-null**, and a **successful DELETE always sets `$outData =
|
|
|
147
175
|
`(recordId, POST, DELETE)` row is dead on arrival: the method is never called, and — exactly like
|
|
148
176
|
the missing-row case — there is no error and no log line to tell you.
|
|
149
177
|
|
|
178
|
+
**The same `if ($outData)` guard (~L5932) is why an interceptor problem is scoped, not global.**
|
|
179
|
+
The whole post-processing block is skipped whenever there is no response data, and it only ever
|
|
180
|
+
resolves a class for the record the request actually touched. So a *missing* row, or a model with
|
|
181
|
+
no such method, cannot break unrelated traffic. This was checked under fire: during a "this will
|
|
182
|
+
break production" alarm, **254 live PUTs kept returning 200** while the suspect record had no
|
|
183
|
+
usable hook. The alarm was wrong. What *is* fatal is narrower and precise: a row that resolves to
|
|
184
|
+
a class which does not define the derived method — *"Call to undefined method"* — for **that**
|
|
185
|
+
record's writes only. Precedent for the cleanup:
|
|
186
|
+
`dbchanges2/Client_Compass/2026-08-06 - RemoveBrokenSalesOrderItemPostPostInterceptor.sql`.
|
|
187
|
+
|
|
150
188
|
**Therefore any logic that must react to a deletion runs in `preDelete`, and must exclude the row
|
|
151
189
|
that is about to disappear**, because at `preDelete` time it is still present. The Compass line
|
|
152
190
|
renumberer takes the id explicitly for this reason:
|
|
@@ -169,6 +207,17 @@ The chain is `_Model_Compass_Usa_X extends _Model_Compass_X extends _Model_Clien
|
|
|
169
207
|
defined on the shared Compass base is inherited by both the Usa and Canada subclasses** — a
|
|
170
208
|
per-region subclass does *not* need its own copy. Do not add duplicate hooks per region.
|
|
171
209
|
|
|
210
|
+
**⚠ …and by the same inheritance, a hook on the shared `_Model_Client_X` runs for EVERY tenant that
|
|
211
|
+
has a row for that record.** Client-specific behaviour must go on `_Model_<Client>_X`, never on
|
|
212
|
+
`_Model_Client_X`. Worked near-miss (Elite, 2026-08-12): putting the NetSuite push on
|
|
213
|
+
`_Model_Client_SalesOrder::postPost` would have pushed **Compass and Quad** sales orders to NetSuite
|
|
214
|
+
too — both subclasses call `parent::postPost()`, and Compass has an active `recordId 14` interceptor
|
|
215
|
+
row. The behaviour was placed on `_Model_Elite_SalesOrder` / `_Model_Elite_ServiceRequest` instead.
|
|
216
|
+
|
|
217
|
+
The corollary is that **reading the client subclass is not enough to know who a hook affects** —
|
|
218
|
+
you must also read the parent chain *and* the interceptor rows of every tenant that overrides the
|
|
219
|
+
same record.
|
|
220
|
+
|
|
172
221
|
### `Core.Records` facts for interceptor debugging
|
|
173
222
|
|
|
174
223
|
- **`Core.Records.model` is UNIQUE** — exactly one record row per model class, so an interceptor's
|
|
@@ -286,6 +335,12 @@ and the failing environment**. It is a small table, and the drift is usually exa
|
|
|
286
335
|
change that switches on a *code* path; if the PHP defining the method is not confirmed deployed to
|
|
287
336
|
that environment, the row takes the endpoint down. Insert inactive, verify the deploy, then flip
|
|
288
337
|
`isActive = 1` — and remember every environment activates independently.
|
|
338
|
+
- **⚠ A post interceptor's `$payload` is the record, not a route-keyed envelope** — and it is
|
|
339
|
+
by-reference, so pull the record into a local instead of reassigning `$payload`.
|
|
340
|
+
- **⚠ Client behaviour never goes on `_Model_Client_X`.** Every tenant with a row for that record
|
|
341
|
+
inherits it through `parent::` calls. Put it on `_Model_<Client>_X`.
|
|
342
|
+
- **A client's rows may be in `Core` (with `clientId`), not in `Client_<X>`** — check both before
|
|
343
|
+
concluding a client has no interceptors.
|
|
289
344
|
- **The method name is derived, so a typo'd enum silently misses.** A row with
|
|
290
345
|
`prePostProcessing = 'PRE'`, `httpMethod = 'PUT'` resolves to `prePut`, not `prePost`; the engine
|
|
291
346
|
will simply find no method and move on.
|
|
@@ -310,6 +365,23 @@ and the failing environment**. It is a small table, and the drift is usually exa
|
|
|
310
365
|
|
|
311
366
|
## Change history
|
|
312
367
|
|
|
368
|
+
- 2026-08-12 — TRUE-80498/80501, from building the Elite service-request chain. (1) **A
|
|
369
|
+
post-processing `$payload` is the record itself (or an array of records), NOT wrapped in a route
|
|
370
|
+
key** — api2 applies the route envelope only *after* the post interceptors run, so
|
|
371
|
+
`$payload->salesOrders` is `null` and the hook silently no-ops; read into a local
|
|
372
|
+
(`is_array($payload) ? $payload[0] : $payload`) and never reassign the by-reference `$payload`,
|
|
373
|
+
which would drop every record after the first. (2) Recorded what the `if ($outData)` guard
|
|
374
|
+
(~L5932) actually buys: the post block is skipped when there is no response data and only
|
|
375
|
+
resolves the touched record's class, so a missing row or absent method **cannot** break unrelated
|
|
376
|
+
traffic — a "this will break prod" alarm was disproved by **254 live PUTs still returning 200**;
|
|
377
|
+
the fatal case remains narrow (a row resolving to a class without the derived method), with
|
|
378
|
+
`dbchanges2/Client_Compass/2026-08-06 - RemoveBrokenSalesOrderItemPostPostInterceptor.sql` as the
|
|
379
|
+
cleanup precedent. (3) **A hook on the shared `_Model_Client_X` runs for every tenant with a row
|
|
380
|
+
for that record** — putting the Elite NetSuite push there would have pushed Compass and Quad
|
|
381
|
+
orders too (both call `parent::postPost()`, Compass has an active `recordId 14` row), so it went
|
|
382
|
+
on `_Model_Elite_*`. (4) **One-client/all-APIs registration lives in `Core` with `clientId` set
|
|
383
|
+
and `apiId NULL`** (Elite: `clientId 41`, records 35 + 14, `minDepth 1`) — "this client has no
|
|
384
|
+
interceptors" requires checking **both** tables. Line numbers are approximate. (snaredla)
|
|
313
385
|
- 2026-08-11 — Three findings from the post-mortem of the `_Model_Compass_Usa_SalesOrderItem::postPost()`
|
|
314
386
|
outage (investigation only, no code change). (1) **`postDelete` can never fire** — the
|
|
315
387
|
post-processing block at `V2.php` ~L5692 runs only when `$outData` is non-null and a successful
|
|
@@ -35,6 +35,7 @@
|
|
|
35
35
|
| [OneUptime Incident → ClickUp Task Sync (Monitor/Oneuptime/SyncIncidents)](features/oneuptime-incident-clickup-sync.md) | An internal/shared TOGA ops feature: worker2 polls the OneUptime API every 15 minutes for **currently-open** incidents and ensures a ClickUp task exists for eac | worker2/Component/Api/Oneuptime/Oneuptime.php, worker2/Worker/Monitor/Oneuptime.php, worker2/Config/production.ini, dbchanges2/Team/2026-08-10a - OneUptime Incident ClickUp Tasks.sql, dbchanges2/Core/2026-08-10b - OneUptime Incident Sync CronJob.sql, dbchanges2/Team/2026-08-12a - OneUptime Incident Tasks Closed Marker.sql |
|
|
36
36
|
| [OneUptime push-metric monitors for 2.0 workers](features/oneuptime-worker2-monitoring.md) | A second, **OneUptime-reporting** monitoring pattern for the 2.0 worker2 tier, ported from the 1.0 `App_SystemMonitor_Compass` monitors. | worker2/Worker/Monitor/Compass.php, worker2/Worker/Client/Compass.php, worker2/composer.json, _underscore/Cloud.php, worker2/Worker/Monitor/Operations.php |
|
|
37
37
|
| [Platform Cache Cleanup (_Worker_Platform_Cache — Clean + Truncate)](features/platform-cache-cleanup.md) | `_Worker_Platform_Cache` owns maintenance of the shared **Cache** cluster that backs api2's [multi-client data retrieval](../../api2/features/cross-client-data- | worker2/Worker/Platform/Cache.php, worker2/Controller/Index.php, worker2/_.php, dbchanges2/Core/2026-07-27a - PlatformCacheCleanCron.sql |
|
|
38
|
+
| [Service Request → Sales Order → Purchase Order generation (Sync/ServiceRequest)](features/service-request-sales-order-generation.md) | `_Worker_Sync_ServiceRequest` turns a **Service Request into a Sales Order, and then into one Purchase Order per vendor**, for **any** tenant. | worker2/Worker/Sync/ServiceRequest.php, _underscore/Model/Elite/ServiceRequest.php, dbchanges2/Core/2026-08-06a - Service request and sales order payload interceptors.sql |
|
|
38
39
|
| [Startech Webhook Handler (worker2)](features/startech-webhook-handler.md) | Receives inbound webhook events from Startech (Easeedesk) and creates or updates the corresponding ticket in TOGA 2.0. | worker2/Worker/Startech.php |
|
|
39
40
|
| [Talos (TOGa IQ) Meeting-Notes Integration & Token Auto-Refresh (consumer)](features/talos-meeting-notes-integration.md) | How a **dev tool / agent consumes Talos (TOGa IQ)** to query the team meeting-notes corpus programmatically. | .claude/skills/plan-ticket/scripts/talos.js |
|
|
40
41
|
| [Talos Pricing Automation (worker2 Cron — Usage Import, AWS Actuals, Margins, Profiles)](features/talos-pricing-automation.md) | The worker2 half of the **Talos Pricing Platform** (see the talos `pricing-cogs-model` and tools `talos-pricing-ui` docs for the other halves). | worker2/Worker/Talos/Pricing.php, worker2/Database/TalosPricingCrons.sql, dbchanges2/Team/2026-08-03a - TalosPricingTokenCostModel.sql, dbchanges2/Team/2026-08-03b - TalosInternalTenant.sql, dbchanges2/Team/2026-08-03c - TalosIntegrationCostFactor.sql, dbchanges2/Core/2026-08-03a - Talos pricing cron jobs.sql |
|
|
@@ -223,6 +223,24 @@ Debugging consequence: **no row means either "the caller never queued it" or "th
|
|
|
223
223
|
and never consumed."** Those have completely different fixes. Distinguish them with the **SQS queue
|
|
224
224
|
metrics**, not the database.
|
|
225
225
|
|
|
226
|
+
## ⚠ Renaming an action is a CROSS-REPO rename — deploy both sides together
|
|
227
|
+
|
|
228
|
+
The action name is a **string** held by the producers, not a symbol the compiler can check. Renaming
|
|
229
|
+
a method in `worker2` without renaming every `runTask(action: '…')` string — which usually lives in
|
|
230
|
+
**another repo** (`_underscore` models, api2 interceptor code, crons, `Core.CronJobs.action` rows) —
|
|
231
|
+
makes the dispatcher target a name that no longer exists.
|
|
232
|
+
|
|
233
|
+
**And the failure is silent.** Producers routinely wrap `runTask` in `try/catch` + `error_log` so a
|
|
234
|
+
queue failure cannot fail the originating write, so the user's record saves, nothing downstream
|
|
235
|
+
happens, and no error surfaces. Worked example: `GenerateSalesOrder` →
|
|
236
|
+
`GenerateSalesOrderFromServiceRequest` spans `worker2/Worker/Sync/ServiceRequest.php` and the
|
|
237
|
+
dispatch string in `_underscore/Model/Elite/ServiceRequest.php:44` — see the
|
|
238
|
+
[Service Request → Sales Order generation](./service-request-sales-order-generation.md).
|
|
239
|
+
|
|
240
|
+
Rules: rename **both sides in one PR and one deploy**; grep every repo for the old string (including
|
|
241
|
+
`Core.CronJobs.action` and migrations) before merging; and treat it exactly like the
|
|
242
|
+
`WorkerJobs.output` multi-producer column rename in [architecture.md](../architecture.md).
|
|
243
|
+
|
|
226
244
|
## Gotchas
|
|
227
245
|
|
|
228
246
|
- Class must be **`abstract`** and methods **`public static`** or routing fails.
|
|
@@ -269,6 +287,13 @@ metrics**, not the database.
|
|
|
269
287
|
commit-before-SQS transaction pattern that the worker relies on.
|
|
270
288
|
|
|
271
289
|
## Change history
|
|
290
|
+
- 2026-08-12 — Added **"renaming an action is a cross-repo rename"**: the action name is a string
|
|
291
|
+
held by the producer (usually an `_underscore` model, a cron row or a migration in another repo),
|
|
292
|
+
so renaming the worker method alone makes `runTask` dispatch to a name that no longer exists —
|
|
293
|
+
and because producers wrap `runTask` in try/catch + `error_log`, the whole downstream chain stops
|
|
294
|
+
**silently**. Rename both sides in one PR/deploy. Worked example: `GenerateSalesOrder` →
|
|
295
|
+
`GenerateSalesOrderFromServiceRequest` across worker2 and
|
|
296
|
+
`_underscore/Model/Elite/ServiceRequest.php:44`. (snaredla)
|
|
272
297
|
- 2026-08-11 — Added the **nested-JSON-object → `stdClass` dispatch gotcha**: the dispatcher casts
|
|
273
298
|
only the top level of `CronJobs.parameters` to an array before spreading as named args
|
|
274
299
|
(`Controller/Index.php` ~L455/692), so a nested JSON object stays `stdClass` and a strict
|