toga-ai 1.0.421 → 1.0.423
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/2.0/apps/_underscore/INDEX.md +2 -2
- package/knowledge/2.0/apps/_underscore/architecture.md +23 -15
- package/knowledge/2.0/apps/_underscore/features/acl-permission-chain.md +38 -2
- package/knowledge/2.0/apps/_underscore/features/per-client-database-connections.md +19 -1
- package/knowledge/2.0/apps/api2/architecture.md +25 -3
- package/knowledge/2.0/apps/api2/features/v2-api-error-codes.md +12 -4
- package/knowledge/2.0/apps/toga2-view/INDEX.md +2 -0
- package/knowledge/2.0/apps/toga2-view/features/query-string-builder.md +64 -0
- package/knowledge/2.0/apps/toga2-view/features/zip-validation-contact-phone.md +49 -0
- package/knowledge/INDEX.md +1 -1
- package/knowledge/clients/rate/INDEX.md +2 -2
- package/knowledge/clients/rate/features/service-card-entitlements.md +48 -12
- package/knowledge/clients/rate/features/whole-home-warranty-purchase-guard.md +66 -1
- package/package.json +1 -1
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|-----|---------|-------|
|
|
5
5
|
| [Proposed — git-sourced base+overlay JSON authoring for the Surface layer](architecture/surface-authoring-proposal.md) | A **proposal / handoff recommendation** (not implemented) that the Surface layer's *authoring* model move off hand-authored SQL against the `SurfaceOverrides` E | _underscore/Model/Core/Surface.php, _underscore/Model/Client/SurfaceOverride.php |
|
|
6
6
|
| [_underscore Framework Architecture](architecture.md) | `_underscore` is the shared PHP backend framework for **all 2.0 applications**. | _underscore/_underscore.php, _underscore/Loader.php, _underscore/Framework.php, _underscore/Model.php, _underscore/Database.php, _underscore/Query.php, _underscore/Route.php, _underscore/Component.php |
|
|
7
|
-
| [ACL Permission Chain (Record & Field Authorization)](features/acl-permission-chain.md) | Authorization in the 2.0 API is **metadata-driven**: whether a role may Create/Read/Update/Delete a record is decided by rows across **four linked tables**, not | api2/Component/Api/V2/V2.php, _underscore/Model/Core/Page.php, _underscore/Model/Client/TrackingNumber.php, dbchanges2/Client/2026-06-03- BLANK_CLIENT_DATABASE.sql, dbchanges2/Client/2026-06-23b - ItemTranslationsAcl.sql |
|
|
7
|
+
| [ACL Permission Chain (Record & Field Authorization)](features/acl-permission-chain.md) | Authorization in the 2.0 API is **metadata-driven**: whether a role may Create/Read/Update/Delete a record is decided by rows across **four linked tables**, not | api2/Component/Api/V2/V2.php, _underscore/Model/Core/Page.php, _underscore/Model/Client/TrackingNumber.php, dbchanges2/Client/2026-06-03- BLANK_CLIENT_DATABASE.sql, dbchanges2/Client/2026-06-23b - ItemTranslationsAcl.sql, dbchanges2/Client/2026-07-02c - TrackingNumberNeedsReturnLabelFieldPermission.sql |
|
|
8
8
|
| [Address Validation (carrier waterfall + validateAddress scripted endpoint)](features/address-validation.md) | `_Model_Client_Address::validateAddress` verifies a US address against a **carrier waterfall (USPS → FedEx → UPS)** and returns a single canonical, carrier-norm | _underscore/Model/Client/Address.php |
|
|
9
9
|
| [_ApiRequest ENCODE__JSON now sends Content-Type: application/json](features/apirequest-json-content-type.md) | `_ApiRequest::execute()`'s `ENCODE__JSON` branch json-encoded the request body but never set a `Content-Type` header. | _underscore/ApiRequest.php |
|
|
10
10
|
| [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 |
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
| [_Model magic-field access (__get without __isset)](features/model-magic-field-access.md) | `_Model` exposes DB columns as "magic" properties via `__get()`, but it defines **no** `__isset()`. | _underscore/Model/Core/Model.php |
|
|
22
22
|
| [_Model::save() vs raw _Query — no atomic conditional update](features/model-save-vs-query-atomic-update.md) | `_Model::save()` is a plain load-then-write ORM primitive and **cannot express an atomic conditional update** (an optimistic-concurrency / row-claim guard such | _underscore/Model.php, _underscore/Query.php |
|
|
23
23
|
| [NetSuite REST Client (_Component_Api_Netsuite) — record writes & SuiteQL](features/netsuite-rest-client.md) | `_Component_Api_Netsuite` is the **2.0 `_underscore` NetSuite REST client** — the shared primitive every worker2/api2 NetSuite caller uses for record GETs, Suit | _underscore/Component/Api/Netsuite/Netsuite.php |
|
|
24
|
-
| [Per-Client Database Connections & the Local Logs Trap](features/per-client-database-connections.md) | When `_underscore` serves a request for a client it opens **three distinct per-client database connections**, not one. | _underscore/Database.php, _underscore/ApiRequest.php, _underscore/Model/Client/Logs/Api.php |
|
|
24
|
+
| [Per-Client Database Connections & the Local Logs Trap](features/per-client-database-connections.md) | When `_underscore` serves a request for a client it opens **three distinct per-client database connections**, not one. | _underscore/Database.php, _underscore/ApiRequest.php, _underscore/Model/Client/Logs/Api.php, api2/Controller/Index.php |
|
|
25
25
|
| [Persona Name Translation (PersonaTranslations sidecar)](features/persona-name-translation.md) | Serves Persona **names** in multiple languages by adding a per-language **sidecar** table `PersonaTranslations`, reusing the platform's existing metadata-driven | _underscore/Model/Client/PersonaTranslation.php, dbchanges2/Client/2026-07-22b - PersonaTranslations.sql, dbchanges2/Core/2026-07-22a - PersonaTranslationsRecord.sql, dbchanges2/Client/2026-07-22c - PersonaTranslationsAcl.sql, dbchanges2/Client_CompassCanada/2026-07-22 - PersonaTranslationsFrench.sql, toga2-commerce/src/pages/Account/view/MySettingsView.tsx |
|
|
26
26
|
| [Recursive Item Fulfillments (upstream mirroring)](features/recursive-item-fulfillments.md) | In a multi-tier supply chain a sales order (SO) spawns a purchase order (PO) that becomes another SO downstream, and so on. | _underscore/Model/Client/ItemFulfillment.php, _underscore/Model/Client/ItemFulfillmentItem.php, _underscore/Model/Client/ItemFulfillmentItemUnit.php, _underscore/Model/Client/ItemFulfillmentPackage.php, _underscore/Model/Compass/AdvanceShippingNotice.php, dbchanges2/Core/2026-02-13 - 75601 - RecursiveItemFulfillmentCreation.sql, dbchanges2/Core/2026-06-04 - RecursiveItemFulfillmentPut.sql, dbchanges2/Client_Compass/2026-07-02a - FixSA133377TrackingSerialAndDuplicateIF.sql |
|
|
27
27
|
| [Surface Resolver (_Model_Core_Surface::resolve — replaces Page::meta)](features/surface-resolver.md) | The runtime for the platform-wide **Surface** UI presentation layer: 9 `_underscore` models plus a cached resolver, `_Model_Core_Surface::resolve(&$api, string | _underscore/Model/Core/Surface.php, _underscore/Model/Client/AclRecordScript.php, _underscore/Model/Core/RecordScript.php, dbchanges2/Core/2026-06-30a - SurfaceMetaGroupAndSalesOrderSections.sql, dbchanges2/Client/2026-06-30a - SurfaceMetaGroupAcl.sql, dbchanges2/Client_Quad/2026-07-01a - GrantSurfacesMetaGroupScriptAcl.sql, dbchanges2/Client_CompassCanada/2026-07-01a - GrantSurfacesMetaGroupScriptAcl.sql, dbchanges2/Core/2026-06-29b - SurfaceMetaPublicReadAcl.sql, dbchanges2/Client/2026-06-29c - SurfaceRecordScriptAcl.sql, dbchanges2/Core/2026-06-29c - SurfaceDebugPhpMethodFix.sql, dbchanges2/Client_Compass/2026-07-15f - SalesOrderRecordActionsRemoveDeadConfigRuleOverrides.sql, dbchanges2/Core/2026-07-17h - Update - ClearApprovalsFilterButtonConfig.sql, dbchanges2/Client_Compass/2026-07-17a - SalesOrderApprovalActionsOverride.sql, dbchanges2/Client_Compass/2026-07-17b - SalesOrderApprovalsFilterButtonOverride.sql, dbchanges2/Client_CompassCanada/2026-07-17a - SalesOrderApprovalActionsOverride.sql, dbchanges2/Client_CompassCanada/2026-07-17b - SalesOrderApprovalsFilterButtonOverride.sql, dbchanges2/Client_Quad/2026-07-17a - SalesOrderApprovalActionsOverride.sql, dbchanges2/Client_Quad/2026-07-17b - SalesOrderApprovalsFilterButtonOverride.sql, _underscore/Model/Core/SurfaceElement.php, _underscore/Model/Core/Action.php, _underscore/Model/Core/Vocabulary.php, _underscore/Model/Core/VocabularyTerm.php, _underscore/Model/Core/Message.php, _underscore/Model/Client/SurfaceOverride.php, _underscore/Model/Client/MessageTranslation.php, _underscore/Model/Client/ThemeToken.php, _underscore/Model/Core/Page.php, api2/Component/Api/V2/V2.php |
|
|
@@ -6,7 +6,7 @@ project: _Underscore
|
|
|
6
6
|
client: shared
|
|
7
7
|
type: architecture
|
|
8
8
|
status: active
|
|
9
|
-
updated: 2026-07-
|
|
9
|
+
updated: 2026-07-23
|
|
10
10
|
owners: ["jcardinal", "rgirish", "mhammontree"]
|
|
11
11
|
files:
|
|
12
12
|
- _underscore/_underscore.php
|
|
@@ -344,21 +344,28 @@ multi-file UI components (`.php`/`.html`/`.css`/`.js`) invoked as `<_ComponentNa
|
|
|
344
344
|
- **`_Database::register()` auto-starts a lazy transaction (since Apr 2 2026, commit `fa7835ed`).** Any code that calls `register()` and then writes to that DB must call `_Database::transactionCommit()` before the request ends — otherwise MySQL silently rolls back all writes when the connection closes. Lazy transactions only materialise on the first write, so read-only callers are unaffected. See `_underscore/Database.php:48`. First discovered when Rate SAML user provisioning silently discarded all new user INSERTs (Jun 2026).
|
|
345
345
|
- **PHP "Unclosed '{'" parse errors report a MISLEADING line number.** When a `.php` file loaded by the autoloader (`Loader.php`) has a dropped/unbalanced brace, PHP reports `Unclosed '{' on line N` where N is the **outermost `class X {` line** and fails at EOF — NOT at the true location of the missing `}`. Worse, because the file loads lazily via the SPL autoloader, the runtime trace points at the **caller** that triggered the autoload (e.g. a `new _Email()` call site), not the broken file. **Triage rule:** for an "Unclosed '{'" error, the real culprit is a missing `}` somewhere between the reported line and EOF of the file that failed to load — run `php -l <file>` (it reports the EOF line) and scan the whole file. **Merge-conflict resolutions are a common source of a single dropped brace** — review the entire merge, not just the one file the error appears to name. (First hit: production 500 EO-1, Jul 2026 — a `}` dropped from `_Email::send()` during merge `685e4a14` surfaced as a trace pointing at the `new _Email()` caller.)
|
|
346
346
|
|
|
347
|
-
- **A controller exception
|
|
348
|
-
dispatch, `_underscore/Route.php`
|
|
347
|
+
- **A controller exception used to be silently swallowed by `Route.php`, then masked as a view error
|
|
348
|
+
(FIXED 2026-07-23).** In dispatch, `_underscore/Route.php` previously wrapped the controller call in
|
|
349
349
|
`catch (Exception $e) { _Database::transactionRollback(); }` — **no log, no rethrow, no debug
|
|
350
|
-
bypass.** The controller response
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
exception thrown inside the controller method
|
|
354
|
-
routing/view misconfiguration.
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
returns a response object that gets JSON-encoded —
|
|
360
|
-
|
|
361
|
-
|
|
350
|
+
bypass.** The controller response was left null, so Route.php fell through to its generic throw:
|
|
351
|
+
`Failed to determine how to render view for route '<route>' ... './View/Index/<method>.html'`.
|
|
352
|
+
That "Failed to determine how to render view" fatal was therefore very often a MASK for a real
|
|
353
|
+
exception thrown inside the controller method (commonly `_Controller_Index::api()`), not a genuine
|
|
354
|
+
routing/view misconfiguration. **Fixed 2026-07-23:** the catch now logs the swallowed exception
|
|
355
|
+
(via `error_log`, message + trace) **and rethrows**, and it catches `\Throwable` (not just
|
|
356
|
+
`Exception`) so `Error`s (type errors, fatals) surface too — the rollback is still performed before
|
|
357
|
+
rethrow. The real exception now propagates instead of being reshaped into the misleading line-525
|
|
358
|
+
view error. When diagnosing, you now see the true controller exception directly. (Note: an
|
|
359
|
+
*unmatched host* does NOT trigger this; `api()` returns a response object that gets JSON-encoded —
|
|
360
|
+
only a thrown exception hits this path.) A frequent underlying culprit was a DB connect / "Unknown
|
|
361
|
+
database" failure in `api()`'s pre-execute block — now separately guarded in api2 (see the api2
|
|
362
|
+
architecture doc's front-controller section). See
|
|
363
|
+
`2.0/apps/api2/workflows/environment-configuration-and-provisioning.md`.
|
|
364
|
+
- **Follow-up (deferred, separate ticket): production info-leak from unconditional `display_errors`.**
|
|
365
|
+
Now that Route.php rethrows, whatever global error handling renders the exception can leak the raw
|
|
366
|
+
message/trace to the client if `display_errors` is on unconditionally. Confirm `display_errors` is
|
|
367
|
+
disabled in production and that the framework's error surface returns a sanitized envelope (not a raw
|
|
368
|
+
stack trace) to API consumers. Not yet done — tracked as a follow-up.
|
|
362
369
|
|
|
363
370
|
## Change history
|
|
364
371
|
- 2026-06-11 — Documented lazy transaction gotcha in `_Database::register()` (rgirish)
|
|
@@ -367,3 +374,4 @@ multi-file UI components (`.php`/`.html`/`.css`/`.js`) invoked as `<_ComponentNa
|
|
|
367
374
|
- 2026-07-02 — Fixed framework-wide `FIELD_STORAGE` write-drop and folder-read bugs in `Model.php`; noted the remaining unconditional-refetch dirty-read follow-up. (mhammontree)
|
|
368
375
|
- 2026-07-07 — Documented `_Query` writes-only async mode (`isAsync` → Worker `Infrastructure/Database/Query`) in the database-architecture section, linked `features/async-query-execution.md`, and noted the SQS-first `_Worker::runTask()` enqueue departure (caller does no MySQL; worker tier creates the row on pickup). (jcardinal)
|
|
369
376
|
- 2026-07-16 — Documented the misleading "Unclosed '{'" parse-error gotcha (autoloader reports the outermost `class {` line / caller trace, not the true dropped-brace location; merge conflicts a common cause). First hit production 500 EO-1 via a dropped `_Email::send()` brace. (jcardinal)
|
|
377
|
+
- 2026-07-23 — Fixed the Route.php swallow→mask gotcha: the controller-call catch now log+rethrows and catches `\Throwable` (not just `Exception`), so the true controller exception surfaces instead of the misleading line-525 "Failed to determine how to render view" fatal. Noted the deferred production `display_errors` info-leak follow-up. (jcardinal)
|
|
@@ -7,13 +7,14 @@ client: shared
|
|
|
7
7
|
type: feature
|
|
8
8
|
status: active
|
|
9
9
|
updated: 2026-07-21
|
|
10
|
-
owners: ["jcardinal", "mhammontree"]
|
|
10
|
+
owners: ["jcardinal", "mhammontree", "tcox"]
|
|
11
11
|
files:
|
|
12
12
|
- api2/Component/Api/V2/V2.php
|
|
13
13
|
- _underscore/Model/Core/Page.php
|
|
14
14
|
- _underscore/Model/Client/TrackingNumber.php
|
|
15
15
|
- dbchanges2/Client/2026-06-03- BLANK_CLIENT_DATABASE.sql
|
|
16
16
|
- dbchanges2/Client/2026-06-23b - ItemTranslationsAcl.sql
|
|
17
|
+
- dbchanges2/Client/2026-07-02c - TrackingNumberNeedsReturnLabelFieldPermission.sql
|
|
17
18
|
---
|
|
18
19
|
|
|
19
20
|
## Summary
|
|
@@ -45,7 +46,35 @@ AclRecordPermissions → AclLogicGroups → AclLogicGroupExpressions →
|
|
|
45
46
|
|
|
46
47
|
**Field visibility — `AclFieldPermissions`.** Record-level access alone does **not** expose
|
|
47
48
|
fields. Each field needs a row: `recordFieldId`, `roleId`, `isWritable` (0 = read-only, 1 =
|
|
48
|
-
writable). Without it the field is
|
|
49
|
+
writable). Without it the field is rejected on write **and** is unreadable:
|
|
50
|
+
|
|
51
|
+
- **Missing grant on a READ** (e.g. `GET /v2/<record>?fields=<field>`) → **`EZ-2`** (field-level
|
|
52
|
+
authorization denied). This is the read-side counterpart of the write code `EV-9`. A field can be
|
|
53
|
+
fully registered (`Core.RecordFields` row present, so no `EV-8`) yet still `EZ-2` because it has
|
|
54
|
+
no `AclFieldPermissions` row — **registering a field and granting access to it are two separate
|
|
55
|
+
steps**, and shipping the field registration without the grant is a recurring launch blocker.
|
|
56
|
+
- **`AclFieldPermissions` is `UNIQUE (recordFieldId, roleId)`** (Client blank DDL) — guard inserts
|
|
57
|
+
with `NOT EXISTS`. For a **read-only, server-written** field (one populated by a `postPost`
|
|
58
|
+
interceptor rather than the API caller), grant `isWritable = 0`.
|
|
59
|
+
|
|
60
|
+
### Standard field vs. custom (`c_`) field — TWO different permission tables
|
|
61
|
+
|
|
62
|
+
The permission table depends on whether the field is a platform **standard** field or a client
|
|
63
|
+
**custom** field, and they are **not interchangeable** — granting the wrong one silently does
|
|
64
|
+
nothing:
|
|
65
|
+
|
|
66
|
+
| Field kind | Registered in | Authorized via | Keyed by |
|
|
67
|
+
|------------|---------------|----------------|----------|
|
|
68
|
+
| **Standard** (e.g. `serviceAddressId`) | `Core.RecordFields` | **`AclFieldPermissions`** | `Core.RecordFields` id |
|
|
69
|
+
| **Custom** (`c_` prefix) | `CustomRecordFields` | **`AclCustomFieldPermissions`** | `CustomRecordFields` id |
|
|
70
|
+
|
|
71
|
+
**Promoting a custom field to a standard field moves its grant to the other table.** When
|
|
72
|
+
`c_serviceAddressId` was promoted to the standard `serviceAddressId` (Rate WH guard, TRUE-79533),
|
|
73
|
+
its `CustomRecordFields` row was deleted, so the pending `AclCustomFieldPermissions` grant
|
|
74
|
+
(TRUE-80176) became a no-op — the replacement must be an `AclFieldPermissions` row. Resolve the
|
|
75
|
+
`recordFieldId` by **route + field-name subselect** against `Core.RecordFields`/`Core.Records`
|
|
76
|
+
(never hardcode), and clone a sibling field's per-role rows — e.g. `Client/2026-07-02c -
|
|
77
|
+
TrackingNumberNeedsReturnLabelFieldPermission.sql` is the precedent shape.
|
|
49
78
|
|
|
50
79
|
## Where ACL rows live: Core vs. Client database
|
|
51
80
|
|
|
@@ -170,6 +199,13 @@ the field.
|
|
|
170
199
|
|
|
171
200
|
## Change history
|
|
172
201
|
|
|
202
|
+
- **2026-07-23** — TRUE-79533: documented the **READ side** of `AclFieldPermissions` — a registered
|
|
203
|
+
field with no grant returns **`EZ-2`** on read (the counterpart of the write code `EV-9`), and
|
|
204
|
+
the **standard vs. custom** split: standard fields use `AclFieldPermissions` (keyed by
|
|
205
|
+
`Core.RecordFields` id), custom (`c_`) fields use `AclCustomFieldPermissions` (keyed by
|
|
206
|
+
`CustomRecordFields` id). Promoting `c_serviceAddressId` → standard `serviceAddressId` moved its
|
|
207
|
+
grant across tables and made the pending `AclCustomFieldPermissions` grant a no-op. Noted the
|
|
208
|
+
`UNIQUE (recordFieldId, roleId)` constraint and `isWritable=0` for server-written fields. (tcox)
|
|
173
209
|
- **2026-07-21** — TRUE-79191: promoted the writable-field recipe from **3-file** to **FOUR
|
|
174
210
|
requirements** — added the **generated model-class declaration** (`_underscore/Model/Client/<Name>.php`,
|
|
175
211
|
e.g. `public $needsReturnLabel = self::FIELD_BOOLEAN;`) as the 4th, whose absence returns **`EO-1`**
|
|
@@ -6,12 +6,13 @@ project: _Underscore
|
|
|
6
6
|
client: shared
|
|
7
7
|
type: feature
|
|
8
8
|
status: active
|
|
9
|
-
updated: 2026-07-
|
|
9
|
+
updated: 2026-07-23
|
|
10
10
|
owners: ["dfranks", "jcardinal", "mhammontree", "apeterson"]
|
|
11
11
|
files:
|
|
12
12
|
- _underscore/Database.php
|
|
13
13
|
- _underscore/ApiRequest.php
|
|
14
14
|
- _underscore/Model/Client/Logs/Api.php
|
|
15
|
+
- api2/Controller/Index.php
|
|
15
16
|
related:
|
|
16
17
|
- ../architecture.md
|
|
17
18
|
- ../workflows/local-db-refresh-from-beta.md
|
|
@@ -89,12 +90,29 @@ registration is region-aware in `api2/Controller/Index.php`; the alias const is
|
|
|
89
90
|
set from beta — see [Refreshing a local dev DB from beta](../workflows/local-db-refresh-from-beta.md).
|
|
90
91
|
This is a **local-environment** gap, not a code bug — do not "fix" it by disabling logging in
|
|
91
92
|
shared code.
|
|
93
|
+
- **The shared Core Logs DB (`DB_LOGS`) has the same trap — and its name is resolved dynamically,
|
|
94
|
+
so "I have all the databases installed" can still fail.** api2's `Controller/Index.php::api()`
|
|
95
|
+
bootstrap registers `DB_LOGS` using a schema name read from a **`Core.Database` row**
|
|
96
|
+
(`id = CORE_LOGS_DATABASE_ID`), not a fixed literal. A Logs DB you created locally whose *name*
|
|
97
|
+
doesn't match that Core row still reads as **missing** → `mysqli_connect` `Unknown database`. Match
|
|
98
|
+
the local schema name to the `Core.Database` row (or fix the row), don't just "create a logs DB."
|
|
99
|
+
- **This Core Logs bootstrap runs OUTSIDE the try/catch that wraps `$api->execute()`** (the env
|
|
100
|
+
lookup, region/host `search()`, `_Model_Core_Database` instantiation, and `_Database::register()`
|
|
101
|
+
for `DB_CORE`/`DB_LOGS` all run before the guarded block at ~line 202 of `Controller/Index.php`).
|
|
102
|
+
A connect failure here threw past `api()` and got masked by `Route.php` as the misleading line-525
|
|
103
|
+
"Failed to determine how to render view" error — see the Route.php swallow→mask gotcha in
|
|
104
|
+
[_underscore architecture](../architecture.md#gotchas--known-issues).
|
|
92
105
|
- Related 1.0 analogue: the legacy `App_` worker has the same hazard writing to `Logs.API`
|
|
93
106
|
(`db_logs`) — the laptop trap there is documented separately in the worker NetSuite bootstrap
|
|
94
107
|
notes.
|
|
95
108
|
|
|
96
109
|
## Change history
|
|
97
110
|
|
|
111
|
+
- 2026-07-23 — Documented the shared **Core Logs** (`DB_LOGS`) analogue of the logs trap: its schema
|
|
112
|
+
name is resolved from a `Core.Database` row (`id = CORE_LOGS_DATABASE_ID`) in
|
|
113
|
+
`api2/Controller/Index.php::api()`, so a name-mismatched local Logs DB still reads as missing; and
|
|
114
|
+
this bootstrap runs outside `api()`'s guarded block, so a connect failure was masked by Route.php's
|
|
115
|
+
line-525 view error. (jcardinal)
|
|
98
116
|
- 2026-07-09 — Linked the new [Running a 2.0 app locally](../workflows/running-a-2.0-app-locally.md) runbook, which frames these three connections as one requirement of a full local browser run. (mhammontree)
|
|
99
117
|
- 2026-07-09 — Linked the new [local-DB-refresh-from-beta workflow](../workflows/local-db-refresh-from-beta.md)
|
|
100
118
|
from the logs-trap fix (refreshing all local schemas from beta covers the missing `Logs_<Id>`). (apeterson)
|
|
@@ -6,7 +6,7 @@ project: API
|
|
|
6
6
|
client: shared
|
|
7
7
|
type: architecture
|
|
8
8
|
status: active
|
|
9
|
-
updated: 2026-07-
|
|
9
|
+
updated: 2026-07-23
|
|
10
10
|
owners: [jcardinal, bala]
|
|
11
11
|
files:
|
|
12
12
|
- api2/Controller/Index.php
|
|
@@ -65,8 +65,21 @@ lands in `api()`.
|
|
|
65
65
|
- **CORS:** permissive `Access-Control-Allow-*`; returns 200 for `OPTIONS` preflight.
|
|
66
66
|
- **`/health`:** 200 + empty object (EB/LB probe — keep it working).
|
|
67
67
|
- **Sentry:** initialized per request; EC2 instance metadata attached.
|
|
68
|
-
- **Core DB:** registers `Core` (`DB_CORE`), preferring a
|
|
69
|
-
(`CORE_READHOST`)
|
|
68
|
+
- **Core DB bootstrap (now guarded, 2026-07-23):** registers `Core` (`DB_CORE`), preferring a
|
|
69
|
+
region-local read host (`CORE_READHOST`), then registers the **Core Logs DB** region-aware as
|
|
70
|
+
`DB_LOGS`. This whole block runs in a **pre-execute phase that executes *before* the `execute()`
|
|
71
|
+
try/catch**, so a failure here historically escaped that guard and became a fatal. Two traps:
|
|
72
|
+
(1) the Core Logs **schema name is resolved from a `Core.Database` row** (`id =
|
|
73
|
+
CORE_LOGS_DATABASE_ID`), so a **name-mismatched local Logs DB** (one whose actual schema name
|
|
74
|
+
differs from the Core-recorded name) reads as **missing** and `mysqli_connect` throws `Unknown
|
|
75
|
+
database` — a common local-dev breakage, not a code bug. (2) That connect failure used to be a
|
|
76
|
+
fatal. **Fixed 2026-07-23:** the pre-execute Core/Logs bootstrap is wrapped in a
|
|
77
|
+
`try/catch (\Throwable)` that, on failure, returns the standard **`INVALID_CONFIGURATION`**
|
|
78
|
+
error envelope and reports to **Sentry** instead of dying with an unguarded fatal. Note the
|
|
79
|
+
rollback in that catch is a **guarded no-op** — with no transaction started yet,
|
|
80
|
+
`_Database::transactionRollback()` short-circuits safely (`Database.php:219–226`). See
|
|
81
|
+
[_underscore architecture — Route.php log+rethrow gotcha](../_underscore/architecture.md) for the
|
|
82
|
+
upstream masking behavior this pairs with, and the per-client-database-connections feature doc.
|
|
70
83
|
- **Host dispatch:** lowercase `HTTP_HOST`, strip trailing domain, switch on the remainder.
|
|
71
84
|
- **Transaction/logging invariant (JSON path):** wraps the call in transactions; on
|
|
72
85
|
**success** commit logs then data; on **failure** commit logs but **roll back data**; on
|
|
@@ -153,6 +166,12 @@ Core/Client/Logs DB passwords, **AWS access key id + secret**, and third-party A
|
|
|
153
166
|
carries a **GitHub PAT**. These should be rotated and moved to SSM Parameter Store / EB env
|
|
154
167
|
properties. **Flag this if you touch config or deploy.**
|
|
155
168
|
|
|
169
|
+
**Follow-up (deferred, separate ticket): raw exception disclosure to clients.** The error paths that
|
|
170
|
+
surface a caught `\Throwable` (now that Route.php rethrows and the bootstrap guard reports failures)
|
|
171
|
+
must not return raw `getMessage()`/`getTrace()` output in the client-facing envelope — that leaks
|
|
172
|
+
internal paths, schema names, and stack frames to API consumers. Sanitize the client envelope
|
|
173
|
+
(generic message + code; full detail to Sentry/logs only). Not yet done.
|
|
174
|
+
|
|
156
175
|
## When making changes here
|
|
157
176
|
|
|
158
177
|
- **Adding/altering an endpoint is usually a data change, not code** — define `Records` +
|
|
@@ -167,3 +186,6 @@ properties. **Flag this if you touch config or deploy.**
|
|
|
167
186
|
the duplicate check.
|
|
168
187
|
- Preserve the commit-logs / rollback-data-on-failure invariant when editing the controller
|
|
169
188
|
or `execute()`.
|
|
189
|
+
|
|
190
|
+
## Change history
|
|
191
|
+
- 2026-07-23 — Documented the now-guarded Core/Logs DB bootstrap in the front controller: the pre-execute block runs before the `execute()` try/catch, the Core Logs schema name is resolved from a `Core.Database` row (`id = CORE_LOGS_DATABASE_ID`) so a name-mismatched local Logs DB reads as missing, and the failure is now wrapped in `try/catch (\Throwable)` returning `INVALID_CONFIGURATION` + Sentry instead of a fatal (guarded no-op rollback, `Database.php:219–226`). Added the deferred raw-getMessage/getTrace client-disclosure follow-up to the Security note. (jcardinal)
|
|
@@ -7,7 +7,7 @@ client: shared
|
|
|
7
7
|
type: feature
|
|
8
8
|
status: active
|
|
9
9
|
updated: 2026-07-21
|
|
10
|
-
owners: [mhammontree]
|
|
10
|
+
owners: [mhammontree, tcox]
|
|
11
11
|
files:
|
|
12
12
|
- api2/Component/Api/V2/V2.php
|
|
13
13
|
- _underscore/Model/Client/TrackingNumber.php
|
|
@@ -36,6 +36,7 @@ migration instead of re-deriving it. All field/script ACL rows live in the **CLI
|
|
|
36
36
|
| **EV-9** | No permission to write field | The field exists but the caller's role has no **`AclFieldPermissions`** grant (`isWritable=1`) in the CLIENT DB | Add the `AclFieldPermissions` row for the role (clone a writable sibling field's grant) |
|
|
37
37
|
| **EO-1** | Operation failed — identifier "There is no field called 'X' in the '_Model_Client_Y' model" | The DB column **and** `Core.RecordFields` exist, but the **generated model class** `_underscore/Model/Client/<Name>.php` doesn't declare the field. This is the **4th** requirement beyond the 3-file migration — and most often it's a **cross-repo git branch mismatch** (`_underscore` on a branch whose generated model lacks a field the DB/RecordFields already carry) | Declare the field in the generated model class (`public $field = self::FIELD_*`) and put all related repos (`_underscore`, `api2`, `dbchanges2`, `toga2-supply`) on the **same** feature branch — see the ACL doc's writable-field recipe |
|
|
38
38
|
| **EZ-1** | Unauthorized record/script dispatch | Missing **`AclRecordScripts`** (scripted APIs) or the **`AclRecordPermissions`** four-table chain (records) for the caller's role | Grant `AclRecordScripts` (scripts) or complete the record-CRUD chain |
|
|
39
|
+
| **EZ-2** | Field-level authorization denied (READ) | The field is registered (`Core.RecordFields` present → no `EV-8`) but the caller's role has no **`AclFieldPermissions`** grant to **read** it. The read-side counterpart of `EV-9` (write) | Add the `AclFieldPermissions` row for the role (`isWritable=0` if the field is server-written). For a **custom** `c_` field use `AclCustomFieldPermissions` instead — see the ACL doc's standard-vs-custom table |
|
|
39
40
|
| **EV-5** | Duplicate `transactionId` | The globally-unique `transactionId` was reused | Send a fresh unique `transactionId` per request |
|
|
40
41
|
| **EV-12** | Record's parent not synced | (Fulfill & Ship) POST `/item-fulfillments` when the Sales Order isn't synced into Toga yet | `GET /sales-orders/syncNetsuiteSalesOrder?netsuiteInternalSalesOrderId=<id>` first |
|
|
41
42
|
|
|
@@ -44,9 +45,12 @@ migration instead of re-deriving it. All field/script ACL rows live in the **CLI
|
|
|
44
45
|
1. **Resolve the role.** Field/script ACLs are keyed by `roleId` **in the CLIENT DB**, and role
|
|
45
46
|
ids differ per client — resolve by subselect (`SELECT id FROM Roles WHERE name='Base'`), never
|
|
46
47
|
hardcode. To find the right grant shape, inspect a **working sibling** field/script's rows.
|
|
47
|
-
2. **EV-8 vs EV-9**
|
|
48
|
-
registration is missing
|
|
49
|
-
|
|
48
|
+
2. **EV-8 vs EV-9 vs EZ-2** — three states of one field. **EV-8** = the Core `RecordFields`
|
|
49
|
+
registration is missing (field unknown). Once registered, a **write** with no grant is **EV-9**
|
|
50
|
+
and a **read** with no grant is **EZ-2** — both mean the `AclFieldPermissions` row is missing,
|
|
51
|
+
differing only by direction. Registering a field and granting access to it are separate steps:
|
|
52
|
+
a field can pass EV-8 yet still EZ-2/EV-9. (For custom `c_` fields the grant table is
|
|
53
|
+
`AclCustomFieldPermissions`, not `AclFieldPermissions`.)
|
|
50
54
|
3. **EV-6 vs EZ-1** are the two halves of exposing a scripted API: EV-6 = no `Core.RecordScripts`
|
|
51
55
|
route (the segment falls through to record lookup); EZ-1 = the route exists but there's no
|
|
52
56
|
`AclRecordScripts` dispatch grant for the caller's role.
|
|
@@ -67,6 +71,10 @@ migration instead of re-deriving it. All field/script ACL rows live in the **CLI
|
|
|
67
71
|
|
|
68
72
|
## Change history
|
|
69
73
|
|
|
74
|
+
- 2026-07-23 — TRUE-79533: added **`EZ-2`** (field-level READ authorization denied) — the read-side
|
|
75
|
+
counterpart of `EV-9`; a registered field (no `EV-8`) with no `AclFieldPermissions` read grant.
|
|
76
|
+
Clarified EV-8/EV-9/EZ-2 as three states of one field and the standard-vs-custom grant-table
|
|
77
|
+
split. Found via the Rate WH `serviceAddressId` standard-field promotion. (tcox)
|
|
70
78
|
- 2026-07-17 — TRUE-79191: documented the EV-6/EV-8/EV-9/EZ-1 troubleshooting map (plus EV-5/EV-12)
|
|
71
79
|
discovered wiring the Signature Type field and the `addresses/validate` scripted route on Fulfill
|
|
72
80
|
& Ship — each code mapped to the missing migration (RecordField / AclFieldPermissions /
|
|
@@ -4,4 +4,6 @@
|
|
|
4
4
|
|-----|---------|-------|
|
|
5
5
|
| [TOGa View Frontend (toga2-view) Architecture](architecture.md) | `toga2-view` is the **React/TypeScript single-page frontend** for the TOGa 2.0 platform — the customer-facing web app (home warranty / tech-support portals). | toga2-view/src/main.tsx, toga2-view/src/App.tsx, toga2-view/src/routes.tsx, toga2-view/src/api/axiosInstance.ts, toga2-view/src/api/apiFunctions.ts, toga2-view/src/utils/queryHelpers.ts, toga2-view/src/contexts/AuthContext.tsx, toga2-view/src/contexts/useUserStore.ts, toga2-view/src/hooks/useAuthenticationFlow.ts, toga2-view/vite.config.ts, toga2-view/package.json |
|
|
6
6
|
| [Mobile Nav Header](features/mobile-nav-header.md) | The `MobileNavToggle` component renders the fixed 72 px header (hamburger + Rate logo) and the slide-in drawer nav. | toga2-view/src/components/MobileNav/MobileNav.tsx, toga2-view/public/assets/Rate_Logo.svg |
|
|
7
|
+
| [/v2 Query-String Builder (assembleOptions / where coercion)](features/query-string-builder.md) | `src/utils/queryHelpers.ts` converts a structured JS options object (`fields`, `where`, `join`/`ojoin`, `sort`, …) into the `api2` `/v2` query string. | toga2-view/src/utils/queryHelpers.ts, toga2-view/src/utils/queryHelpers.test.ts |
|
|
7
8
|
| [Service Card Component](features/service-card.md) | The `ServiceCard` component renders a single service subscription (tech support or home warranty) on both the Home and Services pages. | toga2-view/src/components/ServiceCard/ServiceCard.tsx, toga2-view/src/pages/Services/view/ServicesPage.tsx, toga2-view/src/pages/Home/view/HomePage.tsx, toga2-view/src/constants/bundleConstants.ts, toga2-view/src/pages/Services/viewModels/DUMMYFIELDS/SERVICESDUMMYFIELDS.json |
|
|
9
|
+
| [ZipValidation — contact phone is a prefill default, never a lock](features/zip-validation-contact-phone.md) | The `ZipValidation` page collects the covered-property address and a **contact phone number** as part of the purchase / address-validation entry flow (the front | toga2-view/src/pages/ZipValidation/view/ZipValidation.tsx |
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "/v2 Query-String Builder (assembleOptions / where coercion)"
|
|
3
|
+
framework: "2.0"
|
|
4
|
+
repo: toga2-view
|
|
5
|
+
project: TOGa View Frontend
|
|
6
|
+
client: shared
|
|
7
|
+
type: feature
|
|
8
|
+
status: active
|
|
9
|
+
updated: 2026-07-23
|
|
10
|
+
owners: ["tcox"]
|
|
11
|
+
files:
|
|
12
|
+
- toga2-view/src/utils/queryHelpers.ts
|
|
13
|
+
- toga2-view/src/utils/queryHelpers.test.ts
|
|
14
|
+
related:
|
|
15
|
+
- ../architecture.md
|
|
16
|
+
- ../../../../clients/rate/features/service-card-entitlements.md
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
## Summary
|
|
20
|
+
|
|
21
|
+
`src/utils/queryHelpers.ts` converts a structured JS options object (`fields`, `where`,
|
|
22
|
+
`join`/`ojoin`, `sort`, …) into the `api2` `/v2` query string. Every data call in the app is built
|
|
23
|
+
here (see the [architecture doc](../architecture.md)), so a bug in the builder fails the request
|
|
24
|
+
**client-side, before any HTTP goes out** — no request appears in the network tab, which makes it
|
|
25
|
+
easy to misread as an API problem.
|
|
26
|
+
|
|
27
|
+
## Key files / entry points
|
|
28
|
+
|
|
29
|
+
- `queryHelpers.ts` — `assembleOptions()` (top-level builder) and `assembleOptionsWhere()`
|
|
30
|
+
(serializes the `where` array), plus `urlEncode()` (per-value encoding).
|
|
31
|
+
- `queryHelpers.test.ts` — unit/regression tests for the builder.
|
|
32
|
+
|
|
33
|
+
## How it works
|
|
34
|
+
|
|
35
|
+
1. `assembleOptions()` walks the options object and delegates each `where` condition to
|
|
36
|
+
`assembleOptionsWhere()`.
|
|
37
|
+
2. Each condition's value is passed through `urlEncode()` before being appended to the query
|
|
38
|
+
string.
|
|
39
|
+
3. **Value coercion.** `urlEncode()` calls `String.prototype.replace`, so it requires a **string**.
|
|
40
|
+
`assembleOptionsWhere()` **coerces numeric condition values to `String` before `urlEncode`** —
|
|
41
|
+
without the coercion, a numeric `where` value (e.g. `where Addresses.id = 14`) throws
|
|
42
|
+
`input.replace is not a function` and aborts the whole request.
|
|
43
|
+
|
|
44
|
+
## Gotchas / known issues
|
|
45
|
+
|
|
46
|
+
- **Numeric `where` values used to crash the builder.** Every `where` in the app historically
|
|
47
|
+
happened to pass **string** values, so the first numeric caller — `fetchAddressesByIds` resolving
|
|
48
|
+
per-entitlement service addresses (`where Addresses.id IN (…)` / `= 14`) — hit
|
|
49
|
+
`input.replace is not a function` in `urlEncode`, and **no `/v2/addresses` request was ever
|
|
50
|
+
sent** (the throw is client-side). Fixed by coercing numeric condition values to `String` in
|
|
51
|
+
`assembleOptionsWhere`. Regression tests in `queryHelpers.test.ts` (5 cases incl. the exact
|
|
52
|
+
addresses-by-id shape; 4 failed pre-fix with the production error).
|
|
53
|
+
- **A builder throw looks like an API outage but isn't.** If a fetch "does nothing" and there is no
|
|
54
|
+
request in the network panel, suspect the option object shape (a non-string `where` value, a
|
|
55
|
+
malformed `fields` entry) before suspecting `api2`.
|
|
56
|
+
|
|
57
|
+
## Change history
|
|
58
|
+
|
|
59
|
+
- 2026-07-23 — Documented the builder and fixed `assembleOptionsWhere` to coerce **numeric** `where`
|
|
60
|
+
values to `String` before `urlEncode` — a numeric value (first hit via `fetchAddressesByIds`)
|
|
61
|
+
threw `input.replace is not a function` and silently aborted the request client-side. Added
|
|
62
|
+
regression tests. (tcox)
|
|
63
|
+
</content>
|
|
64
|
+
</invoke>
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "ZipValidation — contact phone is a prefill default, never a lock"
|
|
3
|
+
framework: "2.0"
|
|
4
|
+
repo: toga2-view
|
|
5
|
+
project: TOGa View Frontend
|
|
6
|
+
client: shared
|
|
7
|
+
type: feature
|
|
8
|
+
status: active
|
|
9
|
+
updated: 2026-07-23
|
|
10
|
+
owners: ["tcox"]
|
|
11
|
+
files:
|
|
12
|
+
- toga2-view/src/pages/ZipValidation/view/ZipValidation.tsx
|
|
13
|
+
related:
|
|
14
|
+
- ../architecture.md
|
|
15
|
+
- ../../../../clients/rate/features/whole-home-warranty-purchase-guard.md
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
## Summary
|
|
19
|
+
|
|
20
|
+
The `ZipValidation` page collects the covered-property address and a **contact phone number** as
|
|
21
|
+
part of the purchase / address-validation entry flow (the front end for the WH purchase guard — see
|
|
22
|
+
[whole-home-warranty-purchase-guard](../../../../clients/rate/features/whole-home-warranty-purchase-guard.md)).
|
|
23
|
+
`handleContinue` saves the entered phone into the user store after each purchase.
|
|
24
|
+
|
|
25
|
+
## Decision — a stored phone is a PREFILL DEFAULT, not a lock
|
|
26
|
+
|
|
27
|
+
The phone field is **always editable**. A phone previously saved to the user store is used only to
|
|
28
|
+
**prefill** the field; it must never disable it. A single customer can cover **different
|
|
29
|
+
properties**, each of which may have a **different contact number**, so locking the field to the
|
|
30
|
+
first value entered is wrong.
|
|
31
|
+
|
|
32
|
+
The **save-if-different** behavior is retained: `handleContinue` still writes the entered phone to
|
|
33
|
+
the store when it changed.
|
|
34
|
+
|
|
35
|
+
## Gotchas / known issues
|
|
36
|
+
|
|
37
|
+
- **Do not re-disable the phone field on "store already has a phone".** The original code disabled
|
|
38
|
+
the field whenever the user store held a phone number — and because `handleContinue` saves the
|
|
39
|
+
entered phone after each purchase, the **first purchase permanently locked the field for every
|
|
40
|
+
later visit** (a self-reinforcing lock). The fix removed `isPhoneDisabled` state, the `disabled`
|
|
41
|
+
prop, and the greyed-out styling. Keep it editable.
|
|
42
|
+
|
|
43
|
+
## Change history
|
|
44
|
+
|
|
45
|
+
- 2026-07-23 — Removed the phone-field lock: a stored phone now prefills but never disables the
|
|
46
|
+
field (a different covered property can have a different contact number). Previously the field
|
|
47
|
+
was disabled once the store held a phone, and the post-purchase save made the first purchase lock
|
|
48
|
+
it for every later visit. `save-if-different` retained. (tcox)
|
|
49
|
+
</content>
|
package/knowledge/INDEX.md
CHANGED
|
@@ -23,7 +23,7 @@ _Auto-generated by `knowledge.js index`. Do not hand-edit._
|
|
|
23
23
|
- **dbchanges2** (Database Changes) _(framework core)_ — 3 doc(s) → [2.0/apps/dbchanges2/INDEX.md](2.0/apps/dbchanges2/INDEX.md)
|
|
24
24
|
- **toga2-supply** (TOGa Supply) — 3 doc(s) → [2.0/apps/toga2-supply/INDEX.md](2.0/apps/toga2-supply/INDEX.md)
|
|
25
25
|
- **saml** (SAML SSO Gateway) — 3 doc(s) → [2.0/apps/saml/INDEX.md](2.0/apps/saml/INDEX.md)
|
|
26
|
-
- **toga2-view** (TOGa View Frontend) —
|
|
26
|
+
- **toga2-view** (TOGa View Frontend) — 6 doc(s) → [2.0/apps/toga2-view/INDEX.md](2.0/apps/toga2-view/INDEX.md)
|
|
27
27
|
- **toga2-hub** (TOGa Hub) — 2 doc(s) → [2.0/apps/toga2-hub/INDEX.md](2.0/apps/toga2-hub/INDEX.md)
|
|
28
28
|
- **talos** (TOGa IQ) — 7 doc(s) → [2.0/apps/talos/INDEX.md](2.0/apps/talos/INDEX.md)
|
|
29
29
|
- **voice-to-voice** (TOGa Voice) — 4 doc(s) → [2.0/apps/voice-to-voice/INDEX.md](2.0/apps/voice-to-voice/INDEX.md)
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
| [Rate Monthly Reconciliation Report](features/monthly-reconciliation-report.md) | 1.0 | A monthly cron that emails an Excel reconciliation report covering all Rate subscription sales orders and their linked PayPal payments for the prior calendar mo | worker/crons/notifications/reports/rate/send_monthly_rate_purchases_report.php, worker/schedules/cron.worker.notification.json |
|
|
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
|
-
| [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 |
|
|
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/hooks/useActiveServices.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, src/api/serviceAddressApi.ts, src/api/apiErrors.ts |
|
|
10
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 |
|
|
11
|
-
| [Rate Whole Home Warranty Per-Address Purchase Guard](features/whole-home-warranty-purchase-guard.md) | 2.0 | > **⚠ DEPLOYING beta→production, NOT YET PROD-VERIFIED (as of 2026-07-23).** TRUE-79533 is > beta-verified (PM Paulina tested the WH purchase flow on beta) and | _underscore/Model/Rate/Entitlement.php, _underscore/Model/Client/Entitlement.php, _underscore/Model/Client/Address.php, dbchanges2/Client/2026-07-22a - EntitlementServiceAddressId.sql, dbchanges2/Core/2026-07-22a - EntitlementServiceAddressIdField.sql, test/@Mark/Rate/verify_wholehome_per_address_guard.php |
|
|
11
|
+
| [Rate Whole Home Warranty Per-Address Purchase Guard](features/whole-home-warranty-purchase-guard.md) | 2.0 | > **⚠ DEPLOYING beta→production, NOT YET PROD-VERIFIED (as of 2026-07-23).** TRUE-79533 is > beta-verified (PM Paulina tested the WH purchase flow on beta) and | _underscore/Model/Rate/Entitlement.php, _underscore/Model/Client/Entitlement.php, _underscore/Model/Client/Address.php, dbchanges2/Client/2026-07-22a - EntitlementServiceAddressId.sql, dbchanges2/Core/2026-07-22a - EntitlementServiceAddressIdField.sql, dbchanges2/Client/2026-07-23a - EntitlementServiceAddressIdFieldPermission.sql, test/@Mark/Rate/verify_wholehome_per_address_guard.php |
|
|
12
12
|
| [Rate](profile.md) | 2.0 | Rate is a mortgage/lending client. | |
|
|
@@ -2,23 +2,27 @@
|
|
|
2
2
|
title: "Service Card Entitlement Display"
|
|
3
3
|
framework: "2.0"
|
|
4
4
|
repo: toga2-view
|
|
5
|
-
project: TOGa View
|
|
5
|
+
project: TOGa View Frontend
|
|
6
6
|
client: rate
|
|
7
7
|
type: client-feature
|
|
8
8
|
status: active
|
|
9
|
-
updated: 2026-
|
|
10
|
-
owners: ["bala"]
|
|
9
|
+
updated: 2026-07-23
|
|
10
|
+
owners: ["bala", "tcox"]
|
|
11
11
|
files:
|
|
12
12
|
- src/components/ServiceCard/ServiceCard.tsx
|
|
13
13
|
- src/components/ServiceCard/index.ts
|
|
14
14
|
- src/hooks/useBundleServices.ts
|
|
15
|
+
- src/hooks/useActiveServices.ts
|
|
15
16
|
- src/pages/Home/api/homeApi.ts
|
|
16
17
|
- src/pages/Home/view/HomePage.tsx
|
|
17
18
|
- src/pages/Home/viewModels/useHomePageViewModel.ts
|
|
18
19
|
- src/pages/Services/view/ServicesPage.tsx
|
|
19
20
|
- src/pages/Services/viewModels/useServicePageViewModel.ts
|
|
21
|
+
- src/api/serviceAddressApi.ts
|
|
22
|
+
- src/api/apiErrors.ts
|
|
20
23
|
related:
|
|
21
24
|
- clients/rate/profile.md
|
|
25
|
+
- whole-home-warranty-purchase-guard.md
|
|
22
26
|
---
|
|
23
27
|
|
|
24
28
|
## Summary
|
|
@@ -57,22 +61,36 @@ regardless of how many entitlements the user had purchased.
|
|
|
57
61
|
|
|
58
62
|
4. `ServiceCard` renders the data rows based on `serviceType`:
|
|
59
63
|
- Tech / other → row 2: **Price** (`$XX.XX`)
|
|
60
|
-
- Warranty → row 2: **Address**
|
|
64
|
+
- Warranty → row 2: **Address** — the entitlement's **own pinned service address**, resolved
|
|
65
|
+
from `Entitlements.serviceAddressId` (see below). If the entitlement is unpinned
|
|
66
|
+
(`serviceAddressId` NULL), the card renders **without an address** — this is intentional (see
|
|
67
|
+
the "own pin only" gotcha).
|
|
61
68
|
|
|
62
69
|
5. Home page slices `allServices` to 3 (`HOME_SERVICE_LIMIT`). If more exist, a tertiary
|
|
63
70
|
"View All Services" text link appears below the cards pointing to `/services`.
|
|
64
71
|
|
|
65
72
|
6. Services page renders all entitlements with no cap in a `grid-cols-1 md:grid-cols-2` grid.
|
|
66
73
|
|
|
67
|
-
##
|
|
74
|
+
## Service address: `serviceAddressId` (per-entitlement pin), not the contact's shared address
|
|
68
75
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
+
As of 2026-07-23 (TRUE-79533) the warranty card's address comes from the entitlement's **own**
|
|
77
|
+
`serviceAddressId` FK — the address pinned by the WH purchase guard's `postPost` (see
|
|
78
|
+
[whole-home-warranty-purchase-guard](whole-home-warranty-purchase-guard.md)). The front end fetches
|
|
79
|
+
those addresses by id via `fetchAddressesByIds` (`src/api/serviceAddressApi.ts`,
|
|
80
|
+
`GET /v2/addresses?where=Addresses.id IN (…)`) and joins them onto each entitlement.
|
|
81
|
+
|
|
82
|
+
- **Field rename.** Every FE reference to the old Rate-custom `c_serviceAddressId` was renamed to
|
|
83
|
+
the standard `serviceAddressId` (`useActiveServices.ts`, `useBundleServices.ts`, `homeApi.ts`,
|
|
84
|
+
comments in `serviceAddressApi.ts`) to match the backend custom→standard field promotion.
|
|
85
|
+
- **The old `contact.primaryContactAddress` display fallback was REMOVED** (`useBundleServices.ts`,
|
|
86
|
+
`homeApi.ts`). The contact's primary address is **one shared value per contact**, overwritten by
|
|
87
|
+
each purchase — so falling back to it made **every unpinned card show the same/latest address**.
|
|
88
|
+
A card now shows only the entitlement's own pin, rendering addressless when unpinned (truthful —
|
|
89
|
+
it surfaces missing pins instead of masking them).
|
|
90
|
+
|
|
91
|
+
Legacy address path (contact-derived — no longer used for the card's address, retained only as
|
|
92
|
+
context for older data): `Entitlements.contactId → Contacts.primaryContactAddressId →
|
|
93
|
+
ContactAddresses.addressId → Addresses`.
|
|
76
94
|
|
|
77
95
|
Contract number comes from `Entitlements.number` (auto-incremented with ET prefix, e.g. `ET100017`).
|
|
78
96
|
|
|
@@ -96,8 +114,26 @@ product types would need `detectServiceType` extended or overridden.
|
|
|
96
114
|
`AddSubscriptionSheet`) replaces the old "Get Started" inactive bundle cards.
|
|
97
115
|
- **`useBundleServices` is shared** — both `useHomePageViewModel` and `useServicePageViewModel`
|
|
98
116
|
call it. Changes to the hook affect both pages.
|
|
117
|
+
- **Show the entitlement's OWN pin only — never the contact's shared address.** The removed
|
|
118
|
+
`contact.primaryContactAddress` fallback made every unpinned card display the same (latest)
|
|
119
|
+
address because that field is one shared, purchase-overwritten value per contact. An unpinned
|
|
120
|
+
entitlement must render addressless.
|
|
121
|
+
- **The services fetch degrades gracefully on field-permission errors.** `apiErrors.ts`
|
|
122
|
+
`isFieldPermissionError()` treats **`EV-8`** (field not registered — `identifiers.field`,
|
|
123
|
+
singular) the **same as `EZ-2`** (no read ACL — `identifiers.fields`, array). When the
|
|
124
|
+
`serviceAddressId` field is missing or ungranted on the API, the services fetch **degrades to
|
|
125
|
+
addressless rendering instead of failing** — which makes the FE deploy order **independent** of
|
|
126
|
+
the backend field-registration / ACL-grant migrations (see the WH guard doc's launch-blocker and
|
|
127
|
+
symptom-signature notes). Do not narrow this back to a single code.
|
|
99
128
|
|
|
100
129
|
## Change history
|
|
101
130
|
|
|
131
|
+
- 2026-07-23 — TRUE-79533 (FE side): warranty card address now comes from the entitlement's **own**
|
|
132
|
+
`serviceAddressId` pin (fetched by id via `fetchAddressesByIds`), renamed all FE
|
|
133
|
+
`c_serviceAddressId` refs to the standard `serviceAddressId`, and **removed the
|
|
134
|
+
`contact.primaryContactAddress` display fallback** (it was one shared value per contact, so every
|
|
135
|
+
unpinned card showed the latest address). Unpinned entitlements now render addressless. Hardened
|
|
136
|
+
`apiErrors.ts isFieldPermissionError()` to treat `EV-8` like `EZ-2` so the services fetch degrades
|
|
137
|
+
to addressless rendering (FE deploy order independent of the DB field/ACL migrations). (tcox)
|
|
102
138
|
- 2026-06-23 — Initial implementation: per-entitlement card display, new ServiceCard component,
|
|
103
139
|
address via LEFT JOIN, 3-card home limit with View All Services tertiary button (bala)
|
|
@@ -7,13 +7,14 @@ client: rate
|
|
|
7
7
|
type: client-feature
|
|
8
8
|
status: draft
|
|
9
9
|
updated: 2026-07-23
|
|
10
|
-
owners: [mhammontree]
|
|
10
|
+
owners: [mhammontree, tcox]
|
|
11
11
|
files:
|
|
12
12
|
- _underscore/Model/Rate/Entitlement.php
|
|
13
13
|
- _underscore/Model/Client/Entitlement.php
|
|
14
14
|
- _underscore/Model/Client/Address.php
|
|
15
15
|
- dbchanges2/Client/2026-07-22a - EntitlementServiceAddressId.sql
|
|
16
16
|
- dbchanges2/Core/2026-07-22a - EntitlementServiceAddressIdField.sql
|
|
17
|
+
- dbchanges2/Client/2026-07-23a - EntitlementServiceAddressIdFieldPermission.sql
|
|
17
18
|
- test/@Mark/Rate/verify_wholehome_per_address_guard.php
|
|
18
19
|
related:
|
|
19
20
|
- clients/rate/profile.md
|
|
@@ -30,6 +31,13 @@ related:
|
|
|
30
31
|
> below as forward-looking until the production purchase flow is verified. See the
|
|
31
32
|
> **shared-interceptor merge hazard** gotcha and the **dangling-pin** / **unguarded prod
|
|
32
33
|
> interceptor** gotchas before merging to `_production`.
|
|
34
|
+
>
|
|
35
|
+
> **🚨 LAUNCH BLOCKER (added 2026-07-23, tcox) — the standard `serviceAddressId` field has NO
|
|
36
|
+
> READ ACL grant.** The custom→standard redesign shipped the column + `Core.RecordFields` row
|
|
37
|
+
> but **not** the `AclFieldPermissions` read grant, so `GET /v2/entitlements?fields=serviceAddressId`
|
|
38
|
+
> returns **403 `EZ-2`** for roles 1/2/4. Prod does **not** launch clean without the new
|
|
39
|
+
> `Client/2026-07-23a` field-permission migration below — see
|
|
40
|
+
> **[Launch blocker: standard field needs a READ ACL grant](#launch-blocker-true-79533--the-standard-field-needs-a-read-acl-grant)**.
|
|
33
41
|
|
|
34
42
|
## Summary
|
|
35
43
|
|
|
@@ -132,6 +140,52 @@ during backend review (Jeff). This is the single biggest change since the beta b
|
|
|
132
140
|
registration was redundant (see the **unguarded prod interceptor** gotcha) and its backfill was
|
|
133
141
|
dropped (prod launches clean).
|
|
134
142
|
|
|
143
|
+
## Launch blocker (TRUE-79533) — the standard field needs a READ ACL grant
|
|
144
|
+
|
|
145
|
+
**The custom→standard redesign shipped the column and the `Core.RecordFields` row but NOT the
|
|
146
|
+
`AclFieldPermissions` READ grant.** Probe-verified on **beta 2026-07-23**:
|
|
147
|
+
`GET /v2/entitlements?fields=serviceAddressId` returns **403 `EZ-2`** for roles 1, 2, and 4
|
|
148
|
+
(Entitlements has `Core.Records.aclDatabase = 'CLIENT'`, so the grant lives in each client DB).
|
|
149
|
+
Without the grant, **production launches with the exact `EZ-2` failure beta hit** — the field is
|
|
150
|
+
registered but unreadable, so the service cards cannot fetch the pin.
|
|
151
|
+
|
|
152
|
+
**Standard vs. custom field permissions (the reason the earlier fix is now moot).** A **standard**
|
|
153
|
+
field is authorized through **`AclFieldPermissions`**, keyed by the **`Core.RecordFields` id**. A
|
|
154
|
+
**custom** (`c_`) field was authorized through **`AclCustomFieldPermissions`**, keyed by the
|
|
155
|
+
`CustomRecordFields` id. The old custom-field grant (**TRUE-80176**) targeted
|
|
156
|
+
`AclCustomFieldPermissions` and is now **moot — close it unmerged**: its guarded INSERT no-ops
|
|
157
|
+
because the `CustomRecordFields` row for `c_serviceAddressId` was deleted in the standard-field
|
|
158
|
+
redesign. The read grant now has to be an **`AclFieldPermissions`** row instead.
|
|
159
|
+
|
|
160
|
+
**Needed migration (proposed, on the TRUE-79533 branch):**
|
|
161
|
+
`dbchanges2/Client/2026-07-23a - EntitlementServiceAddressIdFieldPermission.sql`, following the
|
|
162
|
+
existing `Client/2026-07-02c - TrackingNumberNeedsReturnLabelFieldPermission.sql` precedent:
|
|
163
|
+
- Resolve field ids via `Core.RecordFields` / `Core.Records` subselects (route `'entitlements'`,
|
|
164
|
+
fields `'serviceAddressId'` **and** its sibling `'number'`) — never hardcode ids.
|
|
165
|
+
- **Mirror the sibling `number` field's per-role grants**, but force **`isWritable = 0`**:
|
|
166
|
+
`serviceAddressId` is written **server-side** by the guard's `postPost`, never by the API caller,
|
|
167
|
+
so it must be read-only.
|
|
168
|
+
- Guard each INSERT with `NOT EXISTS` (verified against the Client blank DDL:
|
|
169
|
+
`AclFieldPermissions` is `UNIQUE (recordFieldId, roleId)`).
|
|
170
|
+
|
|
171
|
+
## Beta data migration — old-column pins are orphaned by the redesign (beta only, NO prod impact)
|
|
172
|
+
|
|
173
|
+
Entitlements purchased on **beta before the redesign** carry their pin in the old
|
|
174
|
+
`c_serviceAddressId` column; the new `serviceAddressId` column is **NULL** for them, so their cards
|
|
175
|
+
render **addressless**. One-time beta fix (prod launches clean, so this ships **nowhere near prod**):
|
|
176
|
+
|
|
177
|
+
```sql
|
|
178
|
+
UPDATE Client_Rate.Entitlements
|
|
179
|
+
SET serviceAddressId = c_serviceAddressId
|
|
180
|
+
WHERE serviceAddressId IS NULL AND c_serviceAddressId IS NOT NULL;
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
**Symptom signatures (which layer is misconfigured):**
|
|
184
|
+
- A **front-end `EV-8` on `c_serviceAddressId`** means the redesign SQL **ran** — the old custom
|
|
185
|
+
field is gone/unregistered (`EV-8` = field not registered).
|
|
186
|
+
- A **`EZ-2` on `serviceAddressId`** means the redesign ran but the **read ACL grant is missing**
|
|
187
|
+
(see the launch blocker above).
|
|
188
|
+
|
|
135
189
|
## Rate data model (prod-verified 2026-07-07)
|
|
136
190
|
|
|
137
191
|
- **WH product** = `Items.id 3` / `partNumber 1429124` "Whole Home Warranty - Monthly". Tech
|
|
@@ -243,6 +297,17 @@ live carrier waterfall is opt-in via `RUN_LIVE=1` (defaults off). Verified **18/
|
|
|
243
297
|
|
|
244
298
|
## Change history
|
|
245
299
|
|
|
300
|
+
- 2026-07-23 — **LAUNCH BLOCKER found: the standard `serviceAddressId` field has NO read ACL grant**
|
|
301
|
+
(tcox). Probe-verified on beta: `GET /v2/entitlements?fields=serviceAddressId` → 403 `EZ-2` for
|
|
302
|
+
roles 1/2/4 (`aclDatabase=CLIENT`). Standard fields are authorized via **`AclFieldPermissions`**
|
|
303
|
+
(keyed by `Core.RecordFields` id), **not** `AclCustomFieldPermissions` — so the old custom-field
|
|
304
|
+
grant **TRUE-80176 is moot** (close unmerged; its INSERT no-ops now that the `CustomRecordFields`
|
|
305
|
+
row is deleted). Fix: new `dbchanges2/Client/2026-07-23a - EntitlementServiceAddressIdFieldPermission.sql`
|
|
306
|
+
on the TRUE-79533 branch, mirroring sibling field `number`'s per-role grants with `isWritable=0`
|
|
307
|
+
(field is written server-side by `postPost`), resolving ids by route/field subselect and guarded
|
|
308
|
+
by `NOT EXISTS`. Also recorded the beta-only orphaned-pin backfill (old `c_serviceAddressId` pins
|
|
309
|
+
are NULL in the new column; `UPDATE … SET serviceAddressId = c_serviceAddressId`) and the
|
|
310
|
+
`EV-8`-on-`c_serviceAddressId` vs `EZ-2`-on-`serviceAddressId` symptom signatures. (tcox)
|
|
246
311
|
- 2026-07-23 — **Service-address field promoted CUSTOM → STANDARD** (TRUE-79533, reviewer Jeff)
|
|
247
312
|
and CodeRabbit hardenings, deploying beta→prod. `c_serviceAddressId` on `_Model_Rate_Entitlement`
|
|
248
313
|
is gone; `serviceAddressId` is now an FK on the **base** `_Model_Client_Entitlement`, registered
|
package/package.json
CHANGED