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.
- package/knowledge/1.0/apps/togadesk/INDEX.md +1 -0
- package/knowledge/1.0/apps/togadesk/features/per-client-host-restriction.md +107 -0
- package/knowledge/1.0/apps/worker/INDEX.md +1 -1
- package/knowledge/1.0/apps/worker/features/compass-partial-in-transit-delivered-emails.md +34 -2
- package/knowledge/2.0/apps/_underscore/INDEX.md +1 -0
- package/knowledge/2.0/apps/_underscore/features/cli-script-bootstrap.md +84 -0
- package/knowledge/2.0/apps/_underscore/features/email-send-pipeline.md +33 -1
- package/knowledge/2.0/apps/_underscore/features/email-template-sending.md +10 -1
- package/knowledge/2.0/apps/api2/features/language-translation-layer.md +30 -5
- package/knowledge/INDEX.md +3 -2
- package/knowledge/clients/compass-canada/INDEX.md +1 -0
- package/knowledge/clients/compass-canada/features/french-item-feature-translations.md +9 -3
- package/knowledge/clients/compass-canada/features/french-order-email-localization.md +246 -0
- package/knowledge/clients/compass-canada/profile.md +14 -3
- package/knowledge/clients/compass-usa/features/approval-decision-flow.md +35 -4
- package/knowledge/clients/rumcsi/INDEX.md +5 -0
- package/knowledge/clients/rumcsi/profile.md +44 -0
- package/package.json +1 -1
|
@@ -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-
|
|
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
|
-
|
|
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-
|
|
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 `é`
|
|
70
|
+
into `&eacute;`, which the recipient reads as a literal `é`.
|
|
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
|
+
` ` / `é` 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 `&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-
|
|
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-
|
|
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
|
|
234
|
-
(matching `ItemTranslations`) while the source
|
|
235
|
-
|
|
236
|
-
|
|
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
|
package/knowledge/INDEX.md
CHANGED
|
@@ -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) —
|
|
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)_ —
|
|
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-
|
|
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
|
|
78
|
-
two needs `COLLATE
|
|
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 `é` 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 `&eacute;`, which the recipient reads as a
|
|
112
|
+
literal `é`. (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ée par : {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 ` ` 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
|
+
` `) and the rejection template's `Refusée par : {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-
|
|
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-
|
|
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
|
|
51
|
-
|
|
52
|
-
|
|
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