toga-ai 1.0.501 → 1.0.503

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.
@@ -7,6 +7,7 @@
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/repairorder.php, library/app/model/togadesk/repairordertracking.php, library/app/api/carrier/ups.php |
8
8
  | [Managed Service Order Create Flow (New MSO modal → tickets/add)](features/managed-service-order-create.md) | The **Managed Service Order (MSO) create flow** is how a TOGa Desk user manually creates a managed service order: pick an End User (or a Client Location), choos | desk/includes/controllers/actions/tickets/add.php, desk/template/modals/tickets/addNew.php, desk/template/pages/managed.php, desk/template/pages/getinfo.php, desk/template/footer.php, library/app/model/togadesk/repairorder.php |
9
9
  | [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 |
10
+ | [Per-Client Hostname Restriction (getRestrictedClient)](features/per-client-host-restriction.md) | TOGa Desk supports **per-client branded URLs** (e.g. | desk/includes/functions.php, desk/template/header.php, _/browser/datatable/tickets, desk/includes/classes/class.ticket.php, ebs/setup_phpini.php, ebs/http_to_https.php, desk/template/modals/kb/viewDocument.php, desk/template/modals/files/aws-view.php, desk/template/modals/files/contract-view.php, template/pages/documents/view.php, template/pages/tasks/view.php |
10
11
  | [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 |
11
12
  | [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 |
12
13
  | [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/includes/controllers/actions/tickets/addReply.php, desk/api/resources/tickets.php, desk/api/resources/ticket_replies.php, crons/tickets.php, crons/pipe.php, desk/includes/controllers/actions/tickets/merge.php |
@@ -0,0 +1,107 @@
1
+ ---
2
+ title: Per-Client Hostname Restriction (getRestrictedClient)
3
+ framework: "1.0"
4
+ repo: togadesk
5
+ project: TOGa Desk
6
+ client: shared
7
+ type: feature
8
+ status: active
9
+ updated: 2026-08-04
10
+ owners: ["mhammontree"]
11
+ files:
12
+ - desk/includes/functions.php
13
+ - desk/template/header.php
14
+ - _/browser/datatable/tickets
15
+ - desk/includes/classes/class.ticket.php
16
+ - ebs/setup_phpini.php
17
+ - ebs/http_to_https.php
18
+ - desk/template/modals/kb/viewDocument.php
19
+ - desk/template/modals/files/aws-view.php
20
+ - desk/template/modals/files/contract-view.php
21
+ - template/pages/documents/view.php
22
+ - template/pages/tasks/view.php
23
+ related:
24
+ - ../architecture.md
25
+ - ../../togaview/architecture.md
26
+ ---
27
+
28
+ ## Summary
29
+
30
+ TOGa Desk supports **per-client branded URLs** (e.g. `rumcsi.togadesk.togatech.com`). The whole
31
+ mechanism is host-based: `getRestrictedClient()` in `desk/includes/functions.php` (~L1513-1592)
32
+ derives a single "restricted client" from the request hostname, and `restrictByClient()` (L1508)
33
+ is the gate used throughout the UI. Adding a new per-client URL is therefore **one switch case
34
+ plus a lot of non-application work** (cert, sessions, hardcoded legacy domains).
35
+
36
+ This contradicts the impression that togadesk has no host-based logic — it does, and this is the
37
+ only place a new subdomain gets registered.
38
+
39
+ ## How it works
40
+
41
+ 1. `getRestrictedClient()` lowercases `$_SERVER['HTTP_HOST']`, strips the port, and matches a long
42
+ **hardcoded switch of per-client subdomains**, returning a `CLIENT_*` constant or `null`.
43
+ 2. Client-ID constants are defined in the same file (~L1488-1506):
44
+ `CLIENT_AGILANT=2`, `CLIENT_OFFICEDEPOT=3`, `CLIENT_RUMCSI=146`, `CLIENT_IMHOUSTON=167`,
45
+ `CLIENT_FORDHAM=179`.
46
+ 3. `restrictByClient()` (L1508) wraps that result and gates:
47
+ - navigation/menus in `desk/template/header.php` (~L1638-1665), and
48
+ - the ticket datatables under `_/browser/datatable/tickets/*`.
49
+ 4. Post-login data filtering keys off the **session `clientid`**, not the host — the host
50
+ restriction is a UI/entry gate, not the record-level tenancy boundary.
51
+
52
+ **TOGa View resolves its host separately.** `togaview/_/app/framework.php` has its own
53
+ `switch($_SERVER['HTTP_HOST'])` that sets `$_SESSION['stylePath']` and selects the RUMCSI SAML
54
+ branch; an unregistered hostname falls through to default branding and **skips SAML**. So a
55
+ togadesk-side DNS record is safe on its own, but any new *togaview* hostname must be added there
56
+ too. _(Carried from the togaview architecture doc — not re-verified in code on 2026-08-04.)_
57
+
58
+ ## Adding a new per-client TOGa Desk URL — checklist
59
+
60
+ The app code is the easy part. What actually bites:
61
+
62
+ - **Register the host** in the `getRestrictedClient()` switch (and add a `CLIENT_*` constant if the
63
+ client is new). Verified 2026-08-04: `rumcsi.togadesk.togatech.com` is **already a case**, so the
64
+ RUMCSI AWS DNS record needs no code change.
65
+ - **TLS cert.** A 4-label host such as `rumcsi.togadesk.togatech.com` is **not** covered by a
66
+ `*.togatech.com` wildcard. You need `*.togadesk.togatech.com` or an exact SAN on the ALB
67
+ listener, plus a host-header rule.
68
+ - **Sessions log everyone out on a domain change.** `ebs/setup_phpini.php` sets
69
+ `session.save_handler=rediscluster` but sets **no `session.cookie_domain`**, and there is no
70
+ `session_set_cookie_params()` anywhere — the cookie is host-scoped. Worse, `people.sessionid` is a
71
+ **single column**, so logging in on a second domain invalidates the session on the first.
72
+ - **Smoke-test Office document previews.** `desk/template/modals/kb/viewDocument.php`,
73
+ `modals/files/aws-view.php`, `modals/files/contract-view.php`, `template/pages/documents/view.php`
74
+ and `template/pages/tasks/view.php` embed `https://{HTTP_HOST}/...` into third-party viewers
75
+ (`docs.google.com/gview`, `view.officeapps.live.com`) which fetch **server-side**. On a host with
76
+ an untrusted cert, Word/Excel/PPT previews fail **silently** while same-origin PDF iframes still
77
+ work — making this the best post-cutover check.
78
+ - **Keep the old domain resolvable.** `class.ticket.php` L485-486 and L886-887 hardcode
79
+ `https://togadesk[-env].agilantsolutions.com/uploads/` when rewriting inline ticket images, so
80
+ outbound ticket emails keep serving images from the old domain. Other hardcoded legacy domains:
81
+ `csat.agilantsolutions.com`, `toga.agilantsolutions.com`,
82
+ `yourcloudhq.togaview.agilantsolutions.com`, and `toga_payment_easyurl` / `fullurl` in every
83
+ `config.*.ini`.
84
+
85
+ ## Gotchas / known issues
86
+
87
+ - **Cross-tenant login hole (pre-existing, NOT fixed — deserves its own ticket).** The
88
+ password-login access condition in `desk/includes/functions.php` (~L627-631) is a chain of ORs,
89
+ and two clauses grant access **regardless of which client's subdomain is in use**:
90
+ `getRestrictedClient() != CLIENT_AGILANT && $restrictedClientId == CLIENT_IMHOUSTON` lets an IM
91
+ Houston user log in on the RUMCSI host, and
92
+ `getRestrictedClient() != CLIENT_FORDHAM && $restrictedClientId == CLIENT_FORDHAM` does the same
93
+ for Fordham. Post-login filtering keys off the session `clientid`, so it is not an immediate
94
+ record leak, but it **defeats the per-client URL restriction and contradicts the comment directly
95
+ above it** (L627-629). Recommended fix: replace with an explicit per-restricted-client allowlist
96
+ using guard clauses. Anyone touching login or adding a client URL must know this.
97
+ - **`siteURL()` emits `http://` behind the ALB.** `desk/includes/functions.php` (~L22-31) only emits
98
+ `https` when `$_SERVER['HTTPS'] == 'on'`; behind the ALB only `X-Forwarded-Proto` is set (see
99
+ `ebs/http_to_https.php`). It currently has **no callers** in togadesk — a latent trap for any new
100
+ absolute-link code.
101
+ - The host switch and the client-ID constants are hardcoded — there is no DB-driven mapping, so
102
+ every new branded URL is a code change.
103
+
104
+ ## Change history
105
+
106
+ - 2026-08-04 — Documented from a read-only review (TRUE-80139) of the RUMCSI branded-URL cutover;
107
+ recorded the add-a-hostname checklist and the cross-tenant login hole. No code changed. (mhammontree)
@@ -4,7 +4,7 @@
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, worker/crons/infrastructure/execute_dbchanges.php, worker/.ebextensions/030_dbchanges.config |
6
6
  | [Compass MA Sales Order Exception Report](features/compass-ma-sales-order-exception-report.md) | A worker cron that emails operations the "Compass Refresh Exception Report" — Compass `MA%` sales orders whose corresponding Office Depot (ODP) sales order has | worker/crons/toga2/compass/workflow/7_generate_ma_sales_order_exception_report.php |
7
- | [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/compasscanada/workflow/3_update_salesorder_status_from_grand_and_toy.php |
7
+ | [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/compasscanada/update_salesorder_status_from_odp.php, worker/crons/toga2/compasscanada/workflow/3_update_salesorder_status_from_grand_and_toy.php, worker/crons/toga2/compasscanada/send_delivered_email.php, worker/crons/toga2/compasscanada/workflow/test_partial_in_transit_email.php, worker/crons/toga2/compasscanada/workflow/test_partial_delivered_email.php, library/app/client/compasscanada.php |
8
8
  | [Elite TOGA 2.0 → TOGaDeskSupport Standalone Attachment Sync](features/elite-togadesk-attachment-sync.md) | `sync_togadesk_elite_attachments.php` is a standalone cron (every 5 minutes) that syncs file attachments from TOGA 2.0 into TOGaDeskSupport for Elite. | worker/crons/toga2/elite/sync_togadesk_elite_attachments.php, worker/crons/toga2/elite/test_sync_togadesk_elite_attachments.php |
9
9
  | [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/checker.php, worker2/Component/Forecast/SaleImport/SaleImport.php, test/@dave/looper.php, test/@dave/reconcile_netsuite_totals.php, test/@dave/fixer.php, test/@dave/analyze_netsuite_forecast_diff.php, test/@dave/trueup_sales.php, test/@dave/reconcile_drift_2023plus.php, test/@dave/probe_invoice_gap_2026.php, test/@dave/probe_creditmemo_gap_detail.php, test/@dave/trueup_open_orders.php, test/@dave/loop_trueup_open_orders.php, test/@dave/trueup_opportunities.php, test/@dave/probe_sales_gap_direct.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_profit_invoices.php, test/@dave/probe_profit_gap.php, worker/crons/toga2/forecast2/common_import_sales_from_netsuite.php, worker/crons/toga2/forecast2/periodic_forecast_discrepancy_fix_open_orders.php, worker/crons/toga2/forecast2/import_open_orders.php, worker/schedules/cron.worker.infrastructure.json |
10
10
  | [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, library/app/api/netsuite/rest.php, library/app/systemmonitor/netsuiteintegration.php |
@@ -6,12 +6,18 @@ project: Worker
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-06-18
9
+ updated: 2026-08-03
10
10
  owners: ["bala"]
11
11
  files:
12
12
  - worker/crons/toga2/compass/update_salesorder_status_from_odp.php
13
+ - worker/crons/toga2/compasscanada/update_salesorder_status_from_odp.php
13
14
  - worker/crons/toga2/compasscanada/workflow/3_update_salesorder_status_from_grand_and_toy.php
14
- related: []
15
+ - worker/crons/toga2/compasscanada/send_delivered_email.php
16
+ - worker/crons/toga2/compasscanada/workflow/test_partial_in_transit_email.php
17
+ - worker/crons/toga2/compasscanada/workflow/test_partial_delivered_email.php
18
+ - library/app/client/compasscanada.php
19
+ related:
20
+ - ../../../clients/compass-canada/features/french-order-email-localization.md
15
21
  ---
16
22
 
17
23
  ## Summary
@@ -95,6 +101,12 @@ the tracking number as emailed (it retries next run).
95
101
  - **Compass Canada** — EN/FR by `UserGlobalSettings.settingId = 2` (FR when the value starts
96
102
  with `fr`); French section labels and a French (HTML-entity) status message; uses
97
103
  `htmlentities` in the section header builder and a 7-param French-aware `buildPackageBlockHtml`.
104
+ Language is resolved through the shared 1.0 helper
105
+ `App_Client_CompassCanada::resolveUserLanguageCode($userId, $databaseLink)`
106
+ (`library/app/client/compasscanada.php`), wrapped in `try/catch` → default English at every call
107
+ site. **Item rows are localized too** as of 2026-08-03: titles come from the `ItemTranslations`
108
+ sidecar and the `P/N:` / `Qty:` labels are translated (`N/P` / `Qté`) — see
109
+ [Compass Canada French order emails](../../../clients/compass-canada/features/french-order-email-localization.md).
98
110
 
99
111
  ## Gotchas / known issues
100
112
 
@@ -109,9 +121,29 @@ the tracking number as emailed (it retries next run).
109
121
  clients — that is correct and unrelated to the package-contents source.
110
122
  - **Not yet deployed.** These crons depend on the api2 POST scripted-API change and its
111
123
  `RecordScripts`/`AclRecordScripts` registration — see related docs.
124
+ - **⚠ The Compass Canada crons now hard-depend on `library`'s `App_Client_CompassCanada`.** All 8
125
+ `crons/toga2/compasscanada/` scripts call it for language resolution, and as of 2026-08-03 that
126
+ class change is **uncommitted on `_production`** with no `TRUE-80543` branch. Merging the `worker`
127
+ PR without the `library` change **fatals** every Compass Canada cron. Ship `library` first or in
128
+ the same release.
129
+ - **A language lookup must never abort the run.** `App_Query::execute()` (1.0,
130
+ `library/app/query.php`) throws on any MySQL error, and an unguarded throw here aborts every
131
+ remaining order in that cron tick. Every call site wraps the lookup in `try/catch`, defaults to
132
+ base English, and `error_log`s.
133
+ - **Pick the template from the language of the person actually being emailed.** The manager-approval
134
+ reminder cron (`compass_email_reminders.php`) selected the template from the **requester's**
135
+ language even though the mail goes to the **manager**. Fixed 2026-08-03.
112
136
 
113
137
  ## Change history
114
138
 
139
+ - 2026-08-03 — Compass Canada **item rows are now localized**: titles resolved from the
140
+ `ItemTranslations` sidecar (two-column select + PHP fallback, never a mixed-collation SQL
141
+ `COALESCE`) and translated `N/P` / `Qté` labels; language resolution moved onto the shared 1.0
142
+ helper `App_Client_CompassCanada::resolveUserLanguageCode()` with `try/catch` → English at every
143
+ call site so a lookup failure can't abort the rest of the run. Item text is entity-escaped for the
144
+ iso-8859-1 send. Flagged the **`library` deployment dependency** (uncommitted on `_production`;
145
+ all 8 Canada crons fatal without it). TRUE-80543, not yet deployed. Detail lives on
146
+ [Compass Canada French order emails](../../../clients/compass-canada/features/french-order-email-localization.md). (bala)
115
147
  - 2026-06-18 — Switched Canada `getOrderFulfillmentData` from the ASN chain to the
116
148
  `ItemFulfillmentItems` line bridge (match Compass USA); item info all 14px. (bala)
117
149
  - 2026-06-18 — Brought both prod crons to parity: per-package partial assembly, `buildTrackingUrl`
@@ -11,6 +11,7 @@
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
13
  | [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/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
+ | [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 |
14
15
  | [_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 |
15
16
  | [_Component_*/_Model_* project-namespace registration (autoloader) & backslash-qualify traps](features/component-model-namespace-registration.md) | Every **project-local** `_Component_*` and `_Model_*` class in a 2.0 app **must declare the project namespace** at the top of the file: ```php namespace <NAMESP | _underscore/Loader.php, worker2/_.php, api2/_.php, worker2/Component/Forecast/Db/Db.php, worker2/Component/Forecast/SaleImport/SaleImport.php, api2/Component/Api/Netsuite/Netsuite.php |
16
17
  | [Re-pointing a DB alias mid-request (_Database::register park/restore)](features/database-alias-repointing.md) | `_Database` keys **all live per-database runtime state by the connection ALIAS** (`Client` / `_underscore::DB_CLIENT`, `ClientLogs`, `Archive`), **not** by the | _underscore/Database.php, _underscore/Query.php, api2/Component/Api/V2/V2.php, api2/Component/Api/CrossClient/CrossClient.php |
@@ -0,0 +1,84 @@
1
+ ---
2
+ title: Running 2.0 code from a bare CLI script (bootstrap + transactions)
3
+ framework: "2.0"
4
+ repo: _underscore
5
+ project: _Underscore
6
+ client: shared
7
+ type: feature
8
+ status: active
9
+ updated: 2026-08-03
10
+ owners: ["bala"]
11
+ files:
12
+ - _underscore/Database.php
13
+ - _underscore/Environment.php
14
+ - api2/Initialize.php
15
+ related:
16
+ - ./model-interceptor-unit-testing.md
17
+ - ./database-alias-repointing.md
18
+ - ./email-template-sending.md
19
+ ---
20
+
21
+ ## Summary
22
+
23
+ A throwaway CLI script (a data check, a backfill dry-run, a render harness) that wants the real
24
+ 2.0 framework — `_Model`, `_Query`, `_Database` — is **not** the same as a web request. `_Route`
25
+ does several things for a request that nothing does for `php script.php`, and the two that bite
26
+ hardest are the **transaction** and the **`ENVIRONMENT`** variable. This is the minimum recipe
27
+ plus the traps.
28
+
29
+ For DB-**free** unit tests of interceptor logic, use the PHPUnit scaffold instead — see
30
+ [DB-free unit testing for model interceptors](./model-interceptor-unit-testing.md).
31
+
32
+ ## How it works
33
+
34
+ 1. Set the environment **before** anything loads: `putenv('ENVIRONMENT=<envSlug>')` (or export it).
35
+ `_Environment` **fatals** if `getenv('ENVIRONMENT')` is unset.
36
+ 2. `chdir` to the **api2 root** so relative `./Config` / bootstrap paths resolve, then
37
+ `require 'Initialize.php'`.
38
+ 3. Point `DB_CLIENT` at the client schema you want:
39
+ `_Database::registerClientDatabases('<envSlug>', <clientId>)`.
40
+ 4. **Open and close the transaction yourself:** `_Database::transactionStart()` before the writes
41
+ and `_Database::transactionCommit()` after. A web request gets this from `_Route`; a CLI script
42
+ does not.
43
+ 5. Read-only scripts need neither, but see the write-drop gotcha below before assuming a script is
44
+ read-only.
45
+
46
+ ## Gotchas / known issues
47
+
48
+ - **Without an explicit `transactionStart()` + `transactionCommit()`, CLI writes silently vanish.**
49
+ The auto-increment advances (so ids are consumed and the write *looks* like it worked), but no row
50
+ persists, and reading back a just-inserted row returns **0 rows** in the same script. No error is
51
+ raised. This is the CLI face of the lazy-transaction behavior documented on
52
+ `_underscore/architecture.md` — `_Database::register()` starts a lazy transaction that MySQL rolls
53
+ back when the connection closes uncommitted.
54
+ - **`ENVIRONMENT` must be set before the framework loads**, not after — `_Environment::initialize`
55
+ fatals on a missing value rather than defaulting.
56
+ - **Local MySQL and PROD both run `sql_mode = ''`**, so `ONLY_FULL_GROUP_BY` is **not** enforced in
57
+ either. A local test of a `GROUP BY` change therefore proves nothing unless the script does an
58
+ explicit `SET SESSION sql_mode = 'ONLY_FULL_GROUP_BY,…'` first. (Relevant to any query that
59
+ selects non-aggregated columns alongside an aggregate.)
60
+ - **1.0 sibling trap (`worker` tier):** reference `App_Framework_Worker::initialize()` **before**
61
+ anything touches `App_Framework`. `__APPROOT__/app/framework.php` shadows the library file for
62
+ *both* class names, so resolving `App_Framework` first loads the app file and the library
63
+ definition never arrives.
64
+ - Do not drive a local harness through `_Worker::runTask` — the dev config's
65
+ `[cloud] aws_worker_queue_url` points at the **production** SQS queue. Call the target method
66
+ in-process. (Also noted on [email-template-sending.md](./email-template-sending.md).)
67
+
68
+ ## Change history
69
+
70
+ - 2026-08-03 — Created: recorded the bare-CLI bootstrap recipe (`ENVIRONMENT` first → `chdir` api2
71
+ root + `require Initialize.php` → `registerClientDatabases`) and the traps found building a
72
+ Compass Canada email render harness: CLI scripts must call `_Database::transactionStart()` **and**
73
+ `transactionCommit()` themselves or writes silently roll back while auto-increment still advances;
74
+ local **and** prod run `sql_mode = ''` so a local `GROUP BY`/`ONLY_FULL_GROUP_BY` test proves
75
+ nothing without `SET SESSION sql_mode`; and the 1.0 `App_Framework_Worker`-before-`App_Framework`
76
+ load-order trap. (bala)
77
+
78
+ ## Related docs
79
+
80
+ - [DB-free unit testing for _underscore model interceptors](./model-interceptor-unit-testing.md)
81
+ - [Database alias repointing](./database-alias-repointing.md)
82
+ - [Client Email Template Sending](./email-template-sending.md) — its local test-harness section.
83
+ - [French (fr-CA) Order Email Localization (Compass Canada)](../../../clients/compass-canada/features/french-order-email-localization.md)
84
+ — the work these traps surfaced in.
@@ -6,7 +6,7 @@ project: _Underscore
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-07-29
9
+ updated: 2026-08-03
10
10
  owners: ["bala", "tcox", "dfranks"]
11
11
  files:
12
12
  - _underscore/Email.php
@@ -15,6 +15,7 @@ related:
15
15
  - email-template-sending.md
16
16
  - ../../worker2/features/all-client-email-queue-monitor.md
17
17
  - ../../ai-bdr/features/web-funnel-app.md
18
+ - ../../../clients/compass-canada/features/french-order-email-localization.md
18
19
  ---
19
20
 
20
21
  ## Summary
@@ -56,6 +57,29 @@ SMTP** using PHPMailer, then flips each row to `SENT` or `FAILED`. So a 2.0 emai
56
57
  - **The Send worker forces HTML mode.** `sendEmail()` calls `$mailer->IsHTML(true)`
57
58
  **unconditionally**, ignoring the sender's `setIsHtml(false)`. A caller that queued a
58
59
  "plain-text" message is still transmitted HTML-mode. Author bodies accordingly.
60
+ - **⚠ The whole chain sends as iso-8859-1 — escape non-ASCII, or it mojibakes.** **Neither**
61
+ `_Email::send()` (queue side) **nor** `_Worker_Infrastructure_Email_Send::Run()` (transmit side)
62
+ sets `$mailer->CharSet`, so PHPMailer's default **iso-8859-1** applies end to end. Any raw UTF-8
63
+ in the rendered body (accented French text, em dash, curly quotes) arrives garbled. Two
64
+ consequences for anything building an email body in PHP:
65
+ - **Escape dynamic text as HTML entities** with exactly
66
+ `htmlentities($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8', false)`. Both non-default
67
+ arguments are required:
68
+ - `double_encode = false` — some stored data (e.g. `ItemTranslations.title` in one
69
+ environment) already holds **literal HTML entities as text**; re-encoding turns `&eacute;`
70
+ into `&amp;eacute;`, which the recipient reads as a literal `&eacute;`.
71
+ - `ENT_SUBSTITUTE` — passing explicit flags **drops** PHP 8.1's default `ENT_SUBSTITUTE`, and
72
+ `htmlentities()` returns an **empty string** on invalid UTF-8, so a single bad byte silently
73
+ blanks the entire field.
74
+ - **Stored `EmailTemplates` bodies should be pure ASCII.** A raw non-breaking space (U+00A0) or a
75
+ stray `Â` (U+00C2) pasted into a template body renders as mojibake in the delivered mail — use
76
+ `&nbsp;` / `&eacute;` entities instead. Real defect: 8 Compass Canada French templates carried a
77
+ `Â`+NBSP sequence before the colon in `"Commande :"`.
78
+
79
+ The root-cause fix is still to set `$mailer->CharSet = 'UTF-8'` — but that changes **every**
80
+ client's mail, so it must be its own tested change, not a drive-by. See
81
+ [`email-template-sending.md`](email-template-sending.md) and
82
+ [Compass Canada French order emails](../../../clients/compass-canada/features/french-order-email-localization.md).
59
83
  - **⚠ SECURITY — hardcoded AWS SES SMTP credentials in `_underscore/Email.php`.** The
60
84
  `SMTP_USERNAME` / `SMTP_PASSWORD` class constants hold **real** SES SMTP credentials committed
61
85
  in source, violating the no-secrets-in-code rule. Documenting the **location only** — do not
@@ -102,6 +126,14 @@ SMTP** using PHPMailer, then flips each row to `SENT` or `FAILED`. So a 2.0 emai
102
126
  for a hotfix.
103
127
 
104
128
  ## Change history
129
+ - 2026-08-03 — Documented the **full encoding chain** (no code change): neither `_Email::send()` nor
130
+ the worker2 Send action sets `$mailer->CharSet`, so mail transmits as **iso-8859-1** end to end.
131
+ Recorded the required escape for dynamic body text —
132
+ `htmlentities($v, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8', false)`, where `double_encode = false`
133
+ avoids `&amp;eacute;` on already-entity-encoded data and `ENT_SUBSTITUTE` (dropped when explicit
134
+ flags are passed) stops an invalid-UTF-8 byte returning an empty string — and that stored
135
+ `EmailTemplates` bodies should be pure ASCII (a raw NBSP/`Â` mojibakes). Surfaced fixing Compass
136
+ Canada's French order emails. (bala)
105
137
  - 2026-07-29 — Noted that the SES SMTP credentials/settings now have a **non-PHP consumer**: the
106
138
  BDR funnel sends via nodemailer against the same SES host (us-west-2, port 587, TLS) using
107
139
  `TOGA_SMTP_*` env vars, so a rotation must be coordinated with BDR's `.env.local` + Amplify
@@ -6,7 +6,7 @@ project: _Underscore
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-07-28
9
+ updated: 2026-08-03
10
10
  owners: ["jcardinal", "bala", "mhammontree"]
11
11
  files:
12
12
  - _underscore/Model/Client/EmailTemplate.php
@@ -114,6 +114,11 @@ can keep using `sendEmail($api, ...)`.
114
114
  use ASCII-only content. Recommended **root-cause fix**: set `$mailer->CharSet = 'UTF-8'` in
115
115
  `_Email::send()` — but that affects **every** client, so it should be a separate, tested
116
116
  change rather than a drive-by edit.
117
+ - **The transmit side doesn't set it either**, so the effective wire charset is **iso-8859-1**
118
+ end to end. For a body that genuinely needs accents (e.g. French), the working escape is
119
+ `htmlentities($v, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8', false)` — both non-default arguments
120
+ matter. See the encoding-chain gotcha on
121
+ [`email-send-pipeline.md`](email-send-pipeline.md) for why.
117
122
  - **⚠ SECURITY — live AWS SES SMTP credentials are hardcoded in `_underscore/Email.php`.**
118
123
  The `SMTP_USERNAME` / `SMTP_PASSWORD` class constants (near the top of `_Email`) hold
119
124
  **real** AWS SES SMTP credentials committed in source, violating the team no-secrets-in-code
@@ -146,6 +151,10 @@ worker method) in-process instead.
146
151
 
147
152
  ## Change history
148
153
 
154
+ - 2026-08-03 — Refined the `CharSet` gotcha: the **transmit** side doesn't set it either, so the
155
+ effective wire charset is **iso-8859-1**, and a body that needs accents should escape with
156
+ `htmlentities($v, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8', false)` (details on
157
+ [`email-send-pipeline.md`](email-send-pipeline.md)). Doc only. (bala)
149
158
  - 2026-07-28 — Clarified that `_Email::send()` **queues** a `PENDING` `Logs_[Client].Email` row
150
159
  rather than transmitting; the actual SES SMTP send/retry/`SENT`/`FAILED` happens in the worker2
151
160
  `Infrastructure/Email/Send` cron (~1-min latency). Added the new
@@ -6,7 +6,7 @@ project: API
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-07-21
9
+ updated: 2026-08-03
10
10
  owners: ["jcardinal", "bala"]
11
11
  files:
12
12
  - api2/Component/Api/V2/V2.php
@@ -230,10 +230,27 @@ None — uniform across all clients. The sidecar table + ACL ship via `dbchanges
230
230
  (`itemCategoryFeatureGroup.name`, e.g. "Processeur"/"Mémoire") stayed English, because the group
231
231
  object serialized through the all-fields branch. **Not a query-depth problem** — a commerce-side
232
232
  `depth:6` theory was wrong and reverted; the fix returns French headers at the default `meta.depth 3`.
233
- - **Sidecar/source collation mismatch.** The translation sidecar tables use `utf8mb4_unicode_ci`
234
- (matching `ItemTranslations`) while the source fields are `utf8mb4_0900_ai_ci`. Ad-hoc queries that
235
- compare a sidecar string against a source string must add `COLLATE utf8mb4_bin` or MySQL throws a
236
- collation-mismatch error.
233
+ - **Sidecar/source collation mismatch — and the collations DIFFER PER CLIENT DATABASE.** The
234
+ sidecar tables use `utf8mb4_unicode_ci` (matching `ItemTranslations`) while the source field's
235
+ collation varies by tenant: verified in prod **and** dev-sandbox, `Client_CompassCanada.Items.title`
236
+ is `utf8mb4_0900_ai_ci` but `Client_Compass.Items.title` is `utf8mb4_unicode_ci`. So a hardcoded
237
+ `COLLATE utf8mb4_bin` "fix" is brittle — it happens to work in one client DB and is unnecessary or
238
+ wrong in another. Consequences:
239
+ - **Never build a mixed-collation fallback expression in SQL.**
240
+ `COALESCE(NULLIF(ItemTranslations.title, ''), Items.title)` produces a result whose collation is
241
+ forced to `utf8mb4_bin` (MySQL cannot reconcile the two inputs). Plain `SELECT`ing it works, so it
242
+ looks fine — but **any** comparison, `ORDER BY`, `GROUP BY` or `DISTINCT` on that expression throws
243
+ **MySQL error 1267 "Illegal mix of collations"**. Reproduced against prod. It is a latent landmine:
244
+ the query ships green and fails the first time someone sorts or dedupes on it.
245
+ - **Do the fallback in PHP instead** — select `Items.title` and `ItemTranslations.title AS
246
+ translatedTitle` as **separate columns** and resolve (with a `trim`, so a whitespace-only
247
+ translation falls back) in code. This is the same choice the layer already makes on its read path
248
+ (`_Model::load()` per row, deliberately not a JOIN/COALESCE), so a hand-written query that needs a
249
+ translated value should follow the layer, not invent a SQL fallback. Worked example:
250
+ `_Model_Compass_SalesOrder::resolveItemTitle()` — see
251
+ [Compass Canada French order emails](../../../clients/compass-canada/features/french-order-email-localization.md).
252
+ - An ad-hoc **comparison** across the two (not a fallback) still needs an explicit `COLLATE` — pick
253
+ it after checking that specific client DB's collation, don't assume.
237
254
  - **GROUP BY gotcha on translatable joined fields (site 5).** The sibling joined-field code pushes each
238
255
  selected joined field into `$groupBy` under aggregates; the hidden PK column added for translation must
239
256
  **also** be pushed to `$groupBy` when `!empty($aggregateFunctionFields)`, or a non-English list request
@@ -264,6 +281,14 @@ None — uniform across all clients. The sidecar table + ACL ship via `dbchanges
264
281
 
265
282
  ## Change history
266
283
 
284
+ - 2026-08-03 — Corrected and extended the **collation** gotcha (doc only): the source-field collation
285
+ **differs per client database** (`Client_CompassCanada.Items.title` = `utf8mb4_0900_ai_ci` vs
286
+ `Client_Compass.Items.title` = `utf8mb4_unicode_ci`, sidecar `utf8mb4_unicode_ci` in both, verified
287
+ prod + dev-sandbox), so a hardcoded `COLLATE utf8mb4_bin` is brittle. Added the concrete trap: a
288
+ `COALESCE(NULLIF(sidecar, ''), source)` expression resolves to `utf8mb4_bin` and `SELECT`s fine but
289
+ throws **MySQL 1267** on any comparison / `ORDER BY` / `GROUP BY` / `DISTINCT` — so hand-written
290
+ queries must select both columns and fall back in PHP, matching the layer's own `_Model::load()`
291
+ approach. Surfaced building the Compass Canada French order-email item block. (bala)
267
292
  - 2026-07-23 — Documented (no code change) that the layer is fully generic: `loadTranslationSidecar()`
268
293
  derives the source FK as "the sidecar FK that is not `languageId`" and loads by `(sourceFk, languageId)`
269
294
  via `_Model::load()`, so adding a new translatable field is **DB-metadata-only** (set
@@ -8,7 +8,7 @@ _Auto-generated by `knowledge.js index`. Do not hand-edit._
8
8
  - **worker** (Worker) — 18 doc(s) → [1.0/apps/worker/INDEX.md](1.0/apps/worker/INDEX.md)
9
9
  - **dbchanges** (Database Changes) _(framework core)_ — 1 doc(s) → [1.0/apps/dbchanges/INDEX.md](1.0/apps/dbchanges/INDEX.md)
10
10
  - **worker1.5** (Worker 1.5) — 0 doc(s) → [1.0/apps/worker1.5/INDEX.md](1.0/apps/worker1.5/INDEX.md)
11
- - **togadesk** (TOGa Desk) — 10 doc(s) → [1.0/apps/togadesk/INDEX.md](1.0/apps/togadesk/INDEX.md)
11
+ - **togadesk** (TOGa Desk) — 11 doc(s) → [1.0/apps/togadesk/INDEX.md](1.0/apps/togadesk/INDEX.md)
12
12
  - **togaview** (TOGa View) — 6 doc(s) → [1.0/apps/togaview/INDEX.md](1.0/apps/togaview/INDEX.md)
13
13
  - **webhook** (Webhook) — 1 doc(s) → [1.0/apps/webhook/INDEX.md](1.0/apps/webhook/INDEX.md)
14
14
  - **walmarttechservices** (Walmart Tech Services) — 1 doc(s) → [1.0/apps/walmarttechservices/INDEX.md](1.0/apps/walmarttechservices/INDEX.md)
@@ -18,7 +18,7 @@ _Auto-generated by `knowledge.js index`. Do not hand-edit._
18
18
 
19
19
  ## 2.0 framework
20
20
 
21
- - **_underscore** (_Underscore) _(framework core)_ — 42 doc(s) → [2.0/apps/_underscore/INDEX.md](2.0/apps/_underscore/INDEX.md)
21
+ - **_underscore** (_Underscore) _(framework core)_ — 43 doc(s) → [2.0/apps/_underscore/INDEX.md](2.0/apps/_underscore/INDEX.md)
22
22
  - **worker2** (Worker) — 38 doc(s) → [2.0/apps/worker2/INDEX.md](2.0/apps/worker2/INDEX.md)
23
23
  - **api2** (API) — 21 doc(s) → [2.0/apps/api2/INDEX.md](2.0/apps/api2/INDEX.md)
24
24
  - **dbchanges2** (Database Changes) _(framework core)_ — 5 doc(s) → [2.0/apps/dbchanges2/INDEX.md](2.0/apps/dbchanges2/INDEX.md)
@@ -56,6 +56,7 @@ _Auto-generated by `knowledge.js index`. Do not hand-edit._
56
56
  - **Prudential Financial** (`prudential`) → [clients/prudential/INDEX.md](clients/prudential/INDEX.md)
57
57
  - **Quad Graphics** (`quad`) → [clients/quad/INDEX.md](clients/quad/INDEX.md)
58
58
  - **Rate** (`rate`) → [clients/rate/INDEX.md](clients/rate/INDEX.md)
59
+ - **Richmond University Medical Center** (`rumcsi`) → [clients/rumcsi/INDEX.md](clients/rumcsi/INDEX.md)
59
60
  - **Tow Foundation** (`tow-foundation`) → [clients/tow-foundation/INDEX.md](clients/tow-foundation/INDEX.md)
60
61
  - **TOGA Technology** (`true`) → [clients/true/INDEX.md](clients/true/INDEX.md)
61
62
  - **Walmart Client Profile** (`walmart`) → [clients/walmart/INDEX.md](clients/walmart/INDEX.md)
@@ -3,5 +3,6 @@
3
3
  | Doc | Framework | Summary | Files |
4
4
  |-----|-----------|---------|-------|
5
5
  | [French (fr-CA) Item Feature Translations (Compass Canada)](features/french-item-feature-translations.md) | 2.0 | Renders item **feature** text on the Compass Canada French storefront — feature names, feature-group headers (e.g. | _underscore/Model/Compass/Canada/Feature.php, _underscore/Model/Compass/Canada/ItemCategoryFeatureGroup.php, _underscore/Model/Client/FeatureTranslation.php, _underscore/Model/Client/ItemCategoryFeatureGroupTranslation.php, _underscore/Model/Client/ItemFeatureTranslation.php, worker2/Worker/Etilize/ItemTranslations.php, dbchanges2/Client/2026-07-13a - FeatureTranslations.sql, dbchanges2/Client/2026-07-13b - FeatureTranslationsAcl.sql, dbchanges2/Core/2026-07-13 - FeatureTranslationsRecord.sql, dbchanges2/Client_CompassCanada/2026-07-13 - FeatureAttributeCustomFields.sql, dbchanges2/Client_CompassCanada/2026-07-13 - DedupeItemFeaturesAndGroups.sql, dbchanges2/Client_CompassCanada/2026-07-13 - SeedFrenchFeatureTranslations.sql |
6
+ | [French (fr-CA) Order Email Localization (Compass Canada)](features/french-order-email-localization.md) | 2.0 | Compass Canada order emails (order requested, manager-approval request, approved/rejected, in-transit, delivered, reminders) are sent in **each recipient's own | _underscore/Model/Compass/SalesOrder.php, _underscore/Model/Compass/ApprovalDecision.php, library/app/client/compasscanada.php, worker/crons/toga2/compasscanada/send_delivered_email.php, worker/crons/toga2/compasscanada/compass_email_reminders.php, worker/crons/toga2/compasscanada/compass_cancel_pending_approval_orders.php, worker/crons/toga2/compasscanada/update_salesorder_status_from_odp.php, worker/crons/toga2/compasscanada/workflow/3_update_salesorder_status_from_grand_and_toy.php, worker/crons/toga2/compasscanada/workflow/4_import_grand_and_toy_advance_shipping_notices.php, worker/crons/toga2/compasscanada/workflow/test_partial_in_transit_email.php, worker/crons/toga2/compasscanada/workflow/test_partial_delivered_email.php, worker/crons/toga2/compasscanada/workflow/test_email_previews_prod.php |
6
7
  | [Grand & Toy ASN Import (Compass Canada)](features/grand-and-toy-asn-import.md) | 2.0 | Imports Grand & Toy (G&T) Advance Shipping Notices for Compass Canada. | worker/crons/toga2/compasscanada/workflow/4_import_grand_and_toy_advance_shipping_notices.php, worker/crons/toga2/compasscanada/workflow/import_grand_and_toy_asn_from_file.php, worker/schedules/cron.worker.sync.json, _underscore/Model/Compass/AdvanceShippingNotice.php, _underscore/Model/Compass/Canada/AdvanceShippingNotice.php, dbchanges2/Client_CompassCanada/ |
7
8
  | [Compass Canada](profile.md) | 2.0 | Compass Canada is the Canadian arm of the Compass account — a separate TOGA tenant, related to but distinct from Compass USA. | |
@@ -5,7 +5,7 @@ project: _Underscore
5
5
  client: compass-canada
6
6
  type: client-feature
7
7
  status: active
8
- updated: 2026-07-14
8
+ updated: 2026-08-03
9
9
  owners: ["bala"]
10
10
  files:
11
11
  - _underscore/Model/Compass/Canada/Feature.php
@@ -74,8 +74,12 @@ Prod rollout is deferred until explicitly requested; the dev/beta build (beta DB
74
74
 
75
75
  ## Gotchas / known issues
76
76
  - **Sidecar/source collation mismatch.** Sidecar tables are `utf8mb4_unicode_ci` (matching
77
- `ItemTranslations`); the source fields are `utf8mb4_0900_ai_ci`. Any ad-hoc query comparing across the
78
- two needs `COLLATE utf8mb4_bin` or MySQL throws a collation-mismatch error.
77
+ `ItemTranslations`); in `Client_CompassCanada` the source fields are `utf8mb4_0900_ai_ci`, so an
78
+ ad-hoc query **comparing** across the two needs an explicit `COLLATE`. Two corrections since:
79
+ the source collation **differs per client DB** (so don't copy a `COLLATE utf8mb4_bin` literal
80
+ around), and a `COALESCE(NULLIF(sidecar, ''), source)` **fallback** expression must not be built
81
+ in SQL at all — it throws MySQL 1267 on any sort/compare. See the canonical gotcha on
82
+ [Language Translation Layer](../../../2.0/apps/api2/features/language-translation-layer.md).
79
83
  - **Etilize attribute ids of the form `34xxxxxx` are NOT corrupted** (false alarm resolved). A manual
80
84
  XML showing `32xxxxxx` came from a different Etilize view; the worker's Etilize endpoint returns
81
85
  `34xxxxxx` consistently for both backfill and import, so translations match. Do not "fix" them.
@@ -84,6 +88,8 @@ Prod rollout is deferred until explicitly requested; the dev/beta build (beta DB
84
88
  correct value is `1086408433`. Fixed by a single `UPDATE` — this was a data error, not a code bug.
85
89
 
86
90
  ## Change history
91
+ - 2026-08-03 — Corrected the collation gotcha to point at the canonical (now per-client-aware) version
92
+ on the shared translation-layer doc; no code or rollout change. (bala)
87
93
  - 2026-07-13 — Built the fr-CA item-feature translation path end-to-end on dev/beta: shared feature
88
94
  sidecar tables + Core wiring, Compass-only `c_etilizeAttributeId` / `c_etilizeAttributeGroupId` custom
89
95
  fields, worker2 backfill + feature-translation import, dedup of ~7700 dup features / ~2100 dup groups,
@@ -0,0 +1,246 @@
1
+ ---
2
+ title: French (fr-CA) Order Email Localization (Compass Canada)
3
+ framework: "2.0"
4
+ project: _Underscore
5
+ client: compass-canada
6
+ type: client-feature
7
+ status: active
8
+ updated: 2026-08-03
9
+ owners: ["bala"]
10
+ files:
11
+ - _underscore/Model/Compass/SalesOrder.php
12
+ - _underscore/Model/Compass/ApprovalDecision.php
13
+ - library/app/client/compasscanada.php
14
+ - worker/crons/toga2/compasscanada/send_delivered_email.php
15
+ - worker/crons/toga2/compasscanada/compass_email_reminders.php
16
+ - worker/crons/toga2/compasscanada/compass_cancel_pending_approval_orders.php
17
+ - worker/crons/toga2/compasscanada/update_salesorder_status_from_odp.php
18
+ - worker/crons/toga2/compasscanada/workflow/3_update_salesorder_status_from_grand_and_toy.php
19
+ - worker/crons/toga2/compasscanada/workflow/4_import_grand_and_toy_advance_shipping_notices.php
20
+ - worker/crons/toga2/compasscanada/workflow/test_partial_in_transit_email.php
21
+ - worker/crons/toga2/compasscanada/workflow/test_partial_delivered_email.php
22
+ - worker/crons/toga2/compasscanada/workflow/test_email_previews_prod.php
23
+ related:
24
+ - ../profile.md
25
+ - ../../compass-usa/features/approval-decision-flow.md
26
+ - ../../../2.0/apps/api2/features/language-translation-layer.md
27
+ - ../../../2.0/apps/_underscore/features/email-send-pipeline.md
28
+ - ../../../1.0/apps/worker/features/compass-partial-in-transit-delivered-emails.md
29
+ ---
30
+
31
+ ## Summary
32
+
33
+ Compass Canada order emails (order requested, manager-approval request, approved/rejected,
34
+ in-transit, delivered, reminders) are sent in **each recipient's own language**. The French
35
+ *wrapper* (subject + body template) was already selected correctly; what was broken was the
36
+ **`{orderItems}` item block**, which was built from `Items.title` and never consulted the
37
+ `ItemTranslations` sidecar, with hardcoded English `P/N:` / `Qty:` labels. So a French user
38
+ received a French email containing English product names.
39
+
40
+ Two things are load-bearing here and are easy to get wrong again:
41
+
42
+ 1. **Language is per recipient, not per order.** One order's requester and manager can have
43
+ different languages, so the item block must be rebuilt for each recipient.
44
+ 2. **These emails go out as iso-8859-1**, so French text must be HTML-entity-escaped with an
45
+ exact flag set (see *Encoding*) or the recipient sees mojibake or a blank product title.
46
+
47
+ Spans both tiers: **2.0** (`_underscore` Compass models, order/approval interceptors) and
48
+ **1.0** (`library` helper + the Compass Canada worker crons).
49
+
50
+ ## Key files / entry points
51
+
52
+ **2.0 (`_underscore`)**
53
+ - `_underscore/Model/Compass/SalesOrder.php`
54
+ - `buildOrderItemsHtmlForRecipient(&$api, $salesOrderId, ?int $recipientUserId)` — **the entry
55
+ point every email site should call.** Resolves that recipient's language first, then builds
56
+ the block.
57
+ - `buildOrderItemsHtml($salesOrderId, string $languageCode)` — the builder; conditionally
58
+ `LEFT JOIN`s `ItemTranslations` and selects `translatedTitle` as a **separate column**.
59
+ - `resolveItemTitle(?string $baseTitle, ?string $translatedTitle)` — PHP-side fallback
60
+ (trims, so a whitespace-only translation falls back to English).
61
+ - `resolveItemTranslationLanguageId(string $languageCode)` — `Languages.code → id`, returns
62
+ `0` for base English / unknown / lookup failure (0 means "no translation join").
63
+ - `resolveOrderItemLabels(string $languageCode)` — the label pair; constants
64
+ `ORDER_ITEM_LABEL_PART_NUMBER__FRENCH = 'N/P'`, `ORDER_ITEM_LABEL_QUANTITY__FRENCH = 'Qté'`
65
+ (stored as the `&eacute;` entity).
66
+ - `_underscore/Model/Compass/ApprovalDecision.php`
67
+ - `resolveRecipientLanguageCode(&$api, ?int $userId)` — the single language resolver
68
+ (`UserGlobalSettings.settingId = 2` INNER JOIN `Languages ON code = value`). Returns `en`
69
+ for any non-`Compass_Canada` client, an empty `$userId`, no row, or a thrown query.
70
+ - `resolveEmailTemplateUuid(&$api, $templateKey, ?int $userId)` — picks the EN/FR template for
71
+ **that** recipient, off the same resolver.
72
+
73
+ **1.0 (`library` + `worker`)**
74
+ - `library/app/client/compasscanada.php` — `App_Client_CompassCanada::resolveUserLanguageCode(int $userId, string $databaseLink)`
75
+ plus `SETTING_ID__LANGUAGE = 2`, `LANGUAGE_CODE__BASE`/`__FRENCH_CANADA`, and
76
+ `isFrenchLanguageCode()` (prefix match on `fr`). **All 8 Compass Canada crons depend on this
77
+ class** — see *Deployment status*.
78
+ - The crons that render or send a localized order email: `send_delivered_email.php`,
79
+ `compass_email_reminders.php`, `compass_cancel_pending_approval_orders.php`,
80
+ `update_salesorder_status_from_odp.php`, `workflow/3_…grand_and_toy.php`,
81
+ `workflow/4_import_…advance_shipping_notices.php`, and the two partial-email test scripts.
82
+
83
+ ## How it works
84
+
85
+ 1. **Resolve the recipient.** Each email site knows *who* it is emailing (requester = order
86
+ `contactId → Users`, manager = `ManagerDecision.assignedToUserId`, etc.) and passes that
87
+ `userId` in. 2.0 uses `resolveRecipientLanguageCode()`; the 1.0 crons use
88
+ `App_Client_CompassCanada::resolveUserLanguageCode()`.
89
+ 2. **Pick the template** for that language (`resolveEmailTemplateUuid`), and
90
+ 3. **Build the item block for that same recipient** (`buildOrderItemsHtmlForRecipient`). Steps 2
91
+ and 3 read the *same* resolver, so the wrapper language and the item language can never
92
+ disagree.
93
+ 4. **Titles:** for a non-base language the query `LEFT JOIN`s `ItemTranslations` on
94
+ `(itemId, languageId)` and selects `Items.title` **and** `ItemTranslations.title AS
95
+ translatedTitle` as two columns; `resolveItemTitle()` does the fallback in PHP. Do **not**
96
+ collapse this into a SQL `COALESCE` — see *Gotchas*.
97
+ 5. **Escape for the wire:** every item string is run through
98
+ `htmlentities($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8', false)` before it enters the HTML.
99
+ 6. `_Email::send()` queues a `PENDING` `Logs_CompassCanada.Email` row; the worker2
100
+ `Infrastructure/Email/Send` cron transmits it ~1 minute later. See
101
+ [2.0 Email Send Pipeline](../../../2.0/apps/_underscore/features/email-send-pipeline.md).
102
+
103
+ ### Encoding — why those exact `htmlentities` flags
104
+
105
+ Compass emails are transmitted as **iso-8859-1**: neither `_Email::send()` nor
106
+ `_Worker_Infrastructure_Email_Send::Run()` sets `$mailer->CharSet`, so PHPMailer's default
107
+ applies. Any raw UTF-8 accent in the rendered body therefore arrives as mojibake. Entity-escaping
108
+ the dynamic text sidesteps it — but both non-default arguments are required:
109
+
110
+ - **`double_encode = false`** — in some environments `ItemTranslations.title` holds *literal HTML
111
+ entities as text*. Re-encoding those produced `&amp;eacute;`, which the recipient reads as a
112
+ literal `&eacute;`. (Prod and dev-sandbox data is clean raw-accent UTF-8; a stale local dataset
113
+ had 71 of 215 rows entity-encoded. The flag makes the code correct under either data shape.)
114
+ - **`ENT_SUBSTITUTE`** — passing explicit flags **drops** PHP 8.1's default `ENT_SUBSTITUTE`, and
115
+ `htmlentities()` returns an **empty string** on invalid UTF-8. Omitting it silently blanks the
116
+ whole product title.
117
+
118
+ ### Resilience — a language lookup must never break the caller
119
+
120
+ `App_Query::execute()` (1.0, `library/app/query.php`) genuinely throws on any MySQL error, so this
121
+ is a real failure mode, not a theoretical one. The guard is placed differently per tier, on purpose:
122
+
123
+ - **1.0 crons:** each of the six call sites wraps `resolveUserLanguageCode()` in `try/catch`,
124
+ defaults to base English and `error_log`s. An unguarded throw there aborts the remaining orders
125
+ in that run.
126
+ - **2.0:** the guard lives **inside** the two helpers (`ApprovalDecision::resolveRecipientLanguageCode()`
127
+ and `SalesOrder::resolveItemTranslationLanguageId()`) so every caller inherits it. These run
128
+ inside `postPost`/`postPut` interceptors — an unguarded throw would fail the customer's **order
129
+ submission** or an admin's **approval click**.
130
+
131
+ ### Prod-safe email preview harness
132
+
133
+ `worker/crons/toga2/compasscanada/workflow/test_email_previews_prod.php` renders Order Requested,
134
+ In Transit and Delivered in EN and FR **from real prod data**. Its safety design is worth copying
135
+ for any future prod preview script:
136
+
137
+ - a single hardcoded `TEST_RECIPIENT` (an internal `@togatech.com` address), **no CC/BCC**;
138
+ - an **abort guard** that refuses to run if the recipient's domain is a client domain
139
+ (`compass-canada.com`, `compass-usa.com`, `compassdigital.io`);
140
+ - `DRY_RUN` ships **true** — flip it to `false` only for the real send, then flip it back;
141
+ - **SELECT-only**: no `cronInitialization`, no status/flag writes, reads off the read cluster;
142
+ - fully self-contained, so it runs against prod **without** any undeployed code.
143
+ - **Caveat:** a real send still inserts a row in `Logs_CompassCanada.Email` — that table is the
144
+ customer email audit log, so a preview send is visible there.
145
+
146
+ ## Data model
147
+
148
+ - `UserGlobalSettings.settingId = 2` = `language`; values `en` / `fr-CA`. `Settings.id = 2` is the
149
+ `language` setting in **both** prod and dev-sandbox, so the hardcoded id is safe.
150
+ - `Languages` (client DB) — `code` → `id`; fr-CA is `languageId 2` for Compass Canada.
151
+ - `ItemTranslations` (client DB) — the item-text sidecar (`itemId`, `languageId`, `title`, …).
152
+ Shared layer: [Language Translation Layer](../../../2.0/apps/api2/features/language-translation-layer.md).
153
+ - `EmailTemplates` (client DB) — 23 Compass Canada template UUIDs are hardcoded across the code
154
+ (EN + FR pairs). `Logs_CompassCanada.Email` is the send queue / audit log.
155
+
156
+ ## Gotchas / known issues
157
+
158
+ - **Never build one item block per order.** A single shared `$orderItemsHtml` was reused for the
159
+ requester email *and* the manager email, so when their languages differed one of them got the
160
+ wrong language. Always call `buildOrderItemsHtmlForRecipient(..., $recipientUserId)` per
161
+ recipient.
162
+ - **Pick the template from the ACTUAL recipient's language.** The 1.0 manager-approval reminder
163
+ cron (`compass_email_reminders.php`) chose the template from the **requester's** language even
164
+ though the email is sent to the **manager**. Same class of bug as the item block; now uses the
165
+ manager's language.
166
+ - **Do not `COALESCE` the sidecar title with the source title in SQL.**
167
+ `COALESCE(NULLIF(ItemTranslations.title, ''), Items.title)` yields a `utf8mb4_bin` result (MySQL
168
+ cannot reconcile the two input collations). `SELECT`ing it works, but **any** comparison,
169
+ `ORDER BY`, `GROUP BY` or `DISTINCT` on that expression throws **MySQL 1267 "Illegal mix of
170
+ collations"** — reproduced against prod. Worse, the collations **differ per client database**
171
+ (`Client_CompassCanada.Items.title` = `utf8mb4_0900_ai_ci`, `Client_Compass.Items.title` =
172
+ `utf8mb4_unicode_ci`, `ItemTranslations.title` = `utf8mb4_unicode_ci` in both), so a hardcoded
173
+ `COLLATE` clause is brittle. Select the two columns separately and fall back in PHP — which is
174
+ also what the shared translation layer does (`_Model::load()`, deliberately not a JOIN/COALESCE).
175
+ - **The `{role}` token appears TWICE in the French rejection template, with different grammar.**
176
+ The second occurrence is `par un <strong>{role} TechHub</strong>`, which is already correct. So
177
+ the missing elision must be fixed **in the template**, never by moving an article into the PHP
178
+ role constant — that would break the second slot (`par un l'admin TechHub`). The template now
179
+ reads `Refus&eacute;e par&nbsp;: {role}`, which is correct for both `admin` and `gestionnaire`.
180
+ - **⚠ Do NOT "fix" the `approvalManagerId` subquery in `_Model_Compass_SalesOrder::postPut`.** It
181
+ reads the step-2 `ApprovalDecision.decidedByUserId` (not `assignedToUserId`), which is `NULL`
182
+ while the decision is pending — so `$managerEmail` stays empty and that arm never sends. That
183
+ looks like a bug but is exactly what **prevents duplicate manager emails**: the canonical
184
+ manager approval-request email is sent by `_Model_Compass_ApprovalDecision`, which correctly uses
185
+ `ManagerDecision.assignedToUserId`. Switching the column would send a second manager email on
186
+ every subsequent `SalesOrders` PUT. Evidence: prod `Logs_CompassCanada.Email` held 461 emails
187
+ over ~3 weeks including 113 manager-approval emails, with **zero** duplicate `(to, subject)`
188
+ pairs. Also recorded on
189
+ [Approval-Decision Flow](../../compass-usa/features/approval-decision-flow.md).
190
+ - **French template bodies must be pure ASCII.** Under the iso-8859-1 send, a raw non-breaking
191
+ space in a stored template body renders as mojibake. Eight Compass Canada French templates
192
+ carried a stray `Â` (U+00C2) immediately followed by a raw NBSP (U+00A0) before the colon in
193
+ `"Commande :"` (10 occurrences); replacing that 4-byte sequence with the `&nbsp;` entity makes
194
+ those bodies ASCII. Applied to prod **and** dev-sandbox. Check for this whenever a French
195
+ template is authored or pasted from Word/Docs.
196
+ - **Compass US is provably unaffected.** `Client_Compass` has **0** `ItemTranslations` rows and only
197
+ `en` in `Languages`, and every language branch is gated on
198
+ `clientIdentifier === 'Compass_Canada'`, so the French path cannot alter a US email.
199
+
200
+ ## Verified state (2026-08-03, read-only checks)
201
+
202
+ - All **23** Compass Canada template UUIDs hardcoded in the code exist and are `isActive` in **prod
203
+ and dev-sandbox**; all **8** Compass US ones exist and are active in prod.
204
+ - Prod Compass Canada: **220** fr-CA `ItemTranslations` rows and **100% coverage** of every item ever
205
+ ordered (1178 item rows across 699 orders, zero fallbacks). dev-sandbox: 215 rows, 985 item rows /
206
+ 585 orders, zero fallbacks.
207
+ - Verification run: a 20-scenario local regression suite (20/20) against a prod-shaped dataset, ~20
208
+ read-only prod + dev-sandbox data checks, and an end-to-end render into the local email queue
209
+ confirming **zero raw UTF-8 bytes and zero double-encoded entities**.
210
+
211
+ ## Deployment status (open risk)
212
+
213
+ **Not deployed.** The code is committed on branch **`TRUE-80543`** in `_underscore` and `worker`.
214
+ The `library` change (`App_Client_CompassCanada`) is still **UNCOMMITTED on `_production`** and has
215
+ **no `TRUE-80543` branch** — and **all 8 worker crons depend on that class**, so merging the worker
216
+ PR without the library change would **fatal** every Compass Canada cron. Ship `library` first (or in
217
+ the same release). The `EmailTemplates` data fixes (stray `Â`+NBSP, the `{role}` elision) are
218
+ already applied to prod and dev-sandbox.
219
+
220
+ ## Change history
221
+
222
+ - 2026-08-03 — Localized the Compass Canada `{orderItems}` email block: titles now come from the
223
+ `ItemTranslations` sidecar (two-column select + PHP `resolveItemTitle()` fallback, never a
224
+ mixed-collation SQL `COALESCE`) and the `P/N:`/`Qty:` labels are translated. Made language
225
+ **per recipient** via `buildOrderItemsHtmlForRecipient()` / `resolveRecipientLanguageCode()`
226
+ (one shared block had been reused for requester + manager), and fixed the 1.0 manager-reminder
227
+ cron choosing the template from the requester's instead of the manager's language. Escaped item
228
+ text with `htmlentities(..., ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8', false)` for the iso-8859-1
229
+ send. Added try/catch fallback-to-English around every language lookup (per call site in the 1.0
230
+ crons, inside the helpers in 2.0). Fixed 8 French `EmailTemplates` bodies (stray `Â` + raw NBSP →
231
+ `&nbsp;`) and the rejection template's `Refus&eacute;e par&nbsp;: {role}` elision in prod +
232
+ dev-sandbox. Built the prod-safe `test_email_previews_prod.php` preview harness. Deliberately left
233
+ the `postPut` `approvalManagerId` subquery alone (it prevents duplicate manager emails).
234
+ TRUE-80543, not yet deployed. (bala)
235
+
236
+ ## Related docs
237
+
238
+ - [Compass Canada profile](../profile.md)
239
+ - [Compass Approval-Decision Flow](../../compass-usa/features/approval-decision-flow.md) — the
240
+ shared parent that sends the approval/notification emails and resolves EN/FR templates.
241
+ - [Language Translation Layer](../../../2.0/apps/api2/features/language-translation-layer.md) — the
242
+ `ItemTranslations` sidecar and its PHP-side fallback rule.
243
+ - [2.0 Email Send Pipeline](../../../2.0/apps/_underscore/features/email-send-pipeline.md) — the
244
+ queue + Send worker, and the missing `CharSet` that makes these emails iso-8859-1.
245
+ - [Compass Partial In-Transit & Delivered Emails](../../../1.0/apps/worker/features/compass-partial-in-transit-delivered-emails.md)
246
+ — the 1.0 crons whose item rows this localizes.
@@ -10,16 +10,18 @@ apps:
10
10
  - worker
11
11
  - worker2
12
12
  - dbchanges2
13
+ - library
13
14
  project: _Underscore
14
15
  client: compass-canada
15
16
  type: profile
16
17
  status: active
17
- updated: 2026-07-23
18
+ updated: 2026-08-03
18
19
  owners: [jcardinal, bala, tcox, apeterson]
19
20
  files: []
20
21
  related:
21
22
  - ../compass-usa/profile.md
22
23
  - ../compass-usa/features/approval-decision-flow.md
24
+ - features/french-order-email-localization.md
23
25
  - ../../2.0/apps/toga2-commerce/features/expedited-shipping-gating.md
24
26
  - ../../2.0/apps/_underscore/features/item-fulfillment-stage-lifecycle-and-order-status.md
25
27
  - ../../2.0/apps/_underscore/features/surface-resolver.md
@@ -34,7 +36,10 @@ to but distinct from Compass USA. Like Compass USA it spans the **2.0** commerce
34
36
  - **2.0:** `_underscore` backend, prod schema `Client_CompassCanada` (archive
35
37
  `Archive_CompassCanada`, logs `Logs_CompassCanada`). Client-specific model overrides live
36
38
  under `_underscore/Model/Compass/Canada/` (a sub-client of the Compass model tree).
37
- - **1.0:** worker crons under `worker/crons/toga2/compasscanada/`.
39
+ - **1.0:** worker crons under `worker/crons/toga2/compasscanada/`, plus the shared client helper
40
+ `App_Client_CompassCanada` in **`library`** (`library/app/client/compasscanada.php`) — language
41
+ resolution + client constants that all 8 Canada crons depend on. `library` is in `apps` for that
42
+ reason.
38
43
 
39
44
  ## Vendors & integrations
40
45
  - **Grand & Toy (G&T)** — primary hardware vendor. SOs flow toga → MITS → PO to G&T; G&T sends
@@ -59,7 +64,10 @@ to but distinct from Compass USA. Like Compass USA it spans the **2.0** commerce
59
64
  client role 1, id-agnostic + NOT-EXISTS-guarded, beta only). Exposes surfaces to role 1 only, vs
60
65
  Compass USA's 1/3/4. See [Surface Resolver](../../2.0/apps/_underscore/features/surface-resolver.md).
61
66
  - Customer language preference: `UserGlobalSettings.settingId = 2` (`en` / `fr-CA`); customer-
62
- facing emails are sent in EN or FR accordingly.
67
+ facing emails are sent in EN or FR accordingly — **per recipient**, including the `{orderItems}`
68
+ block, and entity-escaped because the send is iso-8859-1. See
69
+ [French Order Email Localization](features/french-order-email-localization.md). French
70
+ `EmailTemplates` bodies must be pure ASCII.
63
71
  - **Approval decisions use the shared Compass parent.** `_Model_Compass_Canada_ApprovalDecision`
64
72
  is an empty subclass of `_Model_Compass_ApprovalDecision`; the entire approval/notification/
65
73
  manager-reassignment/VIP-auto-approve flow lives in the parent and branches at runtime on
@@ -87,4 +95,7 @@ to but distinct from Compass USA. Like Compass USA it spans the **2.0** commerce
87
95
  Compass Canada's own IF lifecycle stages (picked/packed/shipped) + shipped backfill are seeded by
88
96
  `dbchanges2/Client_CompassCanada/2026-06-30a - ItemFulfillmentLifecycleAndShippedBackfill.sql`.
89
97
  See [IF stage lifecycle & order status](../../2.0/apps/_underscore/features/item-fulfillment-stage-lifecycle-and-order-status.md).
98
+ - **Item titles in customer emails** come from the `ItemTranslations` sidecar (prod: 220 fr-CA rows,
99
+ 100% coverage of every item ever ordered). Resolve the English/translated fallback **in PHP**, never
100
+ as a SQL `COALESCE` — the two columns' collations differ and differ *per client DB*.
90
101
  - Related: [Compass USA](../compass-usa/profile.md).
@@ -6,7 +6,7 @@ project: _Underscore
6
6
  client: compass-usa
7
7
  type: client-feature
8
8
  status: active
9
- updated: 2026-07-23
9
+ updated: 2026-08-03
10
10
  owners: ["apeterson", "dfranks", "bala"]
11
11
  files:
12
12
  - _underscore/Model/Compass/ApprovalDecision.php
@@ -16,6 +16,7 @@ files:
16
16
  related:
17
17
  - mr-ma-order-approval-and-status.md
18
18
  - ../../compass-canada/profile.md
19
+ - ../../compass-canada/features/french-order-email-localization.md
19
20
  - ../../../2.0/apps/_underscore/features/email-template-sending.md
20
21
  ---
21
22
 
@@ -47,9 +48,16 @@ Compass US and Compass Canada resolve to different model classes at request time
47
48
  (`_Model_Compass_Usa_ApprovalDecision` / `_Model_Compass_Canada_ApprovalDecision`), but both are
48
49
  empty shells extending `_Model_Compass_ApprovalDecision`. All behavior is inherited. Where the two
49
50
  tenants differ, the **parent** branches on `$api->client->clientIdentifier === 'Compass_Canada'`:
50
- - **Email-template resolution** — `resolveEmailTemplateUuid()` picks the tenant's template UUID.
51
- - **EN/FR localization** — user language is read from `UserGlobalSettings` (`settingId = 2`;
52
- `en` / `fr-CA`) so Canadian notifications go out in the recipient's language.
51
+ - **Email-template resolution** — `resolveEmailTemplateUuid(&$api, $templateKey, ?int $userId)` picks
52
+ the tenant's template UUID, **for that recipient's language**.
53
+ - **EN/FR localization** — one resolver,
54
+ `resolveRecipientLanguageCode(&$api, ?int $userId)`, reads the language from `UserGlobalSettings`
55
+ (`settingId = 2`; `en` / `fr-CA`) and returns `en` for any non-Canada client, an empty `$userId`,
56
+ no row, or a thrown query (it catches + `error_log`s, because it runs inside an interceptor). The
57
+ template UUID **and** the `{orderItems}` block
58
+ (`_Model_Compass_SalesOrder::buildOrderItemsHtmlForRecipient()`) both read this same resolver, so
59
+ wrapper language and item language can never disagree — see
60
+ [Compass Canada French order emails](../../compass-canada/features/french-order-email-localization.md).
53
61
  - **Web-link host resolution** — `_Model_Compass_SalesOrder::resolveSupplyHost()` /
54
62
  `resolveCommerceHost()` pick the tenant's supply/commerce domain for the links embedded in
55
63
  approval and order-status emails (see below).
@@ -138,6 +146,19 @@ requester**. All of these comparisons are case-insensitive (`strcasecmp()`), mat
138
146
  requester/new-manager guards.
139
147
  - **Don't put tenant behavior in the empty subclasses.** `Usa`/`Canada` `ApprovalDecision` are
140
148
  intentionally empty; the tenant branch lives in the parent on `clientIdentifier`.
149
+ - **⚠ DO NOT "fix" the `approvalManagerId` subquery in `_Model_Compass_SalesOrder::postPut`.** It
150
+ reads the step-2 `ApprovalDecision.decidedByUserId` (aliased `approvalManagerId`) rather than
151
+ `assignedToUserId`. Because `decidedByUserId` is `NULL` while a step-2 decision is still pending,
152
+ `$managerEmail` stays empty and that manager arm **never sends**. It reads like an obvious bug — it
153
+ is in fact what **prevents duplicate manager emails**: the canonical manager approval-request email
154
+ is sent by `_Model_Compass_ApprovalDecision`, which correctly uses `ManagerDecision.assignedToUserId`.
155
+ Switching the column would fire a **second** manager email on every subsequent `SalesOrders` PUT.
156
+ Evidence (2026-08-03): prod `Logs_CompassCanada.Email` held 461 emails over ~3 weeks, including 113
157
+ manager-approval emails, with **zero** duplicate `(to, subject)` pairs. If this arm ever *should*
158
+ send, remove it or gate it explicitly — don't change the column.
159
+ - **Localize per recipient, not per order.** An order's requester and manager can have different
160
+ languages. Anything that builds shared email HTML once and reuses it for both recipients will send
161
+ one of them the wrong language (this was a live bug in the `{orderItems}` block, fixed 2026-08-03).
141
162
  - **Never hardcode the web host in email links.** The email links previously used string literals
142
163
  `compass.togasupply.com` / `compass.togacommerce.com` in the parent classes. Because the tenant
143
164
  subclasses are empty, **Compass Canada orders inherited the US links** and sent Canada
@@ -148,6 +169,16 @@ requester**. All of these comparisons are case-insensitive (`strcasecmp()`), mat
148
169
  approve as manager on his behalf. (Fixed 2026-07-23.)
149
170
 
150
171
  ## Change history
172
+ - 2026-08-03 — Recorded that the `postPut` `approvalManagerId` subquery reading
173
+ `ApprovalDecision.decidedByUserId` (not `assignedToUserId`) is **deliberately left alone** — it is
174
+ what stops a duplicate manager email on every subsequent `SalesOrders` PUT (verified: 113
175
+ manager-approval emails in 3 weeks of prod `Logs_CompassCanada.Email`, zero duplicate
176
+ `(to, subject)` pairs). Also documented the single per-recipient language resolver
177
+ `resolveRecipientLanguageCode(&$api, ?int $userId)` (interceptor-safe: catches and defaults to
178
+ `en`) that now drives **both** the template UUID and the `{orderItems}` block, and the
179
+ localize-per-recipient rule. Compass US behavior unchanged (all language branches are gated on
180
+ `clientIdentifier === 'Compass_Canada'`, and `Client_Compass` has no translations and only `en` in
181
+ `Languages`). TRUE-80543. (bala)
151
182
  - 2026-07-23 — Fixed Compass approval/status **email links** being hardcoded to the US domains
152
183
  (`compass.togasupply.com` / `compass.togacommerce.com`) in the parent classes, so Compass Canada
153
184
  orders (empty `Canada` subclass) inherited US links and Canada recipients were sent to a site
@@ -0,0 +1,5 @@
1
+ # Client: Richmond University Medical Center `rumcsi`
2
+
3
+ | Doc | Framework | Summary | Files |
4
+ |-----|-----------|---------|-------|
5
+ | [Richmond University Medical Center](profile.md) | 1.0 | Richmond University Medical Center (**RUMCSI**) is a framework **1.0** client served by **TOGa Desk** (staff/analyst side) and **TOGa View** (client portal). | |
@@ -0,0 +1,44 @@
1
+ ---
2
+ title: "Richmond University Medical Center"
3
+ framework: "1.0"
4
+ apps:
5
+ - togadesk
6
+ - togaview
7
+ - library
8
+ project: TOGa Desk
9
+ client: rumcsi
10
+ type: profile
11
+ status: active
12
+ updated: 2026-08-04
13
+ owners: ["mhammontree"]
14
+ files: []
15
+ related:
16
+ - ../../1.0/apps/togadesk/features/per-client-host-restriction.md
17
+ - ../../1.0/apps/togadesk/architecture.md
18
+ - ../../1.0/apps/togaview/architecture.md
19
+ ---
20
+
21
+ ## Summary
22
+
23
+ Richmond University Medical Center (**RUMCSI**) is a framework **1.0** client served by
24
+ **TOGa Desk** (staff/analyst side) and **TOGa View** (client portal). Its TOGa Desk
25
+ `clients.clientId` is **146**, exposed in code as the constant `CLIENT_RUMCSI` in
26
+ `desk/includes/functions.php`.
27
+
28
+ ## Platform footprint
29
+
30
+ - **Branded per-client URL.** RUMCSI is one of the clients gated by TOGa Desk's host-based
31
+ restriction: `rumcsi.togadesk.togatech.com` is already a case in `getRestrictedClient()`, which
32
+ returns `CLIENT_RUMCSI` and drives `restrictByClient()` nav/menu and ticket-datatable scoping.
33
+ See [Per-client hostname restriction](../../1.0/apps/togadesk/features/per-client-host-restriction.md)
34
+ for the full add-a-hostname checklist (cert scope, host-scoped sessions, hardcoded legacy domains).
35
+ - **TOGa View SAML.** `togaview/_/app/framework.php` has a RUMCSI-specific host branch that selects
36
+ branding (`$_SESSION['stylePath']`) and the SAML login path — an unregistered togaview hostname
37
+ falls through to default branding and skips SAML.
38
+ - **Hardcoded client-ID branching.** RUMCSI appears in TOGa Desk's hardcoded client-ID branching
39
+ (`central` dispatch, `tickets/add`) — verify the RUMCSI arm before changing shared logic.
40
+
41
+ ## Change history
42
+
43
+ - 2026-08-04 — Client profile created during the TRUE-80139 read-only review of the RUMCSI branded
44
+ TOGa Desk URL. (mhammontree)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toga-ai",
3
- "version": "1.0.501",
3
+ "version": "1.0.503",
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",