toga-ai 1.0.1067 → 1.0.1069
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 -1
- package/knowledge/2.0/apps/_underscore/architecture.md +10 -1
- package/knowledge/2.0/apps/_underscore/features/calculated-sql-fields.md +6 -1
- package/knowledge/2.0/apps/_underscore/features/contact-notes-files-types.md +70 -0
- package/knowledge/2.0/apps/_underscore/features/customer-locations-files-notes.md +32 -8
- package/knowledge/2.0/apps/_underscore/features/note-record-authorship.md +3 -2
- package/knowledge/2.0/apps/_underscore/features/record-activity-timeline.md +3 -2
- package/knowledge/2.0/apps/_underscore/features/record-hard-delete.md +16 -1
- package/knowledge/2.0/apps/api2/features/cross-client-data-retrieval.md +6 -1
- package/knowledge/2.0/apps/toga25-blox/INDEX.md +1 -0
- package/knowledge/2.0/apps/toga25-blox/features/contact-channels-save.md +45 -0
- package/knowledge/INDEX.md +2 -2
- package/package.json +1 -1
|
@@ -22,8 +22,9 @@
|
|
|
22
22
|
| [_Component_*/_Model_* project-namespace registration (autoloader) & backslash-qualify traps](features/component-model-namespace-registration.md) | Project-local `_Component_*`/`_Model_*` must declare the app namespace AND live in a `<Name>/<Name>.php` directory, or the autoloader fatals at runtime; open wh |
|
|
23
23
|
| [_Config group access — the two-argument "optional" form does NOT rescue a missing GROUP](features/config-group-access.md) | `_Config::<group>('prop', false)` does NOT rescue a MISSING GROUP (only a missing prop) — guard with `_Config::isGroupSet()`; open before reading per-environmen |
|
|
24
24
|
| [Contacts require a customerId — the Internal customer & every contact-creation path](features/contact-customer-required-internal-customer.md) | Every `Client_<Tenant>.Contacts` row must have a `customerId`; with no real customer, use the shared Internal customer (fixed uuid in every tenant). |
|
|
25
|
+
| [Contact related records — notes, files, contact types (slug), delete](features/contact-notes-files-types.md) | Shared Client-DB data model around a Contact (notes, files, typed contact types, delete); open before linking anything to a contact, keying on a contact type, o |
|
|
25
26
|
| [Core.Domains — the app host registry (and why you cannot derive a host)](features/core-domains-app-host-registry.md) | `Core.Domains` is the authoritative host↔client↔app↔env registry; a host is NOT derivable from env slug or api host — read the table. |
|
|
26
|
-
| [Customer
|
|
27
|
+
| [Customer data model — tiers, industries, customer since, locations, files, notes](features/customer-locations-files-notes.md) | Shared Client-DB data model around a Customer (tier, industry, customer-since, site/contact counts, locations, files, notes); open before adding a customer fiel |
|
|
27
28
|
| [Re-pointing a DB alias mid-request (_Database::register park/restore)](features/database-alias-repointing.md) | `_Database` keys **all live per-database runtime state by the connection ALIAS** (`Client` / `_underscore::DB_CLIENT`, `ClientLogs`, `Archive`), **not** by the |
|
|
28
29
|
| [Effective persona resolution (_Model_Client_User::_personaIds) — the three sources and the sector hop](features/effective-persona-resolution.md) | A user's EFFECTIVE persona set = UNION of three sources (`Users_Personas` + own-location + 5-hop sector `Locations_Personas`), owned by `_personaIds()`; open be |
|
|
29
30
|
| [2.0 Email Send Pipeline (queue + Send worker)](features/email-send-pipeline.md) | In 2.0, `_Email::send()` **does not transmit** — it queues the message. |
|
|
@@ -6,7 +6,7 @@ project: _Underscore
|
|
|
6
6
|
client: shared
|
|
7
7
|
type: architecture
|
|
8
8
|
status: active
|
|
9
|
-
updated: 2026-10-
|
|
9
|
+
updated: 2026-10-17
|
|
10
10
|
owners: ["jcardinal", "rgirish", "mhammontree", "ajean"]
|
|
11
11
|
files:
|
|
12
12
|
- _underscore/_underscore.php
|
|
@@ -436,7 +436,16 @@ multi-file UI components (`.php`/`.html`/`.css`/`.js`) invoked as `<_ComponentNa
|
|
|
436
436
|
message/trace to the client if `display_errors` is on unconditionally. Confirm `display_errors` is
|
|
437
437
|
disabled in production and that the framework's error surface returns a sanitized envelope (not a raw
|
|
438
438
|
stack trace) to API consumers. Not yet done — tracked as a follow-up.
|
|
439
|
+
- **`class_exists('X')` throws, not false, for an unknown class.** It runs the `_Loader` autoloader,
|
|
440
|
+
which throws on a missing file → 500 EO-1. For optional PHP-extension classes (e.g. `Transliterator`,
|
|
441
|
+
intl is missing on local XAMPP) call `class_exists('\X', false)`. Live case:
|
|
442
|
+
`_Model_Client_CustomerLookup::deriveSlug()` (TRUE-82903).
|
|
443
|
+
- **A NOT NULL column's DB default never applies to a model INSERT.** `_Model` writes every declared
|
|
444
|
+
field explicitly, so a `FIELD_INTEGER` with no `FIELDOPT_DEFAULT` inserts NULL → MySQL 1048 on a
|
|
445
|
+
`NOT NULL DEFAULT 0` column. Declare `FIELDOPT_DEFAULT` on the model to match every NOT NULL column
|
|
446
|
+
default. Live cases: `CustomerTier` / `Industry.sortOrder` (TRUE-82903), `Cache Tables.safeRecordCount`.
|
|
439
447
|
|
|
440
448
|
## Change history
|
|
441
449
|
|
|
442
450
|
- 2026-10-07 — Corrected tenant DB-name resolution: resolve `Core.Clients` id columns into `Core.Databases`; `clientIdentifier` is not the suffix (Compass). (mhammontree)
|
|
451
|
+
- 2026-10-17 — Gotchas: `class_exists` autoload throws; model INSERT ignores DB column defaults (TRUE-82903). (jcardinal)
|
|
@@ -6,7 +6,7 @@ project: _Underscore
|
|
|
6
6
|
client: shared
|
|
7
7
|
type: feature
|
|
8
8
|
status: active
|
|
9
|
-
updated: 2026-
|
|
9
|
+
updated: 2026-10-17
|
|
10
10
|
owners: [snaredla, jcardinal, bala, apeterson, rgirish]
|
|
11
11
|
files:
|
|
12
12
|
- _underscore/Model.php
|
|
@@ -289,8 +289,13 @@ Keep the expression cheap, or expect callers to pay for it on every list.
|
|
|
289
289
|
same-parent child inserts then deadlock (MySQL 1213). This is exactly how `PurchaseOrder._total`
|
|
290
290
|
deadlocked the ASN path; see
|
|
291
291
|
[save-cascade stored-field deadlocks](./model-save-parent-cascade-stored-field-deadlock.md).
|
|
292
|
+
- **Count calc fields are for listing columns only — not detail-tab counts.** A calc field skips the
|
|
293
|
+
child's row-ACL filters and cannot count across the Core vs Client cluster split. A detail tab's count
|
|
294
|
+
= the embedded tab table's own list total (`fields=uuid`, page size 1, `meta.totalRecordCount`). Full
|
|
295
|
+
rule: toga25-desk `docs/decisions/0153-*`, PLATFORM rule 37.
|
|
292
296
|
|
|
293
297
|
## Change history
|
|
298
|
+
- 2026-10-17 — Count calc fields: listing columns only; detail-tab counts use the list total (TRUE-82903). (jcardinal)
|
|
294
299
|
- 2026-09-17 — Recorded a live instance of the `self::` trap: `_Model_Client_Item`'s four composite
|
|
295
300
|
fields (`_qtyOnHand`, `_qtyAvailable`, `_qtyBackordered`, `_qtyOnOrder`) used **`self::`**, so a
|
|
296
301
|
client subclass overriding a *leaf* `_qty*` method was silently ignored unless it re-declared all
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Contact related records — notes, files, contact types (slug), delete
|
|
3
|
+
framework: "2.0"
|
|
4
|
+
repo: _underscore
|
|
5
|
+
project: _Underscore
|
|
6
|
+
client: shared
|
|
7
|
+
type: feature
|
|
8
|
+
status: active
|
|
9
|
+
updated: 2026-10-17
|
|
10
|
+
owners: [jcardinal]
|
|
11
|
+
files:
|
|
12
|
+
- _underscore/Model/Client/Contact.php
|
|
13
|
+
- _underscore/Model/Client/ContactNote.php
|
|
14
|
+
- _underscore/Model/Client/Contacts/File.php
|
|
15
|
+
- _underscore/Model/Client/ContactType.php
|
|
16
|
+
- dbchanges2/Client/2026-10-17c - ContactsTypesFilesNotes.sql
|
|
17
|
+
- dbchanges2/Core/2026-10-17e - TRUE-82871 ContactsHiFiCore.sql
|
|
18
|
+
- dbchanges2/Core/2026-10-17f - TRUE-82871 ContactDeleteInterceptor.sql
|
|
19
|
+
related:
|
|
20
|
+
- ./customer-locations-files-notes.md
|
|
21
|
+
- ./record-hard-delete.md
|
|
22
|
+
- ./record-activity-timeline.md
|
|
23
|
+
- ./note-record-authorship.md
|
|
24
|
+
- ./contact-customer-required-internal-customer.md
|
|
25
|
+
- ../../toga25-blox/features/contact-channels-save.md
|
|
26
|
+
---
|
|
27
|
+
Shared Client-DB data model around a Contact (notes, files, typed contact types, delete); open before linking anything to a contact, keying on a contact type, or deleting a contact.
|
|
28
|
+
|
|
29
|
+
## Summary
|
|
30
|
+
|
|
31
|
+
Added 2026-10-17 (TRUE-82871, built for TOGa Desk, usable by any app). Copies the Customer notes/files pattern
|
|
32
|
+
([customer related records](./customer-locations-files-notes.md)) — read that doc for the shared shape.
|
|
33
|
+
|
|
34
|
+
| Piece | Shape | Core ids |
|
|
35
|
+
|---|---|---|
|
|
36
|
+
| `ContactTypes.slug` | `VARCHAR(64)` unique; seeded `primary`, `technical`, `billing`, `executive` in every client DB | RecordFields 2815 (Records 169) |
|
|
37
|
+
| `Contacts_Files` | copy of `Customers_Files` | Records 385 `contact-files`, RecordFields 2816-2819 |
|
|
38
|
+
| `ContactNotes` | copy of `CustomerNotes`: write-once, server-stamped `authorUserId` | Records 386 `contact-notes`, RecordFields 2820-2825 |
|
|
39
|
+
| Delete | PRE DELETE interceptor on Records 37 | `Core/2026-10-17f` |
|
|
40
|
+
|
|
41
|
+
## How it works
|
|
42
|
+
|
|
43
|
+
- **Contact types:** presets, filters, and code key on `slug`, never `name` (names are display copy). Badge color
|
|
44
|
+
comes from the Surface vocabulary `contact-type` (term value = slug) + neutral ThemeTokens — no color column.
|
|
45
|
+
An existing row named Primary/Technical/Billing/Executive got the slug instead of a duplicate row.
|
|
46
|
+
- **Inherent children:** `contacts → contact-files` (`contactFiles[]`), `contacts → contact-notes` (`contactNotes[]`),
|
|
47
|
+
both isParentTouched 1.
|
|
48
|
+
- **ContactNotes author:** `authorUserId` from `_Model::getRequestUserId()` in `save()`; no client user (API key,
|
|
49
|
+
worker) → 400; updates refused. See [note authorship](./note-record-authorship.md).
|
|
50
|
+
- **Client ACL:** granted to every role with a global read on contacts. 169: read (slug read-only). 385: CRUD.
|
|
51
|
+
386: create + read, only `note` writable.
|
|
52
|
+
- **Activity:** root `contacts` → surface `contact-activity`; `contact-notes` in `CREATE_HIDDEN_ROUTES`
|
|
53
|
+
([record activity timeline](./record-activity-timeline.md)).
|
|
54
|
+
- **Delete:** block / detach / cascade map in `Contact::relatedRows()` — full list in
|
|
55
|
+
[record hard delete](./record-hard-delete.md) (Contact rule). A blocked delete returns a 400 naming what links it.
|
|
56
|
+
- **Saving primary email/phone from a form:** blox `updateContactChannels` (see
|
|
57
|
+
[contact channels save](../../toga25-blox/features/contact-channels-save.md)).
|
|
58
|
+
|
|
59
|
+
## Gotchas
|
|
60
|
+
|
|
61
|
+
- **Run order is Client first:** `Client/2026-10-17c` on every client DB → deploy `_underscore` → `Core/2026-10-17e`
|
|
62
|
+
and `f` (the Client file reads nothing from Core; Core rows point at models/tables that must already exist).
|
|
63
|
+
- **New `fileId` FK table → `FILE_REFERENCE_TABLES`.** `Contacts_Files` was added; without it a customer/case purge
|
|
64
|
+
could delete a Files row a contact still uses.
|
|
65
|
+
- **Client schemas drift** (e.g. `Client_True.TransferOrders` has no `contactId`) — the delete map probes optional
|
|
66
|
+
columns with `columnExists()`.
|
|
67
|
+
- **TableViewPresets are per-user** (`userId` NOT NULL) — "default preset views" from a design cannot be seeded.
|
|
68
|
+
|
|
69
|
+
## Change history
|
|
70
|
+
- 2026-10-17 — Created: ContactTypes.slug + seeds, Contacts_Files, ContactNotes, contact activity root, contact delete (TRUE-82871). (jcardinal)
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
---
|
|
2
|
-
title: Customer
|
|
2
|
+
title: Customer data model — tiers, industries, customer since, locations, files, notes
|
|
3
3
|
framework: "2.0"
|
|
4
4
|
repo: _underscore
|
|
5
5
|
project: _Underscore
|
|
@@ -10,39 +10,59 @@ updated: 2026-10-17
|
|
|
10
10
|
owners: [jcardinal]
|
|
11
11
|
files:
|
|
12
12
|
- _underscore/Model/Client/Customer.php
|
|
13
|
+
- _underscore/Model/Client/CustomerLookup.php
|
|
14
|
+
- _underscore/Model/Client/CustomerTier.php
|
|
15
|
+
- _underscore/Model/Client/Industry.php
|
|
13
16
|
- _underscore/Model/Client/Locations/Customer.php
|
|
14
17
|
- _underscore/Model/Client/Customers/File.php
|
|
15
18
|
- _underscore/Model/Client/CustomerNote.php
|
|
16
|
-
- dbchanges2/
|
|
19
|
+
- dbchanges2/Client/2026-10-17e - TRUE-82903 CustomerTiersIndustries.sql
|
|
20
|
+
- dbchanges2/Core/2026-10-17h - TRUE-82903 CustomerTiersIndustries.sql
|
|
17
21
|
- dbchanges2/Client/2026-10-17b - CustomerLocationsFilesNotes.sql
|
|
18
|
-
- dbchanges2/Core/2026-10-
|
|
22
|
+
- dbchanges2/Core/2026-10-17b - CustomerLocationsFilesNotesActivity.sql
|
|
19
23
|
related:
|
|
20
24
|
- ./record-hard-delete.md
|
|
21
25
|
- ./record-activity-timeline.md
|
|
22
26
|
- ./note-record-authorship.md
|
|
23
27
|
- ./tableview-joins.md
|
|
28
|
+
- ./calculated-sql-fields.md
|
|
24
29
|
- ./model-save-parent-cascade-stored-field-deadlock.md
|
|
25
30
|
- ./contact-customer-required-internal-customer.md
|
|
26
31
|
- ./acl-permission-chain.md
|
|
32
|
+
- ../../api2/features/api-payload-interceptors.md
|
|
27
33
|
---
|
|
28
|
-
Shared Client-DB data model around a Customer (
|
|
34
|
+
Shared Client-DB data model around a Customer (tier, industry, customer-since, site/contact counts, locations, files, notes); open before adding a customer field, a customer lookup, or linking anything to a customer.
|
|
29
35
|
|
|
30
36
|
## Summary
|
|
31
37
|
|
|
32
|
-
|
|
38
|
+
Built for TOGa Desk (TRUE-82870, TRUE-82903), usable by any app. All tables live in every `Client_*` DB.
|
|
33
39
|
|
|
34
40
|
| Piece | Shape | Core ids |
|
|
35
41
|
|---|---|---|
|
|
36
42
|
| `Customers.primaryLocationId` | FK → Locations, standard field | RecordFields 2799 |
|
|
37
43
|
| `Customers._contactCount` | non-stored `FIELD_SQL` COUNT of Contacts by `customerId` | RecordFields 2800 |
|
|
44
|
+
| `Customers.customerTierId` | nullable FK → CustomerTiers | RecordFields 2826-2841 (with below) |
|
|
45
|
+
| `Customers.industryId` | nullable FK → Industries | |
|
|
46
|
+
| `Customers.dateCustomerSince` | `DATE` | |
|
|
47
|
+
| `Customers._siteCount` | non-stored `FIELD_SQL` COUNT of `Locations_Customers` rows | |
|
|
48
|
+
| `CustomerTiers` | lookup (name, slug, sortOrder, isActive) | Records 387 `customer-tiers` |
|
|
49
|
+
| `Industries` | lookup, same shape | Records 388 `industries` |
|
|
38
50
|
| `Locations_Customers` | many-to-many bridge, `UNIQUE (locationId, customerId)` | Records 382 `location-customers` |
|
|
39
51
|
| `Customers_Files` | copy of `Cases_Files`, `UNIQUE (customerId, fileId)` | Records 383 `customer-files` |
|
|
40
52
|
| `CustomerNotes` | copy of `CaseNotes` minus stage: write-once, internal | Records 384 `customer-notes` |
|
|
41
53
|
|
|
42
|
-
RecordFields 2801-2814 cover
|
|
54
|
+
RecordFields 2801-2814 cover 382-384. SQL: `Client/2026-10-17e` + `Core/2026-10-17h` (tiers/industries), `Client/2026-10-17b` + `Core/2026-10-17b` (locations/files/notes).
|
|
43
55
|
|
|
44
56
|
## How it works
|
|
45
57
|
|
|
58
|
+
- **Customer lookups (`CustomerTiers`, `Industries`)** extend abstract `_Model_Client_CustomerLookup`. Its PRE
|
|
59
|
+
POST/PUT/PATCH/DELETE api2 interceptors: trim `name`; derive `slug` from `name`; duplicate slug → readable 400;
|
|
60
|
+
`sortOrder` = MAX+1 when not sent; delete blocked while any customer points at the row. A new customer lookup =
|
|
61
|
+
subclass + `customerColumn()` + `deleteBlockedMessage()`. Subclasses declare `FIELDOPT_DEFAULT` for every
|
|
62
|
+
NOT NULL column (`sortOrder` 0, `isActive` true) — the model writes NULL otherwise.
|
|
63
|
+
- **Tier colours are data, not code:** Surface vocabulary `customer-tier` (kind TYPE) + client ThemeTokens
|
|
64
|
+
`color.tier.<slug>`.
|
|
65
|
+
- **"Customer since" = `dateCustomerSince`.** Never use `dtCreated` for it (that is the row's insert time).
|
|
46
66
|
- **Location of a customer:** use `primaryLocationId` (a real join). `_location` (city, state from the bill-to
|
|
47
67
|
Address) is kept for its existing readers — do not build new features on it.
|
|
48
68
|
- **Bridge, not `Locations.customerId`:** a location may belong to many customers (developer decision).
|
|
@@ -61,15 +81,19 @@ RecordFields 2801-2814 cover the three new records.
|
|
|
61
81
|
|
|
62
82
|
## Gotchas
|
|
63
83
|
|
|
84
|
+
- **`deriveSlug()` checks `Transliterator` with `class_exists('\X', false)`** — rule lives in
|
|
85
|
+
[_underscore architecture § Gotchas](../architecture.md#gotchas).
|
|
64
86
|
- **Never OUTER-join a many-to-many bridge into a shared TableView.** blox sends every join of a view on every
|
|
65
87
|
request (`buildJoinOptions`), so the general listing repeats each row once per bridge match. Make a
|
|
66
88
|
separate parent-scoped view instead (rule lives in [tableview-joins](./tableview-joins.md)).
|
|
67
|
-
- **`_contactCount` must stay non-stored** — a stored self-aggregate deadlocks the save cascade
|
|
68
|
-
([details](./model-save-parent-cascade-stored-field-deadlock.md)).
|
|
89
|
+
- **`_contactCount` / `_siteCount` must stay non-stored** — a stored self-aggregate deadlocks the save cascade
|
|
90
|
+
([details](./model-save-parent-cascade-stored-field-deadlock.md)). They are for listing columns; detail-tab
|
|
91
|
+
counts come from the tab table's own list total (see [calculated fields](./calculated-sql-fields.md)).
|
|
69
92
|
- **Assets link to a customer through Contacts** (`Units.contactId → Contacts.customerId`), not directly.
|
|
70
93
|
- **Copied RecordFields need `name`.** `CaseNotes` RecordFields 2743-2749 have NULL `name` (not fixed), so the
|
|
71
94
|
copy 2809-2814 came out NULL too; filled by `Core/2026-10-17d`. Set `name` on every new RecordField.
|
|
72
95
|
|
|
73
96
|
## Change history
|
|
97
|
+
- 2026-10-17 — Added CustomerTiers (387), Industries (388), `_Model_Client_CustomerLookup` interceptors, `customerTierId`, `industryId`, `dateCustomerSince`, `_siteCount` (TRUE-82903). (jcardinal)
|
|
74
98
|
- 2026-10-17 — Filled NULL `name` on RecordFields 2809-2814 (Core/2026-10-17d). (jcardinal)
|
|
75
99
|
- 2026-10-17 — Created: primaryLocationId, _contactCount, Locations_Customers, Customers_Files, CustomerNotes (TRUE-82870). (jcardinal)
|
|
@@ -6,7 +6,7 @@ project: _Underscore
|
|
|
6
6
|
client: shared
|
|
7
7
|
type: feature
|
|
8
8
|
status: active
|
|
9
|
-
updated: 2026-10-
|
|
9
|
+
updated: 2026-10-17
|
|
10
10
|
owners: [apeterson, jcardinal]
|
|
11
11
|
files:
|
|
12
12
|
- _underscore/Model/Client/SalesOrderNote.php
|
|
@@ -63,10 +63,11 @@ Before building anything that shows or filters by note author:
|
|
|
63
63
|
|
|
64
64
|
- **`contactId` on a `*Notes` model is an unwritten FK — treat it as always NULL.** Seven models, zero writers.
|
|
65
65
|
- **A notes list UI has no author to show.** Design for it, or the field silently renders blank for every row.
|
|
66
|
-
- **Exception — `CaseNotes` (2026-10-15) and `
|
|
66
|
+
- **Exception — `CaseNotes` (2026-10-15), `CustomerNotes` and `ContactNotes` (2026-10-17) have a real author:** `authorUserId` stamped in `::save()` from `_Model::getRequestUserId()`. Copy that pattern for the real fix. The `save()` body is now copied 3 times (Case, Customer, Contact) — move it to a shared trait/base before a 4th copy. See [Case status + stage gate](./case-status-and-stage-gate.md), [Customer related records](./customer-locations-files-notes.md), [Contact related records](./contact-notes-files-types.md).
|
|
67
67
|
- **`dtStamp` is `FIELD_DATETIME_CREATED`** — stamped by the ORM on insert, the only provenance a note actually carries.
|
|
68
68
|
|
|
69
69
|
## Change history
|
|
70
|
+
- 2026-10-17 — ContactNotes added as third server-stamped-author note model; save() body is a consolidation candidate (TRUE-82871). (jcardinal)
|
|
70
71
|
- 2026-10-17 — CustomerNotes added as a second server-stamped-author note model (TRUE-82870). (jcardinal)
|
|
71
72
|
- 2026-10-15 — Noted CaseNotes as the first note model with a server-stamped author. (jcardinal)
|
|
72
73
|
|
|
@@ -32,7 +32,7 @@ client `AclRecordScripts`. Data source: [record change audit log](record-change-
|
|
|
32
32
|
|
|
33
33
|
- **Roots** = `ROOT_ACTIVITY_SURFACE_SLUGS` (route → activity Surface slug): `tickets` →
|
|
34
34
|
`ticket-activity`, `units` → `asset-activity`, `cases` → `case-activity`, `service-requests` →
|
|
35
|
-
`service-request-activity`, `customers` → `customer-activity`. Any other route → 400. **A new root = one map entry + its Surface
|
|
35
|
+
`service-request-activity`, `customers` → `customer-activity`, `contacts` → `contact-activity`. Any other route → 400. **A new root = one map entry + its Surface
|
|
36
36
|
(`dbchanges2` `Core/`).** Engine (`Source` reads Logs, `Reader` checks as the reader through api2)
|
|
37
37
|
is generic; no new client ACL needed for a new root.
|
|
38
38
|
- **Children:** `Logs.RecordParent`, level by level, max `Source::MAX_DEPTH` (3) levels and
|
|
@@ -45,7 +45,7 @@ client `AclRecordScripts`. Data source: [record change audit log](record-change-
|
|
|
45
45
|
`isVisible = 0`). An UPDATE with no visible field is dropped. Secrets → `{redacted:true}`.
|
|
46
46
|
4. FK values → `{uuid, label[, slug]}`, `{missing:true}`, or `{value:null}` — never a raw id.
|
|
47
47
|
- **CREATE rows of `CREATE_HIDDEN_ROUTES`** (`ticket-notes`, `ticket-note-files`, `unit-notes`,
|
|
48
|
-
`case-notes`, `service-request-notes`, `customer-notes`) are dropped — the host screen merges the notes feed.
|
|
48
|
+
`case-notes`, `service-request-notes`, `customer-notes`, `contact-notes`) are dropped — the host screen merges the notes feed.
|
|
49
49
|
Note edits/deletes still show.
|
|
50
50
|
- **Paging:** cursor = `Logs.Record` id; `nextCursor` null at the end. Scan bounded
|
|
51
51
|
(`MAX_SCAN_BATCHES` × `SCAN_BATCH_SIZE`); a page cut by the bound still returns a `nextCursor`.
|
|
@@ -69,6 +69,7 @@ client `AclRecordScripts`. Data source: [record change audit log](record-change-
|
|
|
69
69
|
(schema + api2 change) — not built.
|
|
70
70
|
|
|
71
71
|
## Change history
|
|
72
|
+
- 2026-10-17 — Added root `contacts` + `contact-notes` to `CREATE_HIDDEN_ROUTES` (TRUE-82871). (jcardinal)
|
|
72
73
|
- 2026-10-17 — Added root `customers` + `customer-notes` to `CREATE_HIDDEN_ROUTES` (TRUE-82870). (jcardinal)
|
|
73
74
|
- 2026-10-17 — One-FK rule for parent links; no request id for grouping (TRUE-82889). (jcardinal)
|
|
74
75
|
- 2026-10-15 — Added roots `units`, `cases`, `service-requests` + their note routes to `CREATE_HIDDEN_ROUTES` (TRUE-82836). (jcardinal)
|
|
@@ -11,6 +11,7 @@ owners: [jcardinal]
|
|
|
11
11
|
files:
|
|
12
12
|
- _underscore/Component/RecordDelete/RecordDelete.php
|
|
13
13
|
- _underscore/Model/Client/Customer.php
|
|
14
|
+
- _underscore/Model/Client/Contact.php
|
|
14
15
|
- _underscore/Model/Client/Ticket.php
|
|
15
16
|
- _underscore/Model/Client/Case.php
|
|
16
17
|
- _underscore/Model/Client/ServiceRequest.php
|
|
@@ -26,7 +27,7 @@ Map-driven hard delete of everything hanging off one record (block / detach / de
|
|
|
26
27
|
`DELETE /v2/<route>/{uuid}` on an opted-in record runs a PRE DELETE interceptor that calls
|
|
27
28
|
`_Component_RecordDelete::purge(['model' => static::class, 'id' => $id])`. `purge()` cleans the related
|
|
28
29
|
rows from a map the model declares in `relatedRows()`; api2 then deletes the record row itself. Used by
|
|
29
|
-
Tickets, Cases, ServiceRequests, Customers. The class DocBlock is the full contract — read it before editing.
|
|
30
|
+
Tickets, Cases, ServiceRequests, Customers, Contacts. The class DocBlock is the full contract — read it before editing.
|
|
30
31
|
|
|
31
32
|
## How it works
|
|
32
33
|
|
|
@@ -44,9 +45,22 @@ Tickets, Cases, ServiceRequests, Customers. The class DocBlock is the full contr
|
|
|
44
45
|
- **Customer rule (Records 113, 2026-10-17):** block on Contacts / SalesOrders / PurchaseOrders; detach Tickets /
|
|
45
46
|
ServiceRequests / Cases `customerId`; delete Customers_Catalogs, Customers_Subscriptions, Locations_Customers,
|
|
46
47
|
Customers_Files, CustomerNotes rows; orphan Files from Customers_Files.
|
|
48
|
+
- **Contact rule (Records 37, interceptor `Core/2026-10-17f`):** block on Users, Tickets (`contactId`,
|
|
49
|
+
`reportedByContactId`, `assignedToContactId`, and `contactPhoneNumberId` via the contact's phone rows),
|
|
50
|
+
Tickets_Contacts, ServiceRequests.requestedForContactId, Units, SalesOrders (`contactId`, `requestedByContactId`),
|
|
51
|
+
PurchaseOrders, Entitlements, TransferOrders.contactId (optional). Detach the `contactId` author column of every
|
|
52
|
+
`*Notes` table (TransferOrderNotes optional) and Files.contactId (optional). Delete Contacts_Files (+ orphan Files),
|
|
53
|
+
ContactNotes, ContactAttempts, ContactMessagingOptIns, Campaigns_Contacts, Campaigns_MeetWithContacts,
|
|
54
|
+
Contacts_Tags, Contacts_Vendors, Contacts_Locations, Locations_Contacts, ContactAddresses / EmailAddresses /
|
|
55
|
+
PhoneNumbers. `preDelete` clears the contact's `primary*` pointers first (they and the children point at each other).
|
|
47
56
|
|
|
48
57
|
## Gotchas
|
|
49
58
|
|
|
59
|
+
- **Client schemas differ — probe optional tables/columns.** A map entry for a table or column missing in one
|
|
60
|
+
`Client_*` DB fails that client's delete. Contact checks with `columnExists()` (`SHOW TABLES` / `SHOW COLUMNS`,
|
|
61
|
+
read host off) before adding TransferOrders / TransferOrderNotes / Files `contactId` entries — e.g.
|
|
62
|
+
`Client_True` has TransferOrders without `contactId`. Do the same for any table not in every client DB.
|
|
63
|
+
|
|
50
64
|
- **New table with a `fileId` FK → add it to `FILE_REFERENCE_TABLES`.** Missing it makes the Files DELETE fail on
|
|
51
65
|
the FK and roll back every delete that touches a shared file (loud, not silent).
|
|
52
66
|
- **Client override of `relatedRows()` must merge `parent::relatedRows()` key by key;** a client `preDelete`
|
|
@@ -57,4 +71,5 @@ Tickets, Cases, ServiceRequests, Customers. The class DocBlock is the full contr
|
|
|
57
71
|
losing the link would lose meaning (orders, required FKs like `Contacts.customerId`).
|
|
58
72
|
|
|
59
73
|
## Change history
|
|
74
|
+
- 2026-10-17 — Contacts opted in (block / detach / cascade map, schema-drift probes); Contacts_Files added to FILE_REFERENCE_TABLES (TRUE-82871). (jcardinal)
|
|
60
75
|
- 2026-10-17 — Created; Customers opted in, Customers_Files added to FILE_REFERENCE_TABLES (TRUE-82870). (jcardinal)
|
|
@@ -116,7 +116,7 @@ Page 1 of 25 still fetches exactly 25; page 254 fetches 500 per pass — ~13 rou
|
|
|
116
116
|
- **MySQL `GET_LOCK` names cap at 64 chars and ERROR rather than truncate.** The original lock name was 80 chars.
|
|
117
117
|
- **`_Query::fetchRows()` returns OBJECTS**, not arrays. CrossClient read them with array syntax throughout.
|
|
118
118
|
- **A V2 LIST nests records under the route key** (`data: {"salesOrders": [...]}`); `parseClientResponse` required `data` to be a flat array and discarded every successful sub-response as unusable.
|
|
119
|
-
- **`
|
|
119
|
+
- **`Tables.safeRecordCount` must be assigned in `buildCache`** — model INSERTs ignore DB column defaults (rule lives in [_underscore architecture § Gotchas](../../_underscore/architecture.md#gotchas)). Likewise a Core column (`Records.ttlCache`) that exists in SQL but is not declared on the model makes `__get` throw.
|
|
120
120
|
- **Dangling foreign keys used to fail an entire listing.** `getFullModelData()` threw out of `_Model::initialize()` when a referenced row did not exist; one orphaned pointer killed the whole page. It now logs and expands the relationship to `null`. **This affects every REST listing/GET in api2, not just cross-client.**
|
|
121
121
|
- **`empty()`/`isset()` on a `_Model` field is unreliable** and fails in the "looks unauthenticated" direction — see [_Model magic-field access](../../_underscore/features/model-magic-field-access.md).
|
|
122
122
|
- **Switching the `Client` DB alias mid-request** needs the park/restore path — `_Database` keys live connection state by **alias**, not physical schema. See [Re-pointing a DB alias mid-request](../../_underscore/features/database-alias-repointing.md).
|
|
@@ -128,6 +128,10 @@ Page 1 of 25 still fetches exactly 25; page 254 fetches 500 per pass — ~13 rou
|
|
|
128
128
|
requests must add `$api->cacheInvalidations['<recordId>:<clientId>'] = ['recordId' => …,
|
|
129
129
|
'clientId' => …]` for each record type it touched, or cross-client listings serve stale rows.
|
|
130
130
|
Example: `_Model_Client_CaseRecordRemoval` (TRUE-82889).
|
|
131
|
+
- **⚠ Fetch scope is either/or: `client=` scatter OR one tenant's own token — never both.** Sending
|
|
132
|
+
`client=` on another tenant's token makes `resolveScope()` fan out to THAT tenant's `Users_Clients`
|
|
133
|
+
(cross-tenant read). Seeded uuids also repeat across `Client_*` DBs, so multi-client UI rows must be
|
|
134
|
+
keyed by client uuid + record uuid. Full rule: toga25-desk ADR 0154, `GOTCHAS.md`.
|
|
131
135
|
- **`curl_multi` busy-spin guard** — both multi loops `usleep(100)` when `curl_multi_select() === -1`.
|
|
132
136
|
- **Known limitation — deep page-number paging is inherently O(page).** Serving page 4430 means materializing all 110,747 preceding rows into the cache. Adaptive chunking makes that cheaper, not cheap: cost still grows with depth, because a page **number** cannot be resolved without knowing everything before it. The durable answer is **exposing keyset cursors to the caller** so cost is independent of depth — the mechanism already exists (V2 supports `seekValues` + `seekUuid` correctly). Raised with the developer 2026-07-28 and **deliberately left unbuilt / not yet scoped**: a known limitation and candidate direction, not pending work.
|
|
133
137
|
|
|
@@ -141,4 +145,5 @@ Page 1 of 25 still fetches exactly 25; page 254 fetches 500 per pass — ~13 rou
|
|
|
141
145
|
- [_Model magic-field access](../../_underscore/features/model-magic-field-access.md)
|
|
142
146
|
|
|
143
147
|
## Change history
|
|
148
|
+
- 2026-10-17 — Fetch scope is `client=` OR own token, never both; key multi-client rows by client + record uuid (TRUE-82903). (jcardinal)
|
|
144
149
|
- 2026-10-17 — Internal-request writes must add their own `cacheInvalidations` (TRUE-82889). (jcardinal)
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
| Doc | Summary |
|
|
4
4
|
|-----|---------|
|
|
5
5
|
| [TOGa Blox 2.5 — Architecture](architecture.md) | Shared React+TS SOURCE library for the new platform apps (new platform apps first; replaces toga-blox for all apps over time); read the ADR for full detail, not |
|
|
6
|
+
| [Contact primary email/phone save (updateContactChannels) — table calls only](features/contact-channels-save.md) | How a contact edit form saves the contact's primary email and phone without nested writes; use it (do not rebuild it) in any app that edits a contact's email/ph |
|
|
6
7
|
| [Old blox (toga-blox) straight port inside toga25-blox](features/old-blox-port.md) | Old `@agilant/toga-blox` code copied unchanged into toga25-blox (blox-v1 layer); open before touching any old component, its checks/config, or merging Storybook |
|
|
7
8
|
| [GitHub Actions → AWS role via OIDC (trust policy + sub claim)](workflows/github-actions-aws-oidc-role.md) | How a GitHub Actions job assumes an AWS IAM role with OIDC, and the `sub` claim trap; open before creating or debugging any GitHub-to-AWS role ("Not authorized |
|
|
8
9
|
| [Moving an old app from @agilant/toga-blox onto toga25-blox](workflows/moving-an-old-app-onto-toga25-blox.md) | Planned steps to switch an old app off the `@agilant/toga-blox` npm package onto toga25-blox source with zero import edits; open before moving any old app. |
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Contact primary email/phone save (updateContactChannels) — table calls only
|
|
3
|
+
framework: "2.0"
|
|
4
|
+
repo: toga25-blox
|
|
5
|
+
project: TOGa Blox 2.5
|
|
6
|
+
client: shared
|
|
7
|
+
type: feature
|
|
8
|
+
status: active
|
|
9
|
+
updated: 2026-10-17
|
|
10
|
+
owners: [jcardinal]
|
|
11
|
+
files:
|
|
12
|
+
- toga25-blox/src/forms/contact/updateContactChannels.ts
|
|
13
|
+
- toga25-blox/src/forms/contact/updateContactChannels.test.ts
|
|
14
|
+
related:
|
|
15
|
+
- ../../api2/features/nested-relationship-writes.md
|
|
16
|
+
- ../../_underscore/features/contact-notes-files-types.md
|
|
17
|
+
- ../../_underscore/features/contact-customer-required-internal-customer.md
|
|
18
|
+
---
|
|
19
|
+
How a contact edit form saves the contact's primary email and phone without nested writes; use it (do not rebuild it) in any app that edits a contact's email/phone.
|
|
20
|
+
|
|
21
|
+
## Summary
|
|
22
|
+
|
|
23
|
+
`updateContactChannels({registry, clientUuid, contactUuid, email?, phone?, signal?})` (exported from `forms`) saves
|
|
24
|
+
the primary `contact-email-addresses` / `contact-phone-numbers` child rows with plain table calls, as the contact's
|
|
25
|
+
own client. A channel left out is not touched.
|
|
26
|
+
|
|
27
|
+
## How it works
|
|
28
|
+
|
|
29
|
+
Per channel, comparing the value when the form opened (`current`) to the form value (`next`):
|
|
30
|
+
1. **Changed, has a primary** → `PUT` the primary child row's value.
|
|
31
|
+
2. **Set, no primary yet** → `POST` a child row, then `PUT` the contact's `primaryContactEmailAddress` /
|
|
32
|
+
`primaryContactPhoneNumber` = `{uuid}` of the new row.
|
|
33
|
+
3. **Cleared** → `PUT` the contact's primary link to `null`. The child row is never deleted.
|
|
34
|
+
|
|
35
|
+
## Gotchas
|
|
36
|
+
|
|
37
|
+
- **Never save email/phone as nested children in one contact `PUT`.** Nested channel objects match by address text
|
|
38
|
+
tenant-wide — they can grab another contact's row or create duplicates
|
|
39
|
+
([nested-relationship writes](../../api2/features/nested-relationship-writes.md)).
|
|
40
|
+
- **No api2 interceptor for this** — rejected as a hidden write; the form owns the sequence.
|
|
41
|
+
- The new row's uuid is read from the POST answer, never the contact's own uuid (an expanded parent in the answer must
|
|
42
|
+
not be linked as its own primary).
|
|
43
|
+
|
|
44
|
+
## Change history
|
|
45
|
+
- 2026-10-17 — Created for the TOGa Desk contact edit drawer (TRUE-82871). (jcardinal)
|
package/knowledge/INDEX.md
CHANGED
|
@@ -21,7 +21,7 @@ _Auto-generated by `knowledge.js index`. Do not hand-edit._
|
|
|
21
21
|
|
|
22
22
|
## 2.0 framework
|
|
23
23
|
|
|
24
|
-
- **_underscore** (_Underscore) _(framework core)_ —
|
|
24
|
+
- **_underscore** (_Underscore) _(framework core)_ — 104 doc(s) → [2.0/apps/_underscore/INDEX.md](2.0/apps/_underscore/INDEX.md)
|
|
25
25
|
- **worker2** (Worker) — 90 doc(s) → [2.0/apps/worker2/INDEX.md](2.0/apps/worker2/INDEX.md)
|
|
26
26
|
- **api2** (API) — 26 doc(s) → [2.0/apps/api2/INDEX.md](2.0/apps/api2/INDEX.md)
|
|
27
27
|
- **dbchanges2** (Database Changes) _(framework core)_ — 22 doc(s) → [2.0/apps/dbchanges2/INDEX.md](2.0/apps/dbchanges2/INDEX.md)
|
|
@@ -34,7 +34,7 @@ _Auto-generated by `knowledge.js index`. Do not hand-edit._
|
|
|
34
34
|
- **toga25-supply** (TOGa 2.5 Supply) — 26 doc(s) → [2.0/apps/toga25-supply/INDEX.md](2.0/apps/toga25-supply/INDEX.md)
|
|
35
35
|
- **toga25-iq** (TOGa IQ) — 1 doc(s) → [2.0/apps/toga25-iq/INDEX.md](2.0/apps/toga25-iq/INDEX.md)
|
|
36
36
|
- **toga25-desk** (TOGa Desk 2.5) — 1 doc(s) → [2.0/apps/toga25-desk/INDEX.md](2.0/apps/toga25-desk/INDEX.md)
|
|
37
|
-
- **toga25-blox** (TOGa Blox 2.5) —
|
|
37
|
+
- **toga25-blox** (TOGa Blox 2.5) — 5 doc(s) → [2.0/apps/toga25-blox/INDEX.md](2.0/apps/toga25-blox/INDEX.md)
|
|
38
38
|
- **toga-blox** (TOGa Blox) — 15 doc(s) → [2.0/apps/toga-blox/INDEX.md](2.0/apps/toga-blox/INDEX.md)
|
|
39
39
|
|
|
40
40
|
## standalone framework
|
package/package.json
CHANGED