toga-ai 1.0.190 → 1.0.192

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,6 +5,7 @@
5
5
  | [TOGa Desk Architecture](architecture.md) | TOGa Desk is the staff-facing support desk **and field-service platform** (analysts work at `/desk/`). | desk/index.php, desk/includes/loader.php, desk/config.php, desk/includes/controllers/actions.php, desk/includes/controllers/data.php, desk/includes/controllers/modals.php, desk/includes/classes/class.app.php, desk/includes/classes/class.ticket.php, desk/api/index.php, desk/api/resources/tickets.php, crons/tickets.php |
6
6
  | [Email-to-Ticket Intake (crons/tickets.php)](features/email-to-ticket-intake.md) | TOGa Desk ingests support email into tickets through a cron-driven IMAP poller (`crons/tickets.php`) plus a postfix pipe variant (`crons/pipe.php`). | crons/tickets.php, crons/tickets_prod.php, crons/pipe.php, desk/includes/classes/class.ticket.php |
7
7
  | [Field-Service Dispatch (central / repair orders)](features/field-service-dispatch.md) | The **central** subsystem is TOGa Desk's field-service dispatch domain: repair-order lifecycle, technician scheduling, onsite vs depot service, parts, and shipm | desk/includes/controllers/actions/central/, desk/includes/classes/class.repair.php, desk/includes/classes/class.repairhistory.php, desk/_/browser/datatable/central.php, desk/template/pages/central.php, desk/template/pages/central/view.php, desk/template/modals/central/addTracking.php, library/app/model/togadesk/repairordertracking.php, library/app/api/carrier/ups.php |
8
+ | [Ticket Email Notifications (notifications table)](features/notifications.md) | Which TOGa Desk emails fire for a given client is driven **entirely by data**, not code: the `TOGaDeskSupport.notifications` table holds one row per `(clientid, | desk/includes/classes/class.notification.php, crons/tickets.php, crons/tickets_prod.php |
8
9
  | [REST API (RPC-over-POST) & API-Key Auth](features/rest-api.md) | TOGa Desk exposes a programmatic API at `desk/api/`. | desk/api/index.php, desk/api/resources/tickets.php, desk/api/resources/assets.php, desk/api/resources/authenticate.php, desk/includes/classes/class.apikey.php, desk/includes/functions.php |
9
10
  | [SMB Contract Editing & the clientMspId Corruption Trap](features/smb-contract-editing.md) | The SMB contracts page (`/desk/?route=toga/smbcontracts&togaClientId=<id>`) edits `TOGA_*.SMBContracts` rows via a modal. | desk/template/modals/toga/smbcontracts/smbContract.php, desk/includes/controllers/modals/toga/smbcontracts/smbContract.php, desk/includes/controllers/actions/toga/smbcontracts/smbContract.php, desk/includes/controllers/actions/toga/smbcontracts/edit.php |
10
11
  | [Ticket Lifecycle (class.ticket.php)](features/ticket-lifecycle.md) | All TOGa Desk ticket creation and reply handling funnels through `Ticket` in `desk/includes/classes/class.ticket.php`. | desk/includes/classes/class.ticket.php, desk/includes/controllers/actions.php, desk/api/resources/tickets.php, crons/tickets.php, desk/includes/controllers/actions/tickets/merge.php |
@@ -27,6 +27,7 @@ related:
27
27
  - 1.0/apps/togadesk/features/rest-api.md
28
28
  - 1.0/apps/togadesk/features/field-service-dispatch.md
29
29
  - 1.0/apps/togadesk/features/email-to-ticket-intake.md
30
+ - 1.0/apps/togadesk/features/notifications.md
30
31
  - 1.0/apps/togadesk/workflows/standalone-test-scripts.md
31
32
  ---
32
33
 
@@ -203,4 +204,5 @@ Invariants:
203
204
  - [REST API](features/rest-api.md)
204
205
  - [Field-service dispatch (central)](features/field-service-dispatch.md)
205
206
  - [Email-to-ticket intake](features/email-to-ticket-intake.md)
207
+ - [Ticket email notifications](features/notifications.md)
206
208
  - [SMB contract editing](features/smb-contract-editing.md)
@@ -0,0 +1,79 @@
1
+ ---
2
+ title: Ticket Email Notifications (notifications table)
3
+ framework: "1.0"
4
+ repo: togadesk
5
+ project: TOGa Desk
6
+ client: shared
7
+ type: feature
8
+ status: active
9
+ updated: 2026-06-24
10
+ owners: ["ajean"]
11
+ files:
12
+ - desk/includes/classes/class.notification.php
13
+ - crons/tickets.php
14
+ - crons/tickets_prod.php
15
+ related:
16
+ - 1.0/apps/togadesk/features/ticket-lifecycle.md
17
+ - 1.0/apps/togadesk/features/email-to-ticket-intake.md
18
+ - 1.0/apps/togadesk/architecture.md
19
+ ---
20
+
21
+ ## Summary
22
+ Which TOGa Desk emails fire for a given client is driven **entirely by data**, not code:
23
+ the `TOGaDeskSupport.notifications` table holds one row per `(clientid, type)`. If a client
24
+ has no row for a given `type`, **that notification silently does not send** — there is no
25
+ fallback to a global/default template. So a "staff never got notified" report is almost
26
+ always a **missing notification row for that client**, not a recipient-config or code bug.
27
+
28
+ ## How it works
29
+ Each ticket event (new ticket, reply, assignment, escalation, auto-close, new account) looks
30
+ up a `notifications` row keyed by the ticket's `clientid` and a fixed `type` string. A match
31
+ renders and sends; **no match = no email, no error, no log**. Notifications split into two
32
+ audiences:
33
+
34
+ - **Submitter-facing (`USER_*` / `PEOPLE_*`):** `USER_NEW_TICKET` (creation acknowledgment),
35
+ `USER_NEW_TICKET_REPLY`, `USER_AUTO_CLOSE`, `PEOPLE_USER_NEW_ACCOUNT`.
36
+ - **Staff-facing (`STAFF_*`):** `STAFF_NEW_TICKET`, `STAFF_NEW_TICKET_REPLY`,
37
+ `STAFF_TICKET_ASSIGNED_TO_DEPARTMENT`, `STAFF_TICKET_ASSIGNED_TO_PERSON`,
38
+ `STAFF_TICKET_ESCALATION`.
39
+
40
+ Most clients have the **full** set of both audiences (e.g. clientids 2, 3, 19, 20, 138). A
41
+ client configured with only `USER_*`/`PEOPLE_*` rows will acknowledge submitters but **never
42
+ notify staff**.
43
+
44
+ ### Staff recipient resolution (separate from the `type` lookup)
45
+ Once a `STAFF_*` notification fires, recipients for the ticket's department are resolved via
46
+ `TOGaDeskSupport.people_departments` (peopleid ↔ departmentid) joined to
47
+ `TOGaDeskSupport.people`, filtered to `isActive = 1` **and** `ticketsnotification = 1`.
48
+ Recipient config and the `(clientid, type)` row are independent: correct recipients still get
49
+ nothing if the `STAFF_*` row is absent.
50
+
51
+ ## Data model
52
+ - `TOGaDeskSupport.notifications` — keyed `(clientid, type)`; one row per enabled notification
53
+ per client. Absence of a row = that notification disabled for that client.
54
+ - `TOGaDeskSupport.people` — `isActive`, `ticketsnotification` (1 = opted into ticket email).
55
+ - `TOGaDeskSupport.people_departments` — peopleid ↔ departmentid mapping.
56
+
57
+ ## Gotchas / known issues
58
+ - **Silent disable by omission.** A missing `(clientid, type)` row produces no email and no log
59
+ line — diagnose "missing notification" reports by querying `notifications` for that client's
60
+ rows first, before suspecting recipient flags or code.
61
+ - **TOGA Technology Helpdesk (clientid 178) had only the `USER_*`/`PEOPLE_*` rows and was
62
+ missing every `STAFF_*` row** (notably `STAFF_NEW_TICKET`). Submitters got their
63
+ `USER_NEW_TICKET` acknowledgment, but no staff were notified of new tickets even though
64
+ department-9 staff had `ticketsnotification = 1`. Root cause was purely the missing rows;
65
+ fixed by adding the `STAFF_*` rows via the TOGa Desk UI (no code change). Reproduced with
66
+ internal ticket #178000688 (internal id 263292, department 9, origin EMAIL).
67
+ - Tickets with **origin EMAIL** are created by the email-to-ticket cron
68
+ (`crons/tickets_prod.php`, run every ~30 min from the worker's `cron.worker.togadesk.json`
69
+ out of the sibling `../ontrack` checkout) — see the email-to-ticket intake doc. The
70
+ notification still depends on a `STAFF_NEW_TICKET` row existing for the ticket's client.
71
+
72
+ ## Change history
73
+ - 2026-06-24 — documented the `notifications` `(clientid, type)` data model and staff-recipient
74
+ resolution; recorded the clientid-178 missing-`STAFF_*`-rows incident (ajean)
75
+
76
+ ## Related docs
77
+ - [Ticket lifecycle](ticket-lifecycle.md)
78
+ - [Email-to-ticket intake](email-to-ticket-intake.md)
79
+ - [TOGa Desk architecture](../architecture.md)
@@ -6,6 +6,7 @@
6
6
  | [ACL Permission Chain (Record & Field Authorization)](features/acl-permission-chain.md) | Authorization in the 2.0 API is **metadata-driven**: whether a role may Create/Read/Update/Delete a record is decided by rows across **four linked tables**, not | api2/Component/Api/V2/V2.php, _underscore/Model/Core/Page.php, dbchanges2/Client/2026-06-03- BLANK_CLIENT_DATABASE.sql, dbchanges2/Client/2026-06-23b - ItemTranslationsAcl.sql |
7
7
  | [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 | _underscore/Model/Client/ItemFulfillment.php, _underscore/Model/Client/ItemFulfillments/TrackingNumber.php, _underscore/Component/Library/LabelPdf/LabelPdf.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 |
8
8
  | [Client Email Template Sending](features/email-template-sending.md) | `_Model_Client_EmailTemplate` sends a stored, client-defined email template by UUID. | _underscore/Model/Client/EmailTemplate.php, _underscore/Model/Client/EmailTemplateOutgoingEmailAddress.php, _underscore/Email.php |
9
+ | [NetSuite REST Client (_Component_Api_Netsuite) — record writes & SuiteQL](features/netsuite-rest-client.md) | `_Component_Api_Netsuite` is the **2.0 `_underscore` NetSuite REST client** — the shared primitive every worker2/api2 NetSuite caller uses for record GETs, Suit | _underscore/Component/Api/Netsuite/Netsuite.php |
9
10
  | [Per-Client Database Connections & the Local Logs Trap](features/per-client-database-connections.md) | When `_underscore` serves a request for a client it opens **three distinct per-client database connections**, not one. | _underscore/Database.php, _underscore/ApiRequest.php, _underscore/Model/Client/Logs/Api.php |
10
11
  | [Recursive Item Fulfillments (upstream mirroring)](features/recursive-item-fulfillments.md) | In a multi-tier supply chain a sales order (SO) spawns a purchase order (PO) that becomes another SO downstream, and so on. | _underscore/Model/Client/ItemFulfillment.php, _underscore/Model/Client/ItemFulfillmentItem.php, _underscore/Model/Client/ItemFulfillmentItemUnit.php, _underscore/Model/Client/ItemFulfillmentPackage.php, _underscore/Model/Compass/AdvanceShippingNotice.php, dbchanges2/Core/2026-02-13 - 75601 - RecursiveItemFulfillmentCreation.sql, dbchanges2/Core/2026-06-04 - RecursiveItemFulfillmentPut.sql |
11
12
  | [Tracking-Number Bridge Migration (ASN / Item Fulfillment / Item Receipt)](features/tracking-number-bridges.md) | Shipment tracking numbers used to live as **scalar FK columns** (`trackingNumberId`, `returnTrackingNumberId`) directly on the lowest-level "unit"/"item" tables | api2/Component/Api/V2/V2.php, _underscore/Model/Client/AdvanceShippingNoticeItemUnit.php, _underscore/Model/Client/AdvanceShippingNoticeItemUnits/TrackingNumber.php, _underscore/Model/Client/ItemFulfillmentItemUnits/TrackingNumber.php, _underscore/Model/Client/ItemFulfillment.php, _underscore/Model/Prudential/AdvanceShippingNotice.php, _underscore/Model/Compass/AdvanceShippingNotice.php, _underscore/Trait/Netsuite/ItemFulfillment.php, api2/Component/Api/Cxml/Cxml.php, dbchanges2/Client/2026-06-10 - TrackingNumberBridges.sql, dbchanges2/Core/2026-06-10 - TrackingNumberBridges.sql, dbchanges2/Client_Prudential/2026-06-15 - ItemFulfillmentTrackingNumberAclLogicGroups.sql, dbchanges2/Client_Quad/2026-06-18a - ItemFulfillmentItemReceiptTrackingNumberAclLogicGroups.sql, dbchanges2/Client_Nychh/2026-06-19b - ItemFulfillmentItemReceiptTrackingNumberAclLogicGroups.sql, dbchanges2/Client_Nychh/2026-06-19a - FixItemFulfillmentTableViewTrackingAndRoot.sql, dbchanges2/Client_Quad/2026-06-19a - FixItemFulfillmentTableViewTrackingAndRoot.sql |
@@ -0,0 +1,101 @@
1
+ ---
2
+ title: NetSuite REST Client (_Component_Api_Netsuite) — record writes & SuiteQL
3
+ framework: "2.0"
4
+ repo: _underscore
5
+ project: _Underscore
6
+ client: shared
7
+ type: feature
8
+ status: active
9
+ updated: 2026-06-24
10
+ owners: ["dfranks"]
11
+ files:
12
+ - _underscore/Component/Api/Netsuite/Netsuite.php
13
+ related:
14
+ - ../../worker2/features/netsuite-salesorder-open-orders-sync.md
15
+ - ../../worker2/features/netsuite-opportunity-sync.md
16
+ - ../architecture.md
17
+ ---
18
+
19
+ ## Summary
20
+
21
+ `_Component_Api_Netsuite` is the **2.0 `_underscore` NetSuite REST client** — the shared primitive
22
+ every worker2/api2 NetSuite caller uses for record GETs, SuiteQL, and (now) record **writes**. It
23
+ is the REST replacement for the SOAP `NetSuiteService` (`library/netsuitetoolkit`), per the
24
+ team standard that production NetSuite code must use REST/SuiteQL (SOAP is OK only for ad-hoc
25
+ troubleshooting). This doc covers the framework-level client mechanics — write methods and the
26
+ SuiteQL escaping rule — that callers in other repos depend on.
27
+
28
+ > **Two `_Component_Api_Netsuite` classes exist — edit the right one.** worker2 (and the webhook
29
+ > integrations) call the **global** `_Component_Api_Netsuite` autoloaded from
30
+ > `_underscore/Component/Api/Netsuite/Netsuite.php`. `api2` ships a *separate* `namespace api` copy
31
+ > (`\api\_Component_Api_Netsuite`) for its own use. Editing the api2 copy has **no effect** on the
32
+ > worker2/webhook path — change the `_underscore` one.
33
+
34
+ ## Record writes — REST has no SOAP `baseRef->internalId`
35
+
36
+ ### Create — `createRecord(string $route, array $payload): string`
37
+
38
+ The **first REST write method in the framework** (added 2026-06-24). It is the reusable primitive
39
+ for migrating all NetSuite writes off SOAP — there is no REST equivalent of SOAP's
40
+ `writeResponse->baseRef->internalId`. NetSuite returns a record POST as **HTTP 204 with the new
41
+ internalId in the `Location` response header** (no body), so the id cannot be read from the
42
+ response payload.
43
+
44
+ `send()` deliberately surfaces only the body, not headers, so `createRecord()` issues the POST
45
+ through `_ApiRequest` directly (mirroring `send()`'s auth/endpoint/header setup), then parses the
46
+ `Location` header off the raw header string:
47
+
48
+ - `_ApiRequest` exposes **`responseCode` + `responseHeaders`** (a raw header string — there is **no
49
+ `getHeader()`** accessor). `_Component_Api_Netsuite::send()` does **not** surface either.
50
+ - Robust parse: `preg_match_all('/^Location:\s*(\S+)/im', $responseHeaders, ...)` and take the
51
+ **LAST** match (skips any 100-Continue / redirect block), then `#/(\d+)(?:[?#]|$)#` to pull the
52
+ trailing numeric id tolerating a query string or fragment.
53
+ - Throws on non-2xx, on no `Location` header, and on no id parsed.
54
+
55
+ ### Update — reuse `send('PATCH', $route, $body)`
56
+
57
+ Updates do **not** need a new helper. A NetSuite record PATCH returns 204 with no body, and
58
+ `send()` already throws on non-2xx — so the update path is just `send('PATCH', RECORD.'/'.$id,
59
+ $body)`. Only the create path (which must read the `Location` header) required the new primitive.
60
+
61
+ ## SuiteQL string-literal escaping — double the quote, never backslash
62
+
63
+ SuiteQL is **ANSI SQL sent to NetSuite as the JSON `q` string** — it is **not** executed against
64
+ MySQL. It escapes a single quote by **doubling it** (`''`), not with a backslash. Therefore
65
+ `_Database::escape()` (mysqli `real_escape_string`, backslash-style) is the **wrong tool** and
66
+ would corrupt values like `O'Brien`. Use `str_replace("'", "''", $value)` to escape a SuiteQL
67
+ string literal. (worker2's `SalesOrder.php` carries this as a private `escapeSuiteQl()` helper;
68
+ any new SuiteQL caller must do the same.)
69
+
70
+ ## Logging
71
+
72
+ `send()` calls `setLogging(false)`, so **NetSuite REST request/response bodies are NOT written to
73
+ `Logs.Api`** — unlike platform/ClickUp/Freshservice/Sentry traffic. You cannot pull a historical
74
+ NetSuite response for debugging. (Removing the default-off is under review; see the opportunity-sync
75
+ doc.)
76
+
77
+ ## Gotchas / known issues
78
+
79
+ - **No `Location` header / wrong header accessor.** `_ApiRequest` has `responseHeaders` (raw
80
+ string) but no `getHeader()` — parse the string. `send()` exposes neither `responseCode` nor
81
+ `responseHeaders`, which is why `createRecord()` goes through `_ApiRequest` directly rather than
82
+ layering on `send()`.
83
+ - **Don't escape SuiteQL with `_Database::escape()`** — backslash escaping corrupts `O'Brien`-style
84
+ values. SuiteQL doubles the quote (`''`).
85
+ - **204-with-empty-body is success, not failure.** Both create (204 + `Location`) and update (204,
86
+ no body) return no payload; treat a 2xx with empty body as success and key off the status, not the
87
+ body.
88
+
89
+ ## Change history
90
+
91
+ - 2026-06-24 — **Added `createRecord()` — the first REST write method in the framework.** Posts
92
+ through `_ApiRequest` directly (since `send()` hides headers) and parses the new internalId from
93
+ the 204 `Location` header (last match, trailing-id regex). Update path documented as reuse of
94
+ `send('PATCH', …)`. Also recorded the SuiteQL `''`-escaping rule (not `_Database::escape()`) as a
95
+ framework-level caller requirement. (dfranks)
96
+
97
+ ## Related docs
98
+
99
+ - [NetSuite → Forecast Open-Orders Sync](../../worker2/features/netsuite-salesorder-open-orders-sync.md) — first caller of `createRecord()` (the SalesOrder outbound push).
100
+ - [NetSuite → TOGA Opportunity Sync](../../worker2/features/netsuite-opportunity-sync.md) — the two-copies gotcha and the `Logs.Api` logging note.
101
+ - [_underscore Framework Architecture](../architecture.md)
@@ -10,7 +10,7 @@
10
10
  | [Elite Freshservice Sync (worker2)](features/elite-freshservice-sync.md) | `_Worker_Elite` processes Freshservice webhook events and syncs them into TOGA 2. | worker2/Worker/Elite.php, worker2/Config/dev-kmaramreddy-laptop.ini |
11
11
  | [Monitoring Framework (Orchestrator + Child Monitors)](features/monitoring-framework.md) | A unified, DB-driven monitoring framework for business-critical data flows (Compass POs, Prudential asset imports, AIG closed claims, …). | worker2/Worker/Monitor.php, worker2/Worker/Monitors/, worker2/Worker/Notification/Email.php, dbchanges2/Core/2026-05-21 - Monitors.sql |
12
12
  | [NetSuite → TOGA Opportunity Sync (API Message Queue + worker2 webhook)](features/netsuite-opportunity-sync.md) | Outbound sync from NetSuite to TOGA for the record types the Forecast2 importer pulls (opportunities first; sales/items/etc. | worker2/Worker/Netsuite.php, worker2/Worker/Netsuite/Opportunity.php, worker2/Controller/Index.php, _underscore/Worker.php, test/@dave/NetSuite/api-message-queue/lib_amq_queue.js, test/@dave/NetSuite/api-message-queue/ue_api_msg_queue_enqueue.js, test/@dave/NetSuite/api-message-queue/ue_amq_drain.js, test/@dave/NetSuite/api-message-queue/ss_amq_drain.js, test/@dave/NetSuite/api-message-queue/DEPLOY_RUNBOOK.md, test/@dave/clickup/backfill_opportunity_numbers.php, test/@dave/clickup/probe_opportunity_fields.php, test/@dave/probe_clickup_desc_match.php, worker/crons/toga2/forecast2/common_import_sales_from_netsuite.php |
13
- | [NetSuite → Forecast Open-Orders Sync (salesOrder webhook → OpenOrderItems)](features/netsuite-salesorder-open-orders-sync.md) | Webhook-driven, single-record port of the legacy open-orders importer (TRUE-79142). | worker2/Worker/Netsuite/SalesOrder.php, worker2/Worker/Netsuite.php, test/@dave/probe_salesorder_rest_shape.php, test/@dave/probe_open_order_lines.php, test/@dave/check_so_status.php, test/@dave/check_so_history.php, test/@dave/probe_so_rest_lines.php, test/@dave/probe_missing_oo_timing.php, test/@dave/probe_missing_oo_createdby.php, test/@dave/probe_drift_so_dates.php, worker/crons/toga2/forecast2/import_open_orders.php, worker/crons/toga2/forecast2/common_import_sales_from_netsuite.php |
13
+ | [NetSuite → Forecast Open-Orders Sync (salesOrder webhook → OpenOrderItems)](features/netsuite-salesorder-open-orders-sync.md) | Webhook-driven, single-record port of the legacy open-orders importer (TRUE-79142). | worker2/Worker/Netsuite/SalesOrder.php, worker2/Worker/Netsuite.php, test/@dave/probe_salesorder_rest_shape.php, test/@dave/probe_open_order_lines.php, test/@dave/check_so_status.php, test/@dave/check_so_history.php, test/@dave/probe_so_rest_lines.php, test/@dave/probe_missing_oo_timing.php, test/@dave/probe_missing_oo_createdby.php, test/@dave/probe_drift_so_dates.php, test/@dave/probe_open_order_gating.php, worker/crons/toga2/forecast2/import_open_orders.php, worker/crons/toga2/forecast2/common_import_sales_from_netsuite.php |
14
14
  | [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 |
15
15
  | [Team Sprint Management & Reporting](features/team-sprint-management.md) | `_Worker_Team_Sprint` (file `Worker/Team/Sprint.php`) is the engine behind TOGA's internal **development-sprint process and reporting**. | worker2/Worker/Team/Sprint.php |
16
16
  | [Teams Meeting Transcript Export](features/teams-transcript-export.md) | `_Worker_Team_Transcripts` (action `Team/Transcripts/Export`) polls Microsoft Graph for Teams meeting transcripts produced by a set of organizers, classifies ea | worker2/Worker/Team/Transcripts.php, worker2/Config/production.ini, worker2/Database/TeamsTranscriptExports.sql, dbchanges2/Core/2026-06-18a - Teams Transcript Export schedule.sql |
@@ -287,7 +287,15 @@ None — platform-wide Forecast sync.
287
287
  / Peter Violini — **zero** `asisystem.com` members in the 72-person workspace), the user field is always
288
288
  blank. **It does not self-heal:** `UPDATE_CLICKUP = false` means a later NetSuite edit never back-fills the
289
289
  field, and the users field is add-only on update anyway — so onboarding the person into ClickUp later
290
- requires a one-time backfill of the existing tasks. Diagnostic signature when triaging a "no presales lead"
290
+ requires a one-time backfill of the existing tasks.
291
+ **Mitigation (2026-06-24): a default assignee fallback.** `buildCustomFields()`'s else-branch (when
292
+ `resolvePresalesUserId()` returns `null`) now wires in the previously-unused constant
293
+ `DEFAULT_PRESALES_LEAD_USER_ID = '87374309'` (verified via ClickUp `GET /team` = Cory Martin,
294
+ comartin@togatech.com, an active member) so the Presales Lead **users** field is set to that fallback
295
+ user instead of being omitted — the task no longer lands with an empty users field. The ClickUp
296
+ people-field value shape is `['add' => [$userId], 'rem' => []]` (delta op: `add` attaches, `rem`
297
+ removes; `rem` only matters on the update path; a `null` in the `add` array is still a 400, which is why
298
+ the fallback is wired rather than allowing null). Diagnostic signature when triaging a "no presales lead"
291
299
  report: NS **has** the lead set, the ClickUp task's **Presales Email is set** but **Presales Lead user is
292
300
  empty**, and `creator` = the worker2 `[clickup] token` owner (`jcardinal@togatech.com`) — which also rules
293
301
  out the NetSuite-side `OPP <id>` stub path (those are unnamed, empty-field tasks) and the legacy `webhook/`
@@ -337,6 +345,11 @@ same **skip-if-unchanged** compare on the extracted values, and **actor-identity
337
345
  trigger a CU→NS write. The NS→CU change-detection above is the complementary backstop, not a substitute.
338
346
 
339
347
  ## Change history
348
+ - 2026-06-24 — **Default presales-lead assignee fallback.** `buildCustomFields()` now wires the
349
+ previously-unused `DEFAULT_PRESALES_LEAD_USER_ID = '87374309'` (Cory Martin, comartin@togatech.com —
350
+ verified active via ClickUp `GET /team`) into the else-branch when `resolvePresalesUserId()` returns
351
+ null, so external/non-roster presales leads no longer create a task with an **empty** Presales Lead
352
+ users field. Recorded the people-field delta shape `['add' => [userId], 'rem' => []]`. (dfranks)
340
353
  - 2026-06-23 — **Root-caused "ClickUp opportunity tasks with no Presales Lead."** NetSuite HAD the lead set
341
354
  on all four reported opps; the ClickUp Presales Lead *user* field was blank because the leads are external
342
355
  (`@asisystem.com`) employees with no ClickUp account, so `resolvePresalesUserId()` returns null and
@@ -19,10 +19,12 @@ files:
19
19
  - test/@dave/probe_missing_oo_timing.php
20
20
  - test/@dave/probe_missing_oo_createdby.php
21
21
  - test/@dave/probe_drift_so_dates.php
22
+ - test/@dave/probe_open_order_gating.php
22
23
  - worker/crons/toga2/forecast2/import_open_orders.php
23
24
  - worker/crons/toga2/forecast2/common_import_sales_from_netsuite.php
24
25
  related:
25
26
  - ./netsuite-opportunity-sync.md
27
+ - ../../_underscore/features/netsuite-rest-client.md
26
28
  - ../architecture.md
27
29
  ---
28
30
 
@@ -44,8 +46,10 @@ separate build (see *Related work*).
44
46
 
45
47
  - `Worker/Netsuite/SalesOrder.php` — `_Worker_Netsuite_SalesOrder`. **One class, two directions:**
46
48
  the new INBOUND import (`POST`/`PUT`/`DELETE`, REST → Forecast) was *merged into* the pre-existing
47
- OUTBOUND push (`Create`/`Update`/`Sync`, Toga → NetSuite via SOAP). The action router forces the
48
- import entry points to live here (`salesOrder` → `_Worker_Netsuite_SalesOrder`), so they coexist.
49
+ OUTBOUND push (`CreateNetSuite`/`UpdateNetSuite`/`Sync`, Toga → NetSuite). The action router forces
50
+ the import entry points to live here (`salesOrder` → `_Worker_Netsuite_SalesOrder`), so they coexist.
51
+ **The OUTBOUND push is now REST-only** (2026-06-24) — see *Outbound push (Toga → NetSuite)* below;
52
+ zero SOAP references remain in this file.
49
53
  - `Worker/Netsuite.php` — the shared router (unchanged; no per-recordType edits needed).
50
54
  - NetSuite access is **REST only**, via the `_underscore` client `_Component_Api_Netsuite`
51
55
  (`RECORD_SALES_ORDER` + `?expandSubResources=true`) — never the SOAP `NetSuiteService`.
@@ -70,6 +74,46 @@ separate build (see *Related work*).
70
74
  deletes any row NetSuite no longer returns as open (orphans + now-closed lines). Commits
71
75
  `DB_FORECAST` (lazy-transaction discipline).
72
76
 
77
+ ## Outbound push (Toga → NetSuite) — REST-only
78
+
79
+ The reverse direction (`CreateNetSuite` / `UpdateNetSuite` / `Sync`) pushes a Toga sales order
80
+ *into* NetSuite. As of 2026-06-24 it is **REST-only** via the `_underscore`
81
+ [`_Component_Api_Netsuite`](../../_underscore/features/netsuite-rest-client.md) client — the SOAP
82
+ `NetSuiteService` was fully removed from this file.
83
+
84
+ - **Item lookup** — SOAP `ItemSearchBasic/search()` → SuiteQL `SELECT id FROM item WHERE itemid =
85
+ '<part>'`.
86
+ - **Customer resolution** — new `resolveNetSuiteCustomerId()`: prefers mapped
87
+ `c_netsuiteInternalCustomerId`, else SuiteQL `SELECT id FROM customer WHERE companyname =
88
+ '<name>'`; **throws on 0 or >1 matches** (never attach the wrong customer — REST has no SOAP
89
+ RecordRef-by-name resolution).
90
+ - **`buildNetSuiteOrder()`** returns a REST JSON body: `entity:{id}`,
91
+ `item:{items:[{item:{id},quantity,rate}]}`, **date-only** `tranDate` (`YYYY-MM-DD`; SOAP needed
92
+ full ISO 8601), `shippingAddress` via `array_filter` dropping null/`''`. `amount` is **omitted**
93
+ (NetSuite computes it).
94
+ - **Create** → `createRecord()` (reads the new internalId from the 204 `Location` header);
95
+ **Update** → `send('PATCH', RECORD_SALES_ORDER.'/'.$id, $body)` (204, no body, throws on non-2xx).
96
+ The SOAP `getNetSuiteErrorDetail()` was removed — REST errors surface as exceptions from
97
+ `send()`/`createRecord()`.
98
+ - **SuiteQL string escaping** uses a private `escapeSuiteQl()` (`str_replace("'", "''", $value)`),
99
+ **not** `_Database::escape()` — SuiteQL doubles the quote, so backslash escaping would corrupt
100
+ `O'Brien`-style names. (See the [REST client doc](../../_underscore/features/netsuite-rest-client.md).)
101
+ - **Blast radius isolated.** Two other worker2 files still use SOAP and each construct their own
102
+ `NetSuiteService` (`Worker/Ai/Bdr/Netsuite.php`, `Worker/Client/Aig/ClosedClaims.php`) — untouched;
103
+ `library/netsuitetoolkit` stays loaded for worker2.
104
+
105
+ ### Refactor (2026-06-24)
106
+
107
+ - `fetchSalesOrder()` 4 positional params → `(array $clientContext, int $salesOrderId)` per the
108
+ TOGA 4-param rule (4+ positional must become an assoc-array/named-params signature; PHP named
109
+ *arguments* at the call site do **not** satisfy the rule — it targets the **signature**).
110
+ - **`Sync()` double-fetch fixed.** `Sync` previously re-ran `getClientContext()` + `fetchSalesOrder()`
111
+ and then called Create/Update, which fetched **again** (2× Core DB + 2× Toga API per Sync). It now
112
+ fetches **once** and delegates to private `doCreate(array,object,int)` / `doUpdate(object,int)`;
113
+ the public `CreateNetSuite`/`UpdateNetSuite` are thin fetch-then-delegate wrappers (public
114
+ action-router contract unchanged). Closes a small **TOCTOU** — the open/close decision and the
115
+ write now use the same fetched record.
116
+
73
117
  ## Data model
74
118
 
75
119
  `Forecast.OpenOrderItems` — flat, denormalized **leaf** table (no header table, no FK children).
@@ -229,8 +273,39 @@ refunds). The low-level Forecast SQL/lookup helpers (`sqlLiteral`, `buildInsert`
229
273
  `lookupId`, `toSqlDate`, `fetchRecord`) are currently duplicated in `Opportunity.php` + `SalesOrder.php`
230
274
  and are a candidate to extract into a shared `_Component_Forecast_Db` before the Sales build.
231
275
 
276
+ ## Retiring the legacy import crons — what the webhook does NOT yet cover
277
+
278
+ The NS inbound webhook currently covers **only** `salesOrder` (→ `OpenOrderItems`) and `opportunity`
279
+ (→ `Opportunities`). `invoice` / `cashSale` / `creditMemo` / `cashRefund` (→ `Forecast.Sales`) and
280
+ the supporting-records sync are **not yet on the webhook**. So disabling `import_sales.php` /
281
+ `import_supporting_records.php` / the Sales discrepancy-fix would **stop those syncs**.
282
+
283
+ **Durable retirement rule:** a legacy import cron may only be safely retired once its NS-side AMQ
284
+ enqueuer is **RELEASED in prod** (not `Testing` — `Testing` fires only for the deploying user) with
285
+ `DEV_OVERRIDE.enabled=false`. Note also that `periodic_forecast_discrepancy_fix_open_orders.php` is
286
+ currently the **only path that deletes stale `OpenOrderItems` rows** (a webhook edit only deletes
287
+ rows for orders it re-reads) — confirmed live: SO 7181316 went `Billed` (0 open) in NS but kept a
288
+ stale local `OpenOrderItems` row that only the discrepancy-fix cron would remove. Don't retire that
289
+ cron until the webhook path owns stale-row cleanup.
290
+
291
+ **Pre-fixture gating check.** `test/@dave/probe_open_order_gating.php` is a read-only probe that
292
+ replicates the inbound import gate against NS (REST GET `salesOrder`; `status ∈ OPEN_STATUSES`;
293
+ per-line `qtyOpen = quantity − quantityBilled`; `revenue = qtyOpen × rate`; keep unless
294
+ `revenue == 0 && profit == 0`) — use it to verify an order still passes gating before using it as a
295
+ test fixture (it surfaced the stale SO 7181316 above).
296
+
232
297
  ## Change history
233
298
 
299
+ - 2026-06-24 — **Outbound push (`CreateNetSuite`/`UpdateNetSuite`/`Sync`) converted SOAP →
300
+ REST-only** via `_Component_Api_Netsuite`: item + customer lookups now SuiteQL
301
+ (`resolveNetSuiteCustomerId()` throws on 0/>1 name matches), `buildNetSuiteOrder()` emits a REST
302
+ JSON body (date-only `tranDate`, `amount` omitted), create → `createRecord()` (204 `Location`
303
+ header), update → `send('PATCH', …)`; SuiteQL escaping via `escapeSuiteQl()` (`''`, not
304
+ `_Database::escape()`). Refactored `fetchSalesOrder()` to `(array,int)` (4-param rule) and fixed
305
+ `Sync()` double-fetch (now fetch-once + private `doCreate`/`doUpdate`, closing a TOCTOU). Recorded
306
+ the legacy-cron retirement boundary (webhook covers only salesOrder + opportunity; Sales/supporting
307
+ not yet; the open-orders discrepancy-fix is the only stale-row deleter) and added
308
+ `probe_open_order_gating.php`. (dfranks)
234
309
  - 2026-06-23 — **Documented the OpenOrderItems schema/FK column, two silent zero-write paths, and the
235
310
  full 3-table diagnostic ladder.** Recorded that the table lives in `Forecast` on core2 (~4,347 rows)
236
311
  with FK `netsuiteSalesOrderInternalId`, and the **1054 false-negative** trap from querying it with the
@@ -6,7 +6,7 @@ _Auto-generated by `knowledge.js index`. Do not hand-edit._
6
6
 
7
7
  - **library** (Library) _(framework core)_ — 8 doc(s) → [1.0/apps/library/INDEX.md](1.0/apps/library/INDEX.md)
8
8
  - **worker** (Worker) — 10 doc(s) → [1.0/apps/worker/INDEX.md](1.0/apps/worker/INDEX.md)
9
- - **togadesk** (TOGa Desk) — 7 doc(s) → [1.0/apps/togadesk/INDEX.md](1.0/apps/togadesk/INDEX.md)
9
+ - **togadesk** (TOGa Desk) — 8 doc(s) → [1.0/apps/togadesk/INDEX.md](1.0/apps/togadesk/INDEX.md)
10
10
  - **togaview** (TOGa View) — 6 doc(s) → [1.0/apps/togaview/INDEX.md](1.0/apps/togaview/INDEX.md)
11
11
  - **webhook** (Webhook) — 1 doc(s) → [1.0/apps/webhook/INDEX.md](1.0/apps/webhook/INDEX.md)
12
12
  - **walmarttechservices** (Walmart Tech Services) — 1 doc(s) → [1.0/apps/walmarttechservices/INDEX.md](1.0/apps/walmarttechservices/INDEX.md)
@@ -15,7 +15,7 @@ _Auto-generated by `knowledge.js index`. Do not hand-edit._
15
15
 
16
16
  ## 2.0 framework
17
17
 
18
- - **_underscore** (_Underscore) _(framework core)_ — 11 doc(s) → [2.0/apps/_underscore/INDEX.md](2.0/apps/_underscore/INDEX.md)
18
+ - **_underscore** (_Underscore) _(framework core)_ — 12 doc(s) → [2.0/apps/_underscore/INDEX.md](2.0/apps/_underscore/INDEX.md)
19
19
  - **worker2** (Worker) — 13 doc(s) → [2.0/apps/worker2/INDEX.md](2.0/apps/worker2/INDEX.md)
20
20
  - **api2** (API) — 6 doc(s) → [2.0/apps/api2/INDEX.md](2.0/apps/api2/INDEX.md)
21
21
  - **dbchanges2** (Database Changes) _(framework core)_ — 2 doc(s) → [2.0/apps/dbchanges2/INDEX.md](2.0/apps/dbchanges2/INDEX.md)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toga-ai",
3
- "version": "1.0.190",
3
+ "version": "1.0.192",
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",