toga-ai 1.0.248 → 1.0.250

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.
@@ -6,7 +6,7 @@ project: _Underscore
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-06-24
9
+ updated: 2026-06-30
10
10
  owners: ["jcardinal", "bala", "mhammontree"]
11
11
  files:
12
12
  - _underscore/Model/Client/EmailTemplate.php
@@ -14,6 +14,7 @@ files:
14
14
  - _underscore/Email.php
15
15
  related:
16
16
  - ../../worker2/features/notification-email.md
17
+ - ../../worker2/features/notification-email-template.md
17
18
  ---
18
19
 
19
20
  ## Summary
@@ -100,9 +101,40 @@ can keep using `sendEmail($api, ...)`.
100
101
  Any fire-and-forget caller now propagates that exception, which is intended (so an email is
101
102
  never silently marked as sent). Blast radius is every client, including the Compass/Quad
102
103
  `SalesOrder`/`ApprovalDecision` order-placed emails.
104
+ - **`_Email::send()` does not set PHPMailer `CharSet=UTF-8`.** Non-ASCII subjects/body (em
105
+ dash, curly quotes, accented names) **mojibake** (e.g. `—` → `—`). Scoped workaround:
106
+ use ASCII-only content. Recommended **root-cause fix**: set `$mailer->CharSet = 'UTF-8'` in
107
+ `_Email::send()` — but that affects **every** client, so it should be a separate, tested
108
+ change rather than a drive-by edit.
109
+ - **`clientIdentifier` does NOT pick the template DB.** `_Email::send()` uses
110
+ `clientIdentifier` only for CloudWatch logging/validation. The template loads from whatever
111
+ DB the `DB_CLIENT` alias points at — so register the right `Client_<x>` schema before
112
+ calling `send()`, or you'll load (or fail to find) the wrong tenant's template.
113
+
114
+ ## Local test harness (2.0 worker2 / _underscore)
115
+
116
+ To exercise `send()` locally without enqueuing onto SQS:
117
+
118
+ 1. `putenv('ENVIRONMENT=dev-<machine>')` — the framework reads `getenv('ENVIRONMENT')` and
119
+ `_Environment::initialize` throws if it's unset.
120
+ 2. Add `C:\WWW\_underscore` to `include_path`; `chdir` to the app root (`worker2`) so
121
+ `./Config` and `./_.php` resolve.
122
+ 3. `require vendor/autoload.php` **then** `_underscore/_underscore.php`.
123
+ 4. `_Database::register('Client_Rate','localhost','root','',null,null,_underscore::DB_CLIENT)`.
124
+ 5. `_Model_Client_EmailTemplate::send('Rate', $uuid, $to, [], [], ...$vars)`.
125
+
126
+ Dev config has `debug_mode=1` + `send_debug_emails_to`, so `_Email::send()` routes **all**
127
+ mail to that address. **CAVEAT:** do **not** test via `_Worker::runTask` locally — the dev
128
+ `[cloud] aws_worker_queue_url` points at the **production** SQS queue. Call `send()` (or the
129
+ worker method) in-process instead.
103
130
 
104
131
  ## Change history
105
132
 
133
+ - 2026-06-30 — Documented the **`CharSet=UTF-8` mojibake gotcha** (`_Email::send()` doesn't
134
+ set it → non-ASCII content garbles; scoped workaround = ASCII-only, root-cause = set
135
+ `$mailer->CharSet` as a separate tested change), clarified that `clientIdentifier` does not
136
+ select the template DB (`DB_CLIENT` does), and added the **local in-process test harness**.
137
+ Surfaced building the Rate service-purchase emails (TRUE-79251). (mhammontree)
106
138
  - 2026-06-24 — Added the **branded-wrapper** path: `WRAPPER_UUID` + `renderWrappedBody()` load a
107
139
  reserved per-client `EmailTemplates` row and inject `{subject}`/`{body}`. Consumed by the new
108
140
  worker2 `_Worker_Notification_Email::Send` for internal/notification mail; the wrapper row is
@@ -16,6 +16,7 @@
16
16
  | [NetSuite → TOGA Opportunity Sync (API Message Queue + worker2 webhook)](features/netsuite-opportunity-sync.md) | Outbound sync from NetSuite to TOGA for the record types the Forecast2 importer pulls (opportunities first; sales/items/etc. | worker2/Worker/Netsuite.php, worker2/Worker/Netsuite/Opportunity.php, worker2/Controller/Index.php, _underscore/Worker.php, test/@dave/NetSuite/api-message-queue/lib_amq_queue.js, test/@dave/NetSuite/api-message-queue/ue_api_msg_queue_enqueue.js, test/@dave/NetSuite/api-message-queue/ue_amq_drain.js, test/@dave/NetSuite/api-message-queue/ss_amq_drain.js, test/@dave/NetSuite/api-message-queue/DEPLOY_RUNBOOK.md, test/@dave/clickup/backfill_opportunity_numbers.php, test/@dave/clickup/probe_opportunity_fields.php, test/@dave/probe_clickup_desc_match.php, test/@dave/test_model_load_behavior.php, dbchanges2/Forecast/2026-06-25a - Add unique index on Opportunities netsuiteOpportunityInternalId.sql, worker/crons/toga2/forecast2/common_import_sales_from_netsuite.php |
17
17
  | [NetSuite → Forecast Open-Orders Sync (salesOrder webhook → OpenOrderItems)](features/netsuite-salesorder-open-orders-sync.md) | Webhook-driven, single-record port of the legacy open-orders importer (TRUE-79142). | worker2/Worker/Netsuite/SalesOrder.php, worker2/Worker/Netsuite.php, test/@dave/probe_salesorder_rest_shape.php, test/@dave/probe_open_order_lines.php, test/@dave/check_so_status.php, test/@dave/check_so_history.php, test/@dave/probe_so_rest_lines.php, test/@dave/probe_missing_oo_timing.php, test/@dave/probe_missing_oo_createdby.php, test/@dave/probe_drift_so_dates.php, test/@dave/probe_open_order_gating.php, worker/crons/toga2/forecast2/import_open_orders.php, worker/crons/toga2/forecast2/common_import_sales_from_netsuite.php |
18
18
  | [NetSuite Supporting-Record Webhook Importer (the reusable recipe)](features/netsuite-supporting-record-webhook-importer.md) | A single **repeatable recipe** for porting a legacy daily-pull NetSuite *supporting-record* importer (the lookup/dimension tables behind Forecast2 — Employees, | worker2/Worker/Netsuite/Employee.php, worker2/Worker/Netsuite/Account.php, worker2/Worker/Netsuite/Classification.php, worker2/Worker/Netsuite/Customer.php, worker2/Worker/Netsuite/Item.php, worker2/Worker/Netsuite.php, _underscore/Model/Forecast/Employee.php, _underscore/Model/Forecast/Account.php, _underscore/Model/Forecast/Classification.php, _underscore/Component/Forecast/Db/Db.php, test/@dave/test_employee_lifecycle.php, test/@dave/test_account_lifecycle.php, test/@dave/test_classification_lifecycle.php, test/@dave/NetSuite/api-message-queue/ue_api_msg_queue_enqueue.js, worker/crons/toga2/forecast2/import_supporting_records.php |
19
+ | [Background Email-Template Worker (_Worker_Notification_EmailTemplate)](features/notification-email-template.md) | `_Worker_Notification_EmailTemplate::Send(...)` dispatches a **stored, client-defined `EmailTemplates` row off-thread** as a background WorkerJob. | worker2/Worker/Notification/EmailTemplate.php, _underscore/Model/Client/EmailTemplate.php |
19
20
  | [DB-Driven Notification (Internal) Email](features/notification-email.md) | Internal/notification emails (merge-conflict alerts, ops notices — anything system-generated, not client-facing transactional mail) are sent through one worker | worker2/Worker/Notification/Email.php, _underscore/Model/Client/EmailTemplate.php, dbchanges2/Client/2026-06-23a - EmailTemplateWrapper.sql, dbchanges2/Client_True/2026-06-23a - EmailTemplateWrapper.sql |
20
21
  | [Startech Webhook Handler (worker2)](features/startech-webhook-handler.md) | Receives inbound webhook events from Startech (Easeedesk) and creates or updates the corresponding ticket in TOGA 2.0. | worker2/Worker/Startech.php |
21
22
  | [Talos (TOGa IQ) Meeting-Notes Integration & Token Auto-Refresh (consumer)](features/talos-meeting-notes-integration.md) | How a **dev tool / agent consumes Talos (TOGa IQ)** to query the team meeting-notes corpus programmatically. | .claude/skills/plan-ticket/scripts/talos.js |
@@ -6,7 +6,7 @@ project: Worker
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-06-18
9
+ updated: 2026-06-30
10
10
  owners: [jcardinal, dfranks, mhammontree]
11
11
  files:
12
12
  - worker2/Worker/
@@ -159,10 +159,21 @@ be reattempted.
159
159
  - Class must be **`abstract`** and methods **`public static`** or routing fails.
160
160
  - Webhook headers are **lowercased** by API Gateway (`X-GitHub-Event` → `x-github-event`).
161
161
  - If the action needs a client DB, register it in `initialize()` — it runs before the method.
162
+ - **Worker actions do NOT auto-register `DB_CLIENT`.** The dispatcher registers only
163
+ `DB_CLIENT_LOGS`. A worker that loads a `Client_*` model must register the concrete client
164
+ schema itself — `_Database::registerClientDatabases($clientId, $environment)`, where
165
+ `$environment = (substr(_Environment::$name, 0, 4) === 'dev-') ? 'dev' : _Environment::$name`.
166
+ Without it, every `_Model_Client_*` load fails. Proven pattern: `_Worker_Startech` (and
167
+ `_Worker_Notification_EmailTemplate`). Do this in `initialize()` or at the top of the method.
162
168
  - See [architecture.md](../architecture.md) for the always-HTTP-200 rule and the
163
169
  commit-before-SQS transaction pattern that the worker relies on.
164
170
 
165
171
  ## Change history
172
+ - 2026-06-30 — Added the **`DB_CLIENT` auto-registration gotcha**: the dispatcher registers only
173
+ `DB_CLIENT_LOGS`, so a worker loading a `Client_*` model must call
174
+ `_Database::registerClientDatabases($clientId, $environment)` itself (proven by
175
+ `_Worker_Startech` / `_Worker_Notification_EmailTemplate`). Surfaced building the Rate
176
+ service-purchase email worker (TRUE-79251). (mhammontree)
166
177
  - 2026-06-24 — Clarified that the dispatcher spreads the string-keyed `parameters` as PHP **named
167
178
  arguments** (`$class::$method(...$parameters)`), so parameter names are the queue contract — do
168
179
  not collapse a multi-param action into a single `array` arg. (mhammontree)
@@ -0,0 +1,105 @@
1
+ ---
2
+ title: "Background Email-Template Worker (_Worker_Notification_EmailTemplate)"
3
+ framework: "2.0"
4
+ repo: worker2
5
+ project: Worker
6
+ client: shared
7
+ type: feature
8
+ status: active
9
+ updated: 2026-06-30
10
+ owners: ["mhammontree"]
11
+ files:
12
+ - worker2/Worker/Notification/EmailTemplate.php
13
+ - _underscore/Model/Client/EmailTemplate.php
14
+ related:
15
+ - ./notification-email.md
16
+ - ./creating-worker-actions.md
17
+ - ../../_underscore/features/email-template-sending.md
18
+ ---
19
+
20
+ ## Summary
21
+
22
+ `_Worker_Notification_EmailTemplate::Send(...)` dispatches a **stored, client-defined
23
+ `EmailTemplates` row off-thread** as a background WorkerJob. It is the generic/reusable
24
+ companion to `_Worker_Notification_Email::Send` (see
25
+ [`notification-email.md`](./notification-email.md)): where that one wraps an ad-hoc
26
+ subject/body in the client's branded shell for *internal* notices, this one dispatches a
27
+ **complete pre-authored transactional template by UUID** (e.g. a client-facing purchase
28
+ confirmation). It is **not** specific to any client — any client's stored `EmailTemplates`
29
+ row can be sent through it.
30
+
31
+ Decoupling the send from whatever triggers it (e.g. an entitlement save) means an SMTP /
32
+ template failure becomes a **visible failed `WorkerJobs` row** rather than blocking — or
33
+ silently riding inside — the originating transaction.
34
+
35
+ ## Key files / entry points
36
+
37
+ - `worker2/Worker/Notification/EmailTemplate.php` —
38
+ `abstract class _Worker_Notification_EmailTemplate`. One method:
39
+ `public static Send(string $clientIdentifier, string $uuid, $to, $cc, $bcc, ...$args): string`.
40
+ - Resolves the numeric `clientId` from `$clientIdentifier` via a Core query.
41
+ - Registers the client DB with
42
+ `_Database::registerClientDatabases($clientId, $environment)` (see the DB-registration
43
+ note below — worker actions do **not** get the client DB for free).
44
+ - Calls `_Model_Client_EmailTemplate::send($clientIdentifier, $uuid, $to, $cc, $bcc, ...$args)`.
45
+ - The variadic `...$args` forwards the template's `{placeholder}` values straight through
46
+ the model's named-argument dispatch contract — so the queued `parameters` keys *are* the
47
+ template variables.
48
+ - `_underscore/Model/Client/EmailTemplate.php` — `send()` does the actual load / placeholder
49
+ substitution / `_Email` send. Documented in
50
+ [`email-template-sending.md`](../../_underscore/features/email-template-sending.md).
51
+
52
+ ## How it works
53
+
54
+ 1. **Resolve the client.** Query Core to turn `clientIdentifier` (e.g. `'Rate'`) into the
55
+ numeric `clientId`.
56
+ 2. **Register the client DB.** `_Database::registerClientDatabases($clientId, $environment)`,
57
+ where
58
+ `$environment = (substr(_Environment::$name, 0, 4) === 'dev-') ? 'dev' : _Environment::$name`.
59
+ The worker dispatcher only registers `DB_CLIENT_LOGS` for you — the concrete `Client_<x>`
60
+ schema that `_Model_Client_EmailTemplate` reads from is **your** responsibility (proven
61
+ pattern: `_Worker_Startech`). See
62
+ [`creating-worker-actions.md`](./creating-worker-actions.md).
63
+ 3. **Dispatch the template.** `_Model_Client_EmailTemplate::send()` loads the `EmailTemplates`
64
+ row by UUID, substitutes `{placeholder}` vars from `...$args`, and sends via `_Email`.
65
+
66
+ ### Enqueuing it
67
+
68
+ `_Worker::runTask('Notification/EmailTemplate/Send', ['clientIdentifier'=>'Rate',
69
+ 'uuid'=>'<template-uuid>', 'to'=>'…', 'cc'=>[], 'bcc'=>[], '<var1>'=>…, '<var2>'=>…])`
70
+ inserts a WorkerJob. The dispatcher spreads the string-keyed `parameters` as PHP **named
71
+ arguments**, so each `parameters` key binds to a method parameter — and the leftover keys
72
+ flow into `...$args` as the template variables.
73
+
74
+ ## Gotchas / known issues
75
+
76
+ - **Worker actions do not auto-register `DB_CLIENT`.** The dispatcher registers only
77
+ `DB_CLIENT_LOGS`; a worker that loads a `Client_*` model must call
78
+ `_Database::registerClientDatabases($clientId, $environment)` itself or the
79
+ `_Model_Client_*` load fails. (Captured as a standalone gotcha in
80
+ [`creating-worker-actions.md`](./creating-worker-actions.md).)
81
+ - **Don't test this via `_Worker::runTask` locally.** The dev `[cloud]
82
+ aws_worker_queue_url` points at the **production** SQS queue, so a local `runTask` enqueues
83
+ onto prod. Call `Send()` (or `_Model_Client_EmailTemplate::send()`) in-process instead —
84
+ see the local harness in
85
+ [`email-template-sending.md`](../../_underscore/features/email-template-sending.md#local-test-harness-2-0-worker2--underscore).
86
+
87
+ ## Change history
88
+
89
+ - 2026-06-30 — Built the generic background email-template worker
90
+ `_Worker_Notification_EmailTemplate::Send` (TRUE-79251): resolves the client, registers the
91
+ client DB via `registerClientDatabases`, and dispatches a stored `EmailTemplates` row by
92
+ UUID through `_Model_Client_EmailTemplate::send`, forwarding `{placeholder}` values via
93
+ `...$args`. Sits beside `_Worker_Notification_Email::Send` (internal/branded-wrapper path).
94
+ First consumer: Rate service-purchase confirmation emails — see
95
+ [`clients/rate/features/service-purchase-emails.md`](../../../clients/rate/features/service-purchase-emails.md).
96
+ (mhammontree)
97
+
98
+ ## Related docs
99
+
100
+ - [`notification-email.md`](./notification-email.md) — the internal/branded-wrapper sibling
101
+ (`_Worker_Notification_Email::Send`).
102
+ - [`creating-worker-actions.md`](./creating-worker-actions.md) — worker dispatch + the
103
+ client-DB registration gotcha.
104
+ - [`2.0/apps/_underscore/features/email-template-sending.md`](../../_underscore/features/email-template-sending.md)
105
+ — the `_Model_Client_EmailTemplate` model this worker dispatches.
@@ -16,6 +16,7 @@ files:
16
16
  related:
17
17
  - ../../_underscore/features/email-template-sending.md
18
18
  - ./creating-worker-actions.md
19
+ - ./notification-email-template.md
19
20
  ---
20
21
 
21
22
  ## Summary
@@ -23,7 +24,10 @@ related:
23
24
  Internal/notification emails (merge-conflict alerts, ops notices — anything system-generated,
24
25
  not client-facing transactional mail) are sent through one worker action,
25
26
  `_Worker_Notification_Email::Send(...)`, which wraps a plain subject/body in the client's
26
- **DB-stored branded shell** before sending via `_Email`. The branded shell used to be a
27
+ **DB-stored branded shell** before sending. (For sending a **complete pre-authored stored
28
+ template by UUID** off-thread — e.g. client-facing transactional mail — use the sibling
29
+ worker [`_Worker_Notification_EmailTemplate::Send`](./notification-email-template.md)
30
+ instead.) The branded shell used to be a
27
31
  hardcoded PHP class (`_Email_Template`, now **deleted**); it now lives in the client DB as a
28
32
  reserved `EmailTemplates` row so the branding can change without a code deploy. This is a
29
33
  **shared/core 2.0 mechanism** — every client inherits the wrapper row from the dbchanges2
@@ -16,8 +16,8 @@ _Auto-generated by `knowledge.js index`. Do not hand-edit._
16
16
 
17
17
  ## 2.0 framework
18
18
 
19
- - **_underscore** (_Underscore) _(framework core)_ — 19 doc(s) → [2.0/apps/_underscore/INDEX.md](2.0/apps/_underscore/INDEX.md)
20
- - **worker2** (Worker) — 23 doc(s) → [2.0/apps/worker2/INDEX.md](2.0/apps/worker2/INDEX.md)
19
+ - **_underscore** (_Underscore) _(framework core)_ — 20 doc(s) → [2.0/apps/_underscore/INDEX.md](2.0/apps/_underscore/INDEX.md)
20
+ - **worker2** (Worker) — 24 doc(s) → [2.0/apps/worker2/INDEX.md](2.0/apps/worker2/INDEX.md)
21
21
  - **api2** (API) — 7 doc(s) → [2.0/apps/api2/INDEX.md](2.0/apps/api2/INDEX.md)
22
22
  - **dbchanges2** (Database Changes) _(framework core)_ — 3 doc(s) → [2.0/apps/dbchanges2/INDEX.md](2.0/apps/dbchanges2/INDEX.md)
23
23
  - **toga2-supply** (TOGa Supply) — 3 doc(s) → [2.0/apps/toga2-supply/INDEX.md](2.0/apps/toga2-supply/INDEX.md)
@@ -7,4 +7,5 @@
7
7
  | [Rate SalesOrder → NetSuite CashSale Export (postPost)](features/netsuite-cashsale-export.md) | 2.0 | Rate sells home-warranty / home-tech-support products. | _underscore/Model/Rate/SalesOrder.php, _underscore/Model/Rate/Item.php |
8
8
  | [Rate SAML SSO](features/saml-sso.md) | 2.0 | Rate uses Azure AD as its IdP (`login.rate.com`). | _underscore/Model/Rate/ClientAuthentication.php, saml/Controller/Index.php, toga2-view/src/hooks/useAuthenticationFlow.ts |
9
9
  | [Service Card Entitlement Display](features/service-card-entitlements.md) | 2.0 | Rate's home and services pages display one service card per purchased entitlement. | src/components/ServiceCard/ServiceCard.tsx, src/components/ServiceCard/index.ts, src/hooks/useBundleServices.ts, src/pages/Home/api/homeApi.ts, src/pages/Home/view/HomePage.tsx, src/pages/Home/viewModels/useHomePageViewModel.ts, src/pages/Services/view/ServicesPage.tsx, src/pages/Services/viewModels/useServicePageViewModel.ts |
10
+ | [Rate Service-Purchase Confirmation Emails (Tech / Warranty)](features/service-purchase-emails.md) | 2.0 | When a Rate customer purchases a service, a confirmation email is sent. | _underscore/Model/Rate/Entitlement.php, worker2/Worker/Notification/EmailTemplate.php, dbchanges2/Client_Rate/2026-06-30a - Rate purchase email templates.sql |
10
11
  | [Rate](profile.md) | 2.0 | Rate is a mortgage/lending client. | |
@@ -0,0 +1,137 @@
1
+ ---
2
+ title: "Rate Service-Purchase Confirmation Emails (Tech / Warranty)"
3
+ framework: "2.0"
4
+ repo: _underscore
5
+ project: _Underscore
6
+ client: rate
7
+ type: client-feature
8
+ status: active
9
+ updated: 2026-06-30
10
+ owners: [mhammontree]
11
+ files:
12
+ - _underscore/Model/Rate/Entitlement.php
13
+ - worker2/Worker/Notification/EmailTemplate.php
14
+ - dbchanges2/Client_Rate/2026-06-30a - Rate purchase email templates.sql
15
+ related:
16
+ - clients/rate/profile.md
17
+ - clients/rate/features/aig-contract-creation.md
18
+ - ../../../2.0/apps/worker2/features/notification-email-template.md
19
+ - ../../../2.0/apps/_underscore/features/email-template-sending.md
20
+ ---
21
+
22
+ ## Summary
23
+
24
+ When a Rate customer purchases a service, a confirmation email is sent. There are two
25
+ products, each with its own template:
26
+
27
+ - **Whole Home Tech Services** ("1 Year Unlimited Tech Support") — the *tech* email.
28
+ - **Whole Home Warranty** — the *warranty* email. (AIG is the warranty provider behind this
29
+ product; not the separate `aig` Staples client.)
30
+
31
+ The send is triggered from the existing Rate entitlement interceptor
32
+ (`_Model_Rate_Entitlement::postPost`) and dispatched **off-thread** through the generic
33
+ [`_Worker_Notification_EmailTemplate::Send`](../../../2.0/apps/worker2/features/notification-email-template.md)
34
+ worker. The email content lives in DB-stored `Client_Rate.EmailTemplates` rows so it can be
35
+ edited without a deploy.
36
+
37
+ ## How it works
38
+
39
+ 1. **Trigger.** `postPost` (`_underscore/Model/Rate/Entitlement.php`) runs after an
40
+ entitlement save. It calls `sendPurchaseEmail($payload)` **after** the existing
41
+ AIG-contract block, via helpers:
42
+ - `resolvePurchaseProduct()` — warranty vs. tech detection.
43
+ - `buildPurchaseEmailParameters()` — assemble the template `{placeholder}` values.
44
+ - `sendPurchaseEmail()` — enqueue
45
+ `_Worker::runTask('Notification/EmailTemplate/Send', [...])`, wrapped in `try/catch` so a
46
+ send failure **never blocks the save**.
47
+ 2. **Fires on purchase, independent of the AIG contract outcome.** Tech has no AIG contract
48
+ at all; the warranty email still sends even if the AIG contract call failed (that path is
49
+ monitored separately by `_Worker_Monitors_RateEntitlement` — see
50
+ [`aig-contract-creation.md`](./aig-contract-creation.md)).
51
+ 3. **Dispatch + send.** The worker resolves the `Rate` client, registers `Client_Rate`, and
52
+ sends the stored template by UUID with the assembled variables.
53
+
54
+ ### Product detection (the entitlement / product model)
55
+
56
+ In `Client_Rate`: `Entitlements.saleItemId → Items.id`. **There is no product-type column** —
57
+ the discriminator is **`Items.title`**. Detection mirrors toga2-view's `detectServiceType`:
58
+
59
+ - title contains `"warranty"` → **warranty**;
60
+ - else `"tech"` / `"support"` → **tech**.
61
+
62
+ Live titles: `"1 Year Unlimited Tech Support - Annual/Monthly"` and
63
+ `"Whole Home Warranty - Monthly"`. The entitlement's client identifier is `'Rate'`.
64
+
65
+ ### Constants (in `_Model_Rate_Entitlement`)
66
+
67
+ - `EMAIL_TEMPLATE_TECH_PURCHASE` / `EMAIL_TEMPLATE_WARRANTY_PURCHASE` — the template UUIDs.
68
+ **Kept in sync with the dbchanges2 rows** (tech `2c4f8a1e-…`, warranty `5d9e3f72-…`).
69
+ - `CLIENT_IDENTIFIER_RATE = 'Rate'`, `PORTAL_URL_TECH` / `PORTAL_URL_WARRANTY`,
70
+ `SUPPORT_PHONE = '1-206-260-6187'`.
71
+
72
+ ## Templates (dbchanges2)
73
+
74
+ `dbchanges2/Client_Rate/2026-06-30a - Rate purchase email templates.sql` (new `Client_Rate`
75
+ migration folder) inserts two transactional HTML emails via
76
+ `INSERT … ON DUPLICATE KEY UPDATE` (fixed UUIDs). `isActive` is **intentionally excluded**
77
+ from the `ON DUPLICATE UPDATE` list so a re-run never silently re-activates a row that was
78
+ turned off. Responsive 600px table-based HTML faithful to the Figma.
79
+
80
+ - **Warranty template** keeps the "Renewal Notice" / "Renewal Summary" verbiage per PM/design
81
+ but **drops the "Renewed" row** — a first purchase has no prior renewal. **Next Charge** is
82
+ the date shown.
83
+
84
+ ## Brand assets (toga-public S3)
85
+
86
+ 2.0 transactional-email images are hosted on the public bucket **`toga-public`** (us-west-2),
87
+ same convention as the Compass emails (`compass/Email/…`). Rate assets live under
88
+ `rate/Email/`: `rate-header-logo.png` (white "Rate" + red house composited onto header
89
+ `#2c2926`), `toga-technology-footer.png` (white TOGA wordmark composited onto footer
90
+ `#21292d`), `check-circle.png` (green `#226257` circle + white check), `ProximaNovaRegular.woff`.
91
+
92
+ **Recipe for a transparent/SVG brand asset on a dark email region:** rasterize/flatten the
93
+ asset onto the **exact region background color** so it renders identically across clients
94
+ (Figma SVG → rasterize via Node `sharp` with `flatten: <bgcolor>`).
95
+
96
+ ### Figma fonts (TOGa View 2.0 email designs)
97
+
98
+ - **Figtree** — content area (headline/body/summary). Free Google Font.
99
+ - **Proxima Nova** — button + footer "Contact us" / copyright. toga2-view self-hosts
100
+ `ProximaNovaRegular.woff` at `toga2-view/src/fonts/`.
101
+ - **Axiforma** — "Powered By" + the "TOGA TECHNOLOGY" wordmark. **Not in repo, not free** — so
102
+ the footer wordmark is rendered as the **logo image** (Axiforma baked in) rather than live
103
+ text.
104
+
105
+ Email web-font reality: Gmail/Outlook strip web fonts and fall back; Apple Mail/iOS honor
106
+ `@font-face`/`<link>`. Design for the fallback.
107
+
108
+ ## Gotchas / known issues
109
+
110
+ - **`border-radius` on a `<td>` renders SQUARE in many clients (Outlook).** Use an `<img>` for
111
+ circular badges/icons (e.g. the green circle-check) instead of a CSS-rounded table cell.
112
+ - **ASCII-only subjects (mojibake workaround).** `_Email::send()` does not set PHPMailer
113
+ `CharSet=UTF-8`, so non-ASCII subjects/body (em dash, curly quotes, accents) mojibake. This
114
+ workflow uses ASCII-only subjects; the proper root-cause fix is tracked on
115
+ [`email-template-sending.md`](../../../2.0/apps/_underscore/features/email-template-sending.md).
116
+ - **Worker DB registration** — see
117
+ [`notification-email-template.md`](../../../2.0/apps/worker2/features/notification-email-template.md):
118
+ worker actions do not auto-register `DB_CLIENT`.
119
+
120
+ ## Go-live TODOs (TRUE-79251, flagged in-code as `TODO TRUE-79251`)
121
+
122
+ Not yet durable knowledge — open items before go-live:
123
+
124
+ - AIG Terms & Conditions URL — `{termsUrl}` currently `'#'`.
125
+ - From-address / name — currently `donotreply@togatech.com` / "Rate".
126
+ - Confirm the **Next Charge** source field — currently `dateCoverageEnd`.
127
+
128
+ (Support phone is resolved: `1-206-260-6187`.)
129
+
130
+ ## Change history
131
+
132
+ - 2026-06-30 — Built the Rate service-purchase confirmation email workflow (TRUE-79251):
133
+ `postPost` trigger + helpers in `_Model_Rate_Entitlement`, two DB-stored `Client_Rate`
134
+ templates (tech / warranty) dispatched off-thread via
135
+ `_Worker_Notification_EmailTemplate::Send`, brand assets on `toga-public/rate/Email/`.
136
+ Fires on purchase independent of the AIG-contract outcome. Tested locally with a real send
137
+ to the dev team. (mhammontree)
@@ -12,7 +12,7 @@ project: SAML SSO Gateway
12
12
  client: rate
13
13
  type: profile
14
14
  status: active
15
- updated: 2026-06-29
15
+ updated: 2026-06-30
16
16
  owners: ["rgirish", "bala", "mhammontree"]
17
17
  files: []
18
18
  related:
@@ -21,6 +21,7 @@ related:
21
21
  - clients/rate/features/netsuite-cashsale-export.md
22
22
  - clients/rate/features/service-card-entitlements.md
23
23
  - clients/rate/features/aig-contract-creation.md
24
+ - clients/rate/features/service-purchase-emails.md
24
25
  ---
25
26
 
26
27
  ## Summary
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toga-ai",
3
- "version": "1.0.248",
3
+ "version": "1.0.250",
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",
@@ -0,0 +1,191 @@
1
+ ---
2
+ name: plan-ticket
3
+ description: Build an implementation plan for a ClickUp ticket end-to-end. Invoke as `/plan-ticket <TICKET-ID>` (e.g. `/plan-ticket TRUE-79868`). Retrieves the full ticket from ClickUp, interrogates Talos (meeting-notes AI) and the local codebase for context, primes team framework knowledge by self-answering /kickoff from that gathered context, synthesizes a phased plan, saves it to test/@dave/approach/<TICKET>.md, shows a preview and iterates until you approve, then pushes the approved plan as formatted rich text to the ticket's Pseudocode field. Trigger on "/plan-ticket", "plan this ticket", "build a plan for <ticket>".
4
+ ---
5
+
6
+ # plan-ticket — ticket → researched plan → ClickUp Pseudocode field
7
+
8
+ Given a ClickUp ticket id, produce a grounded implementation plan and write it both to
9
+ `test/@dave/approach/<TICKET>.md` and to the ticket's **📝 Pseudocode** custom field
10
+ (rendered, not raw markdown).
11
+
12
+ Helper scripts live in `scripts/` next to this file. They are run with `node`. All Talos
13
+ auth is automatic — see **Token handling** below; the developer should never have to paste a
14
+ token unless the 30-day refresh token has expired.
15
+
16
+ Environment used: `CLICKUP_API_KEY`, `CLICKUP_TEAM_ID` (already in the session env). Talos
17
+ tokens live in `~/.talos/credentials.json` (NOT in the repo).
18
+
19
+ ---
20
+
21
+ ## Step 0 — Resolve the ticket id
22
+
23
+ The id is the argument after `/plan-ticket` (e.g. `TRUE-79868`). If none was given, ask for it.
24
+
25
+ ## Step 1 — Retrieve the full ticket from ClickUp
26
+
27
+ ```bash
28
+ node ".claude/skills/plan-ticket/scripts/clickup.js" get <TICKET>
29
+ ```
30
+
31
+ This prints JSON: `{name, status, description, pseudocodeFieldId, fields:{...non-empty custom fields...}}`.
32
+ Read **all** of it — name, user story / description, and any populated custom fields (Application,
33
+ Requirements, Epic, Acceptance criteria, etc.). These details drive both the Talos query and the
34
+ codebase investigation. The **Application / Client** fields here also feed the self-answered
35
+ kickoff in Step 4. Note `pseudocodeFieldId` for the push in Step 7.
36
+
37
+ ## Step 2 — Investigate the local codebase FIRST (before Talos)
38
+
39
+ **Do the code recon before querying Talos.** Investigating first surfaces the *specific*
40
+ unknowns — what already exists vs. what's build-from-scratch, FK topology, schema quirks,
41
+ sibling/precedent plans in `test/@dave/approach/`, the exact reference handlers the ticket
42
+ mirrors — which lets Step 3 ask Talos sharp, targeted questions ("a meeting discuss the DELETE
43
+ policy for X, given Sales/Opportunities hold RESTRICT FKs?") instead of generic ones. A generic
44
+ Talos query usually returns "no specific notes"; a code-informed one is far more likely to hit.
45
+
46
+ Ground everything in the real code. Prefer the TOGA `planner` agent for non-trivial tickets (it
47
+ reads deep docs in its own context and returns a phased plan); for small tickets, read the
48
+ specific files directly. Open the actual classes, schema (`dbchanges2` / the live core2 reader),
49
+ and reference handlers the ticket touches — do not guess paths. **Come out of this step with a
50
+ concrete list of open questions / decisions to put to Talos.**
51
+
52
+ ## Step 3 — Interrogate Talos (meeting-notes AI), informed by the code findings
53
+
54
+ Compose the question from the ticket details **plus the specific unknowns/decisions Step 2
55
+ surfaced** — name the concrete choices you need ruled on (delete policy, scope boundaries,
56
+ owners, deadlines) and the architecture you found, so Talos can confirm/deny against real
57
+ meeting notes. Then:
58
+
59
+ ```bash
60
+ node ".claude/skills/plan-ticket/scripts/talos.js" query "<your code-informed question>"
61
+ ```
62
+
63
+ The helper auto-refreshes the access token, opens a thread against DevCore, auto-accepts the
64
+ agent's plan-approval interrupt, and prints the assistant's answer. Capture what it finds
65
+ (with the meeting/date citations it gives) — and note explicitly when it finds nothing specific.
66
+
67
+ > Token handling: `talos.js` reads `~/.talos/credentials.json`, and if the cached access token
68
+ > is missing/expiring it calls `POST {apiHost}/v2/auth/refresh` with the stored refresh token to
69
+ > mint a new one (transactionId must be unique per call — the helper generates it). If the
70
+ > **refresh** token itself has expired (~30 days), the helper prints clear instructions: the
71
+ > developer logs in at `talos.togaiq.com`, grabs the `accessToken` + `refreshToken` from the
72
+ > browser (DevTools → Application/Local Storage, or any `api.togaiq.com` request's
73
+ > `Authorization` header), and runs `node scripts/talos.js set-tokens <access> <refresh>` once.
74
+ > Never echo tokens into chat, memory, the KB, or any committed file.
75
+
76
+ ## Step 4 — Prime framework context via kickoff (self-answered from Steps 1–3)
77
+
78
+ Now that you hold the ticket (Step 1), the code recon (Step 2), and the Talos findings
79
+ (Step 3), invoke **`/kickoff`** to prime the team knowledge base — but **answer its interview
80
+ yourself from what you just gathered** so it never stops to ask the developer. Steps 1–2
81
+ already tell you everything kickoff's Step 2 interview needs:
82
+
83
+ - **Framework (1.0 / 2.0 / both)** — from the repos the code sweep landed in (`worker/`,
84
+ `library/`, `toga/`, etc. → 1.0; `worker2/`, `api2/`, `_underscore/`, the React SPAs → 2.0)
85
+ plus the ticket's **Application** field.
86
+ - **Layer (front / back / hybrid)** — from the repo types touched (React SPA → front-end;
87
+ PHP API/worker → back-end; both → hybrid).
88
+ - **Repos** — the in-scope repos the Step 2 sweep identified.
89
+ - **Client** — from the ticket's Application/Client field or ClickUp space; use
90
+ **"shared / internal"** when the ticket isn't client-specific.
91
+
92
+ Invoke kickoff with these pre-filled as the trailing argument so its interview is satisfied and
93
+ it goes straight to preflight + priming, e.g.:
94
+
95
+ ```
96
+ /kickoff <framework> <layer>, repos: <repos>, client: <client> — <ticket title>
97
+ ```
98
+
99
+ **Only stop to ask the developer if a gate answer is genuinely ambiguous** after all three
100
+ inputs (e.g. the code spans both frameworks and neither ticket nor Talos says which is in
101
+ scope). Otherwise let kickoff run unattended — it checks for harness updates, resolves the
102
+ load-set, and returns the `context-primer` briefing.
103
+
104
+ **Carry the primer briefing into synthesis.** The framework rules, gotchas, and client
105
+ variations it surfaces directly inform the plan — fold them into the **Architecture decision**
106
+ and **Risks/gotchas** sections in Step 5. This is the payoff of priming before synthesizing.
107
+
108
+ > Ordering note: the code sweep (Step 2) intentionally runs *before* kickoff. Kickoff's gate
109
+ > hook blocks reads until it primes, and doing the recon first is exactly what lets you
110
+ > self-answer the interview here. Once kickoff's preflight runs, the gate releases and Step 5
111
+ > synthesis can read freely.
112
+
113
+ ## Step 5 — Synthesize the plan and save it
114
+
115
+ Write a phased plan to `test/@dave/approach/<TICKET>.md`. Structure: a **`# <TICKET> — <title>`
116
+ heading**, then a **one-line bold header** (`**Repos:** … · **Framework:** … · **Client:** …`,
117
+ plus a `· **Sibling/precedent:** <TICKET>` when one exists), then Summary · Meeting-notes
118
+ context (Talos, with citations + an explicit "no notes found" note where true) · What already
119
+ exists · Architecture decision · Phases (each: title, exact file paths, pseudocode) · Testing ·
120
+ Risks/gotchas · Rollout/verification · Owners/open-questions to confirm · Key files. Run any
121
+ relevant TOGA reviewers (php-reviewer/sql-reviewer) if code-shaped decisions warrant it.
122
+
123
+ > **ANY SQL SHOWN IN THE PLAN'S PSEUDOCODE MUST ALREADY FOLLOW THE TEAM SQL FORMATTING
124
+ > CONVENTIONS — NO ONE-LINE QUERIES, EVEN IN PSEUDOCODE.** WRITE EVERY QUERY MULTI-LINE:
125
+ > KEYWORDS UPPERCASE, EACH CLAUSE ON ITS OWN LINE, TABLE/COLUMNS INDENTED ON THEIR OWN LINES,
126
+ > NO `SELECT *`. THE PLAN IS THE TEMPLATE THE WORK-TICKET STEP COPIES FROM, SO A SLOPPY
127
+ > ONE-LINER HERE BECOMES A STANDARDS VIOLATION IN THE SHIPPED CODE.
128
+
129
+ **No point-in-time status.** Do not record what's currently deployed / Released / live (e.g. "the enqueuer is Released only on X today", "PR #123 is merged") — it's stale the moment it's written. Describe durable mechanisms, decisions, and required steps instead. (Matches the team rule against deployment status in docs.)
130
+
131
+ **Length limit: keep the plan to ≤ 150 lines.** Be terse and high-signal — favor tight
132
+ pseudocode and bullet lists over prose, fold related points into one line, and cut anything that
133
+ doesn't change what the implementer does. If the content can't fit, trim the lowest-value detail
134
+ (verbose rationale, repeated caveats) rather than dropping a section. After writing, verify with
135
+ `wc -l test/@dave/approach/<TICKET>.md` and tighten if it's over.
136
+
137
+ ## Step 6 — Preview & approval loop (gate — do NOT push until approved)
138
+
139
+ **The plan does NOT go to ClickUp automatically.** After writing the `.md`, present it to the
140
+ developer for review and **wait for explicit approval** before Step 7.
141
+
142
+ 1. Show a **preview**: the full plan content (or, if long, the Summary + Architecture decision +
143
+ Phase titles + Owners/open-questions, with the rest available on request) so the developer can
144
+ judge it in-chat. Note the line count (must be ≤150).
145
+ 2. Prompt clearly: *"Approve this plan for the TRUE-XXXXX Pseudocode field, or tell me what to
146
+ change?"*
147
+ 3. **Iterate**: incorporate the developer's comments — edit `test/@dave/approach/<TICKET>.md`,
148
+ re-investigate (codebase or Talos) if a change needs grounding, re-check ≤150 lines, and show
149
+ the updated preview. Go back and forth until the developer explicitly approves.
150
+ 4. Only on explicit approval ("approved", "looks good", "ship it", etc.) proceed to Step 7. Do
151
+ **not** push on ambiguous replies — ask.
152
+
153
+ ## Step 7 — Push the approved plan to the Pseudocode field (rendered)
154
+
155
+ Only after Step 6 approval. ClickUp custom text fields render formatting from a **Quill Delta**
156
+ in `value_richtext`, not markdown in `value`. The helper converts the markdown and pushes both:
157
+
158
+ ```bash
159
+ node ".claude/skills/plan-ticket/scripts/clickup.js" push <TICKET> "test/@dave/approach/<TICKET>.md"
160
+ ```
161
+
162
+ It resolves the Pseudocode field id by name, converts markdown → Delta, and POSTs
163
+ `{value:<plain>, value_richtext:<Delta JSON string>}`. Confirm it returns 200.
164
+
165
+ **Overwrite guard.** `push` **refuses** (exit 2) if the Pseudocode field already has content, to
166
+ avoid clobbering a manual/teammate edit — it prints the existing content. If that happens: show
167
+ the existing content to the developer, confirm it's safe to replace, and only then re-run with
168
+ `--force`:
169
+ ```bash
170
+ node ".claude/skills/plan-ticket/scripts/clickup.js" push <TICKET> "test/@dave/approach/<TICKET>.md" --force
171
+ ```
172
+ (A prior plan *we* wrote and the developer just re-approved is fine to replace — but still
173
+ surface it so the developer knows what's being overwritten.)
174
+
175
+ ## Step 8 — Report
176
+
177
+ Summarize: ticket title, what Talos surfaced (or didn't), the plan's key decisions + any
178
+ owners/open questions to confirm, and the two outputs (the `.md` path + the Pseudocode field).
179
+ Offer `/capture` if a durable KB-worthy finding emerged.
180
+
181
+ ---
182
+
183
+ ## Notes & reuse
184
+
185
+ - The markdown→Quill-Delta conversion rules and the `value_richtext` mechanism are documented in
186
+ the team KB (`2.0/apps/worker2/features/clickup-richtext-api.md`) and personal memory
187
+ (`reference_clickup_richtext_field_delta`).
188
+ - Talos is a LangGraph Agent Protocol server (`api.togaiq.com`); DevCore is the dev-team assistant
189
+ wired to the meeting-notes knowledge base. Other assistants (One/Sales/HR) are in
190
+ `credentials.json` under `assistants` if a different one is ever needed.
191
+ - `talos.js query` accepts an optional 2nd arg = assistant key (default `devcore`).