toga-ai 1.0.166 → 1.0.168
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 +1 -0
- package/knowledge/2.0/apps/_underscore/features/acl-permission-chain.md +107 -0
- package/knowledge/2.0/apps/toga2-commerce/INDEX.md +3 -0
- package/knowledge/2.0/apps/toga2-commerce/architecture.md +246 -0
- package/knowledge/2.0/apps/toga2-commerce/features/client-fields.md +187 -0
- package/knowledge/2.0/apps/toga2-commerce/features/multi-tenant-theming.md +142 -0
- package/knowledge/2.0/apps/worker2/INDEX.md +1 -0
- package/knowledge/2.0/apps/worker2/features/clickup-work-type-automation.md +80 -0
- package/knowledge/INDEX.md +3 -3
- package/package.json +1 -1
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
| Doc | Summary | Files |
|
|
4
4
|
|-----|---------|-------|
|
|
5
5
|
| [_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 |
|
|
6
|
+
| [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, dbchanges2/Client/2026-06-03- BLANK_CLIENT_DATABASE.sql, dbchanges2/Client/2026-06-23b - ItemTranslationsAcl.sql |
|
|
6
7
|
| [Carrier Shipping Labels (UPS/FedEx) & NetSuite Item Fulfillment](features/carrier-shipping-labels.md) | Backend mechanics behind TOGa Supply's Fulfill & Ship: buying a carrier label (UPS/FedEx), persisting it, and creating the NetSuite Item Fulfillment with tracki | _underscore/Model/Client/ItemFulfillment.php, _underscore/Model/Client/ItemFulfillments/TrackingNumber.php, _underscore/Component/Library/LabelPdf/LabelPdf.php, _underscore/Component/Library/Carriers/Ups/Ups.php, _underscore/Component/Library/Carriers/Fedex/Fedex.php, _underscore/Trait/Netsuite/ItemFulfillment.php, _underscore/Trait/Netsuite/SalesOrder.php, _underscore/Component/Library/NetSuite/NetSuite.php, _underscore/Model/Client/TrackingNumber.php, _underscore/Model/Client/ShippingMethod.php, _underscore/Model.php, _underscore/Cloud.php |
|
|
7
8
|
| [Client Email Template Sending](features/email-template-sending.md) | `_Model_Client_EmailTemplate` sends a stored, client-defined email template by UUID. | _underscore/Model/Client/EmailTemplate.php, _underscore/Model/Client/EmailTemplateOutgoingEmailAddress.php, _underscore/Email.php |
|
|
8
9
|
| [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 |
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: ACL Permission Chain (Record & Field Authorization)
|
|
3
|
+
framework: "2.0"
|
|
4
|
+
repo: _underscore
|
|
5
|
+
project: _Underscore
|
|
6
|
+
client: shared
|
|
7
|
+
type: feature
|
|
8
|
+
status: active
|
|
9
|
+
updated: 2026-06-23
|
|
10
|
+
owners: ["jcardinal"]
|
|
11
|
+
files:
|
|
12
|
+
- api2/Component/Api/V2/V2.php
|
|
13
|
+
- _underscore/Model/Core/Page.php
|
|
14
|
+
- dbchanges2/Client/2026-06-03- BLANK_CLIENT_DATABASE.sql
|
|
15
|
+
- dbchanges2/Client/2026-06-23b - ItemTranslationsAcl.sql
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
## Summary
|
|
19
|
+
|
|
20
|
+
Authorization in the 2.0 API is **metadata-driven**: whether a role may Create/Read/Update/Delete
|
|
21
|
+
a record is decided by rows across **four linked tables**, not by code. Granting access by
|
|
22
|
+
inserting only an `AclRecordPermissions` row is the single most common mistake — the permission
|
|
23
|
+
silently does nothing without the rest of the chain. A complete grant requires **all four** tables,
|
|
24
|
+
plus `AclFieldPermissions` for the fields to be readable/writable.
|
|
25
|
+
|
|
26
|
+
## The complete chain (all four are required)
|
|
27
|
+
|
|
28
|
+
```
|
|
29
|
+
AclRecordPermissions → AclLogicGroups → AclLogicGroupExpressions → AclRecordExpressions
|
|
30
|
+
(the role × record (operator AND/OR, (binds a group to an (the SQL test,
|
|
31
|
+
CRUD grant) parent group tree) expression) e.g. '1' = all)
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
1. **`AclRecordPermissions`** — the grant: `recordId`, `roleId`, `allowCreate/Read/Update/Delete`
|
|
35
|
+
(optional `appId`, `indirectRecordId`). Unique on `(appId, recordId, roleId, indirectRecordId)`.
|
|
36
|
+
2. **`AclLogicGroups`** — at least one root group per permission: `aclRecordPermissionId` (FK),
|
|
37
|
+
`operator` **`enum('AND','OR') NOT NULL`** (this column is mandatory and easy to forget),
|
|
38
|
+
optional `parentAclLogicGroupId` for nested groups.
|
|
39
|
+
3. **`AclLogicGroupExpressions`** — binds a logic group to a record expression: `aclLogicGroupId`
|
|
40
|
+
(FK), `aclRecordExpressionId` (FK). Unique on `(aclLogicGroupId, aclRecordExpressionId)`.
|
|
41
|
+
4. **`AclRecordExpressions`** — the actual SQL test: `recordId`, `slug`, `description`,
|
|
42
|
+
`sqlExpression` (TEXT). Unique on `(recordId, slug)`. The conventional "always allow" row is
|
|
43
|
+
`slug = 'all'`, `sqlExpression = '1'`.
|
|
44
|
+
|
|
45
|
+
**Field visibility — `AclFieldPermissions`.** Record-level access alone does **not** expose
|
|
46
|
+
fields. Each field needs a row: `recordFieldId`, `roleId`, `isWritable` (0 = read-only, 1 =
|
|
47
|
+
writable). Without it the field is omitted from responses (and rejected on write).
|
|
48
|
+
|
|
49
|
+
## Where ACL rows live: Core vs. Client database
|
|
50
|
+
|
|
51
|
+
`Core.Records.aclDatabase` decides which database holds the ACL rows for that record:
|
|
52
|
+
|
|
53
|
+
- **`aclDatabase = 'CORE'`** → ACL rows live in the **Core** database.
|
|
54
|
+
- **`aclDatabase = 'CLIENT'`** → ACL rows live in **each client's own database**. A migration
|
|
55
|
+
granting access therefore goes in `dbchanges2/Client/` (or a specific `Client_<Name>/`) so it
|
|
56
|
+
runs against every client DB.
|
|
57
|
+
|
|
58
|
+
**Role ids differ per client DB**, so never hardcode a `roleId` in a `CLIENT` ACL migration —
|
|
59
|
+
resolve it with a subselect, e.g. the Base role:
|
|
60
|
+
|
|
61
|
+
```sql
|
|
62
|
+
roleId = (SELECT id FROM Roles WHERE `name` = 'Base')
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## Canonical example (grant the Base role full CRUD on a record)
|
|
66
|
+
|
|
67
|
+
```sql
|
|
68
|
+
INSERT INTO AclRecordPermissions SET
|
|
69
|
+
uuid = '…', recordId = 331, roleId = (SELECT id FROM Roles WHERE `name` = 'Base'),
|
|
70
|
+
allowCreate = 1, allowRead = 1, allowUpdate = 1, allowDelete = 1;
|
|
71
|
+
|
|
72
|
+
INSERT INTO AclRecordExpressions SET
|
|
73
|
+
uuid = '…', recordId = 331, slug = 'all', description = 'All', sqlExpression = '1';
|
|
74
|
+
|
|
75
|
+
INSERT INTO AclLogicGroups SET
|
|
76
|
+
uuid = '…', aclRecordPermissionId = (SELECT MAX(id) FROM AclRecordPermissions), operator = 'AND';
|
|
77
|
+
|
|
78
|
+
INSERT INTO AclLogicGroupExpressions SET
|
|
79
|
+
uuid = '…', aclLogicGroupId = (SELECT MAX(id) FROM AclLogicGroups),
|
|
80
|
+
aclRecordExpressionId = (SELECT id FROM AclRecordExpressions WHERE recordId = 331 AND slug = 'all');
|
|
81
|
+
|
|
82
|
+
-- and one AclFieldPermissions row per exposed field (recordFieldId, roleId, isWritable)
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
## How it is enforced
|
|
86
|
+
|
|
87
|
+
`_Component_Api_V2::buildLookups()` bulk-loads these tables (from the Core and/or Client ACL
|
|
88
|
+
database per `Records.aclDatabase`) into in-memory lookups once per request; `processRoutePairs()`
|
|
89
|
+
then checks the caller's roles against `AclRecordPermissions` (→ `EZ-1` if no grant) and computes
|
|
90
|
+
readable/writable fields from `AclFieldPermissions`. `_Model_Core_Page::meta()` reads the same
|
|
91
|
+
tables to return per-page ACL to the frontend.
|
|
92
|
+
|
|
93
|
+
## Checklist (so the chain is never half-built)
|
|
94
|
+
|
|
95
|
+
- [ ] `AclRecordPermissions` row for the role × record with the right CRUD flags.
|
|
96
|
+
- [ ] `AclRecordExpressions` row (`slug='all'`, `sqlExpression='1'` for unconditional access).
|
|
97
|
+
- [ ] `AclLogicGroups` row with `operator` set (AND/OR) referencing the permission.
|
|
98
|
+
- [ ] `AclLogicGroupExpressions` row binding the group to the expression.
|
|
99
|
+
- [ ] `AclFieldPermissions` rows for every field that must be readable/writable.
|
|
100
|
+
- [ ] For `aclDatabase = 'CLIENT'` records: rows go in client DB(s); resolve `roleId` by subselect.
|
|
101
|
+
|
|
102
|
+
## Change history
|
|
103
|
+
|
|
104
|
+
- **2026-06-23** — Documented after repeatedly missing the logic-group/expression rows when
|
|
105
|
+
granting access to new records. Created alongside the `item-translations` record (331) ACL,
|
|
106
|
+
whose grant in `dbchanges2/Client/2026-06-23b - ItemTranslationsAcl.sql` is a worked example of
|
|
107
|
+
the full chain for a `CLIENT`-aclDatabase record targeting the Base role.
|
|
@@ -2,5 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
| Doc | Summary | Files |
|
|
4
4
|
|-----|---------|-------|
|
|
5
|
+
| [TOGa Commerce (toga2-commerce / commerce2-react) Architecture](architecture.md) | `toga2-commerce` (npm package name **`commerce2-react`**, product name **TOGa Commerce**) is the customer-facing **B2B commerce storefront** of the 2.0 platform | src/main.tsx, src/App.tsx, src/routes.tsx, src/contexts/AuthContext.tsx, src/contexts/helpers/getLoginSettings.ts, src/api/axiosInstance.ts, src/stores/, src/themeConfig/ThemeContext.tsx, src/fieldsConfig/index.ts, src/hooks/useAssignClientFields.ts, vite.config.ts, package.json |
|
|
5
6
|
| [Cart Notification Emails — duplicate prevention](features/cart-notification-emails.md) | On the cart "Notifications" section a user can add CC email addresses to an order. | src/pages/Cart/CartPage.tsx, src/pages/Cart/view/cartForm/CartForm.tsx, src/stores/useEmailOptionsStore.ts, src/stores/useCartSalesQuoteZu.ts, src/pages/Cart/viewModel/FIELDS/*/*/*/CARTPAGE.ts |
|
|
7
|
+
| [Client Fields — per-tenant / language / role content & config](features/client-fields.md) | Almost no user-facing text, field layout, or page config is hard-coded in `toga2-commerce`. | src/fieldsConfig/index.ts, src/fieldsConfig/getClientLoginFields.ts, src/fieldsConfig/clientFields/COMPASS.json, src/fieldsConfig/clientFields/COMPASSCANADA.json, src/fieldsConfig/clientFields/QUAD.json, src/hooks/useAssignClientFields.ts, src/hooks/useDynamicConditionalFieldOptions.ts, src/stores/useFieldsStore.ts, src/components/BaseDetailField/BaseDetailField.tsx |
|
|
8
|
+
| [Multi-Tenant Resolution & Theming](features/multi-tenant-theming.md) | `toga2-commerce` serves multiple clients from one codebase. | src/themeConfig/themes.json, src/themeConfig/ThemeContext.tsx, src/themeConfig/types.ts, src/components/ThemeSwitcher/ThemeSwitcher.tsx, src/components/AuthLayout/AuthLayout.tsx, src/api/axiosInstance.ts, src/contexts/AuthContext.tsx, tailwind.config.js |
|
|
6
9
|
| [AWS Amplify Build & Deploy (non-prod environments)](workflows/amplify-build-and-deploy.md) | How `toga2-commerce` (React + Vite, "commerce2-react") builds and deploys on **AWS Amplify**. | toga2-commerce/amplify.yml, toga2-commerce/.gitattributes, toga2-commerce/package.json, toga2-commerce/.github/workflows/sync-stage-environments.yml |
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: TOGa Commerce (toga2-commerce / commerce2-react) Architecture
|
|
3
|
+
framework: "2.0"
|
|
4
|
+
repo: toga2-commerce
|
|
5
|
+
project: TOGa Commerce
|
|
6
|
+
client: shared
|
|
7
|
+
type: architecture
|
|
8
|
+
status: active
|
|
9
|
+
updated: 2026-06-23
|
|
10
|
+
owners: ["apeterson"]
|
|
11
|
+
files:
|
|
12
|
+
- src/main.tsx
|
|
13
|
+
- src/App.tsx
|
|
14
|
+
- src/routes.tsx
|
|
15
|
+
- src/contexts/AuthContext.tsx
|
|
16
|
+
- src/contexts/helpers/getLoginSettings.ts
|
|
17
|
+
- src/api/axiosInstance.ts
|
|
18
|
+
- src/stores/
|
|
19
|
+
- src/themeConfig/ThemeContext.tsx
|
|
20
|
+
- src/fieldsConfig/index.ts
|
|
21
|
+
- src/hooks/useAssignClientFields.ts
|
|
22
|
+
- vite.config.ts
|
|
23
|
+
- package.json
|
|
24
|
+
related:
|
|
25
|
+
- 2.0/apps/toga2-commerce/features/multi-tenant-theming.md
|
|
26
|
+
- 2.0/apps/toga2-commerce/features/client-fields.md
|
|
27
|
+
- 2.0/apps/toga2-commerce/workflows/amplify-build-and-deploy.md
|
|
28
|
+
- 2.0/apps/api2/architecture.md
|
|
29
|
+
---
|
|
30
|
+
|
|
31
|
+
## Summary
|
|
32
|
+
|
|
33
|
+
`toga2-commerce` (npm package name **`commerce2-react`**, product name **TOGa Commerce**) is the
|
|
34
|
+
customer-facing **B2B commerce storefront** of the 2.0 platform. It is a **React 18 + TypeScript +
|
|
35
|
+
Vite 5 single-page app** styled with **Tailwind**, and it is the React front end in the 2.0
|
|
36
|
+
"decoupled" architecture: it talks to **`api2`** (the TOGa v2 JSON API) for all data, and renders
|
|
37
|
+
nothing server-side.
|
|
38
|
+
|
|
39
|
+
It is **multi-tenant**: a single codebase serves several clients (currently **COMPASS**,
|
|
40
|
+
**COMPASSCANADA**, **QUAD**, plus a **DEFAULT** fallback). The tenant is resolved at runtime from
|
|
41
|
+
the **hostname** and drives three independent systems — the API base URL, the theme, and the
|
|
42
|
+
per-tenant field/content config. See [multi-tenant-theming](features/multi-tenant-theming.md) and
|
|
43
|
+
[client-fields](features/client-fields.md) for those subsystems in depth.
|
|
44
|
+
|
|
45
|
+
> Note: this is a **2.0 *app* with no PHP**. Its registry `dependsOn` is `api2`; it consumes the
|
|
46
|
+
> `_underscore`/`api2` backend over HTTP but contains no framework PHP classes itself.
|
|
47
|
+
|
|
48
|
+
## Tech stack (verified versions, package.json)
|
|
49
|
+
|
|
50
|
+
| Concern | Library | Version |
|
|
51
|
+
|---|---|---|
|
|
52
|
+
| UI | react / react-dom | ^18.3.1 |
|
|
53
|
+
| Routing | react-router-dom | ^7.5.1 |
|
|
54
|
+
| Server state / cache | @tanstack/react-query (+ persist-client, sync-storage-persister) | ^5.59.19 |
|
|
55
|
+
| Client state | zustand | ^4.5.4 |
|
|
56
|
+
| HTTP | axios | 1.7.7 |
|
|
57
|
+
| Forms | react-hook-form | ^7.53.1 |
|
|
58
|
+
| Animation | framer-motion | ^11.0.18 |
|
|
59
|
+
| Styling | tailwindcss | ^3.4.4 |
|
|
60
|
+
| Errors / monitoring | @sentry/react | ^8.50.0 |
|
|
61
|
+
| Build / tooling | vite ^5.4.10, typescript ^5.8.2 |
|
|
62
|
+
| Tables | @tanstack/react-table, react-table (v7), react-table-sticky |
|
|
63
|
+
| Misc | dayjs, react-select, nouislider, classnames, uuid, react-lottie, @agilant/toga-blox, FontAwesome Pro |
|
|
64
|
+
|
|
65
|
+
E2E tests use **Cypress** (`cypress/`, `cypress.config.ts`).
|
|
66
|
+
|
|
67
|
+
## Bootstrap & provider stack
|
|
68
|
+
|
|
69
|
+
- `src/main.tsx` — initializes **Sentry** (browser tracing + replay) and mounts `<App/>` in React
|
|
70
|
+
`StrictMode`; imports global CSS.
|
|
71
|
+
- `src/App.tsx` — creates a single `QueryClient` (default `staleTime: 24h`) and a
|
|
72
|
+
`createSyncStoragePersister` (localStorage key **`"commerce"`**). Provider nesting, **outermost → innermost**:
|
|
73
|
+
|
|
74
|
+
```
|
|
75
|
+
PersistQueryClientProvider (client=queryClient, persister → localStorage "commerce")
|
|
76
|
+
└─ AuthProvider (contexts/AuthContext.tsx — auth state, tenant/login resolution)
|
|
77
|
+
└─ ThemeProvider (themeConfig/ThemeContext.tsx — CSS-variable theming)
|
|
78
|
+
└─ ToasterProvider (contexts/ToasterContext.tsx — toast notifications)
|
|
79
|
+
└─ RouterProvider (routes.tsx)
|
|
80
|
+
+ ReactQueryDevtools (initialIsOpen=false)
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
`App` also runs a mount effect managing **edit-order mode**: it reads a `localStorage.synced`
|
|
84
|
+
flag and calls `exitEditOrderModeGlobalSyncReset()` when an edit-order session was abandoned
|
|
85
|
+
outside `/cart`, and subscribes to `useReturnOriginalUserFromViewAsStore` (the "view-as" feature).
|
|
86
|
+
|
|
87
|
+
> The cache is **persisted**: query results survive refreshes via `PersistQueryClientProvider`.
|
|
88
|
+
> Combined with the 24h default `staleTime`, catalog data is aggressively cached — invalidate
|
|
89
|
+
> deliberately with `queryClient.invalidateQueries(...)` when freshness matters.
|
|
90
|
+
|
|
91
|
+
## Routing (`src/routes.tsx`, createBrowserRouter)
|
|
92
|
+
|
|
93
|
+
Public:
|
|
94
|
+
- `/` → `AuthRedirect` (→ `/home` if authenticated, else renders nothing while the auth flow runs)
|
|
95
|
+
- `/login` → `LoginRoute` (→ `/home` if already authenticated, else `<Login/>`)
|
|
96
|
+
- `/reset-password` → `ResetPasswordPage`
|
|
97
|
+
|
|
98
|
+
Authenticated (wrapped by `PrivateRoute` → `AuthLayout`):
|
|
99
|
+
- `/home`, `/bundle-view`, `/filter`, `/cart`, `/get-support`, `/account`, `/item-view`,
|
|
100
|
+
`/order-details`, `/privacy-policy`, `/terms-conditions`; unmatched → `Navigate to="/home"`.
|
|
101
|
+
|
|
102
|
+
Mechanics:
|
|
103
|
+
- **`PrivateRoute`** gates on `useAuth().isAuthenticated` + `useAuthenticationFlow().isLoading`;
|
|
104
|
+
shows an `AuthLoading` spinner while loading, redirects to `/login` if unauthenticated.
|
|
105
|
+
- **`AuthLayout`** wraps every authenticated route and **stays mounted across route changes**
|
|
106
|
+
(header, footer, nav, notifications live here). Its `errorElement` renders `<ErrorMessage errorType={500}/>`.
|
|
107
|
+
⚠️ Because it never remounts, React Query `refetchOnMount` only fires **once per session** for
|
|
108
|
+
queries owned by AuthLayout — route-change refreshes must be wired explicitly (e.g. the
|
|
109
|
+
notifications query invalidates on route change keyed on `user?.uuid`).
|
|
110
|
+
- Routes are **statically imported** (no `React.lazy`); Vite handles chunking at build time.
|
|
111
|
+
|
|
112
|
+
## Page architecture — MVVM convention
|
|
113
|
+
|
|
114
|
+
Every page under `src/pages/<Page>/` follows the same shape (View ← ViewModel ← API ← FIELDS):
|
|
115
|
+
|
|
116
|
+
```
|
|
117
|
+
src/pages/<Page>/
|
|
118
|
+
├── <Page>Page.tsx / index.ts # View — layout & rendering only
|
|
119
|
+
├── types.ts # page-local TS types
|
|
120
|
+
├── view/ # sub-components, modals, loading skeletons
|
|
121
|
+
├── viewModel/
|
|
122
|
+
│ ├── use<Page>ViewModel.ts # state + React Query hooks + business logic
|
|
123
|
+
│ ├── index.ts
|
|
124
|
+
│ └── FIELDS/<CLIENT>/[<LANGUAGE>/][<ROLE>/]*.json|*.ts # per-tenant content/config
|
|
125
|
+
├── api/<Page>Api.ts # axios calls for this page
|
|
126
|
+
├── hooks/ # page-scoped hooks
|
|
127
|
+
└── helpers/ # pure compute/format helpers
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
Pages: `Account, BundleView, Cart, Filter, GetSupport, Home, ItemsView, Login, OrderDetails,
|
|
131
|
+
PrivacyPolicy, ResetPassword, TermsConditions`. The **ViewModel hook is the seam**: it calls
|
|
132
|
+
`useAssignClientFields(fieldKey, language, user)` to load the right tenant/role/language content,
|
|
133
|
+
runs the page's queries, and returns ready-to-render data to the View. (See client-fields doc.)
|
|
134
|
+
|
|
135
|
+
## State management
|
|
136
|
+
|
|
137
|
+
### Zustand stores (`src/stores/`)
|
|
138
|
+
Most use the `persist` middleware (localStorage). Verified files:
|
|
139
|
+
|
|
140
|
+
| Store | Responsibility |
|
|
141
|
+
|---|---|
|
|
142
|
+
| `useUserStore` | Logged-in user (persona ids, role flags, currency) |
|
|
143
|
+
| `useCartStoreZu` | Cart items + bundles, per-item quantity-change loading |
|
|
144
|
+
| `useCartSalesQuoteZu` | Active sales order (ship-to, contact, cost center, notes, emails) |
|
|
145
|
+
| `useCartOverlayZu` | Cart preview overlay state |
|
|
146
|
+
| `useEditOrderZu` | "Edit existing order" mode flag + original snapshot |
|
|
147
|
+
| `useEditOrderUuidStore` | UUID of the order being edited |
|
|
148
|
+
| `useSelectedUserZu` | "Order for" user selected in cart |
|
|
149
|
+
| `useReturnOriginalUserFromViewAsStore` | "View as another user" return-to-self tracking |
|
|
150
|
+
| `useEmailOptionsStore` | Notification CC email options (see cart-notification-emails) |
|
|
151
|
+
| `useBundleBuilderZu` | Bundle-builder wizard selections |
|
|
152
|
+
| `useFieldsStore` | **`fieldKey` (tenant) + `language`** — drives field/theme resolution (persist key `fields-key`) |
|
|
153
|
+
| `useSettingsModalStore` | Login/settings modal open flag |
|
|
154
|
+
| `useSettingsErrorStore` | Settings validation errors (suppresses data queries when set) |
|
|
155
|
+
|
|
156
|
+
### React Query (`src/queries/`, `src/hooks/useApiQuery.ts`, `useApiMutation.ts`)
|
|
157
|
+
- Global default `staleTime` 24h; per-query overrides (user/persona data fetched fresh on login).
|
|
158
|
+
- Cache persisted to localStorage (`"commerce"`); rehydrated on load.
|
|
159
|
+
- Page queries are typically `enabled` on `isAuthenticated && user?.uuid && !settingsModalEnabled`.
|
|
160
|
+
|
|
161
|
+
**Two-layer state model:** React Query owns *server* state (catalog, orders, user); Zustand owns
|
|
162
|
+
*local* state (cart, selected user, edit mode, tenant/language). The cart is synced to the backend
|
|
163
|
+
sales order via `src/api/syncSalesOrder*.ts` helpers.
|
|
164
|
+
|
|
165
|
+
## API layer (`src/api/`)
|
|
166
|
+
|
|
167
|
+
`src/api/axiosInstance.ts` is the shared client. Key behaviors (all verified):
|
|
168
|
+
- **Base URL by tenant:** `host = "VITE_API_" + window.location.hostname.toUpperCase().split(".")[0]`;
|
|
169
|
+
`baseURL = import.meta.env[host] || import.meta.env.VITE_API`. So `compass.togacommerce` →
|
|
170
|
+
`VITE_API_COMPASS`, falling back to `VITE_API`. `timeout: 180000`.
|
|
171
|
+
- **Transaction id:** a request interceptor stamps every call with `params.transactionId = uuidv4()`.
|
|
172
|
+
- **Auth token:** a second request interceptor attaches `Authorization: Bearer <token>` — using the
|
|
173
|
+
stored `accessToken` if a `user` exists in localStorage, otherwise fetching a **public token** via
|
|
174
|
+
`POST {baseURL}/auth/public`.
|
|
175
|
+
- **1 ms delay interceptor:** a third request interceptor `await delay(1)` before sending.
|
|
176
|
+
- **401 handling:** response interceptor attempts `POST {baseURL}/auth/refresh` (Bearer refresh
|
|
177
|
+
token) once (`_retry`), retries the original request on success, else calls `performLogout()`
|
|
178
|
+
(clears all auth + cart + `commerce` cache keys and redirects to `/`).
|
|
179
|
+
- Failures are reported to **Sentry** with `errorType: "API"` tags.
|
|
180
|
+
|
|
181
|
+
The backend (`api2`) returns the standard 2.0 envelope (`success`/`data`/`errors`; see api2 arch);
|
|
182
|
+
generic CRUD helpers live in `src/api/genericApi.ts` (paginated `getData`, `saveData`, `updateData`,
|
|
183
|
+
`deleteData`, optional `?depth=-1` for nested responses).
|
|
184
|
+
|
|
185
|
+
## Authentication & roles
|
|
186
|
+
|
|
187
|
+
- `src/contexts/AuthContext.tsx` derives `host` from the hostname (`hostname.toUpperCase().split(".")[0]`),
|
|
188
|
+
loads `getClientLoginFields(host)` (tenant config), decides persona-switcher visibility
|
|
189
|
+
(`determineShouldShowPersonaSwitcher` — QUAD special-cased), then runs
|
|
190
|
+
`getLoginSettings(...)` which writes `useFieldsStore.fieldKey = host` and the language, and
|
|
191
|
+
hydrates `useUserStore`.
|
|
192
|
+
- `src/hooks/useAuthenticationFlow.ts` orchestrates the post-login fetch sequence
|
|
193
|
+
(user data → persona → contact → settings).
|
|
194
|
+
- **Role flags** on the user object are tenant-specific:
|
|
195
|
+
- COMPASS / COMPASSCANADA → `ADMIN` (`_isAdmin`), `SUPERUSER` (`_isSuperAdmin`),
|
|
196
|
+
`MANAGER` (`_isSupervisor`), else `USER`.
|
|
197
|
+
- QUAD → `GLOBALADMIN` (`_isGlobalAdmin`), `BUYER` (`_isBuyer`), `ITSHOPPER` (`_isItShopper`), else `USER`.
|
|
198
|
+
- **Personas:** `user._personaIds` is a colon-delimited string; `getLoginSettings` has special
|
|
199
|
+
handling collapsing persona-24 cases for non-QUAD tenants.
|
|
200
|
+
|
|
201
|
+
## Build & deploy
|
|
202
|
+
|
|
203
|
+
- **Per-tenant dev/build scripts** (`package.json`): `compass`, `compasscanada`, `quad`,
|
|
204
|
+
`togacommerce` run `vite --mode development --host <tenant>.togacommerce`; production builds are
|
|
205
|
+
`build` / `buildAlpha` / `buildBeta` / `buildGamma` / `buildQcSecurity` (each installs the matching
|
|
206
|
+
`@agilant/toga-blox` npm channel, then `tsc` + `vite build --mode <env>`).
|
|
207
|
+
- `vite.config.ts`: React plugin + Sentry Vite plugin (sourcemap upload); dev `server.allowedHosts`
|
|
208
|
+
= `compass.togacommerce`, `compasscanada.togacommerce`, `quad.togacommerce`; PostCSS via
|
|
209
|
+
`postcss.config.cjs`; `optimizeDeps.include: ["react-router-dom"]`.
|
|
210
|
+
- Local dev requires `*.togacommerce` hostnames to resolve to localhost (hosts file) so tenant
|
|
211
|
+
resolution works.
|
|
212
|
+
- Deployment runs on **AWS Amplify** — see [amplify-build-and-deploy](workflows/amplify-build-and-deploy.md).
|
|
213
|
+
A `Dockerfile`/`docker-compose.yml` also exist for containerized dev.
|
|
214
|
+
|
|
215
|
+
## Directory map (`src/`)
|
|
216
|
+
|
|
217
|
+
| Path | Purpose |
|
|
218
|
+
|---|---|
|
|
219
|
+
| `main.tsx` / `App.tsx` | bootstrap, Sentry, provider stack |
|
|
220
|
+
| `routes.tsx` | router + route guards |
|
|
221
|
+
| `api/` | axios instance, generic CRUD, sales-order sync helpers |
|
|
222
|
+
| `contexts/` | `AuthContext`, `ToasterContext` (+ `helpers/getLoginSettings.ts`) |
|
|
223
|
+
| `stores/` | Zustand stores (see table) |
|
|
224
|
+
| `queries/` | login/prefetch React Query definitions |
|
|
225
|
+
| `hooks/` | shared hooks (`useAssignClientFields`, `useAuthenticationFlow`, `useApiQuery/Mutation`, `useBreakpoint`, `useDynamicConditionalFieldOptions`, `useCancelEditOrder`) |
|
|
226
|
+
| `pages/` | feature pages (MVVM) |
|
|
227
|
+
| `components/` | shared UI (Base* atoms, `AuthLayout`, `Header`/`Footer`, `ThemeSwitcher`, `BaseDetailField`, guardrails, error messages, address forms, cards) |
|
|
228
|
+
| `themeConfig/` | `themes.json`, `ThemeContext.tsx`, `types.ts` (theming) |
|
|
229
|
+
| `fieldsConfig/` | global per-tenant client config + the master `FIELDS` registry |
|
|
230
|
+
| `styles/`, `fonts/`, `assets/` | global CSS, web fonts, images/SVGs (some per-tenant) |
|
|
231
|
+
| `utils/` | formatting, cart/address/persona helpers, query helpers |
|
|
232
|
+
| `globalTypes.ts` | shared TS types (API envelope, User, Address, Item/Bundle, SalesOrder, …) |
|
|
233
|
+
|
|
234
|
+
## Gotchas
|
|
235
|
+
|
|
236
|
+
- **`AuthLayout` never remounts** → `refetchOnMount` fires once per session; wire explicit
|
|
237
|
+
invalidation for data that must refresh on navigation.
|
|
238
|
+
- **24h `staleTime` + persisted cache** → users can see stale catalog/pricing; invalidate on the
|
|
239
|
+
events that should bust it.
|
|
240
|
+
- **Tenant resolution depends on the hostname.** On `localhost` with no `*.togacommerce` host the
|
|
241
|
+
first DNS label won't match a tenant, so config falls back (`getClientLoginFields` → COMPASS,
|
|
242
|
+
theme → DEFAULT). Use the per-tenant dev scripts.
|
|
243
|
+
- **Theme vs. fields language keys differ** — theme/field *folder* names are uppercase
|
|
244
|
+
(`COMPASSCANADA`, `ENGLISH`/`FRENCH`) but the runtime `FIELDS` registry keys language as
|
|
245
|
+
lowercase `en`/`fr`. See the client-fields doc.
|
|
246
|
+
</content>
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Client Fields — per-tenant / language / role content & config
|
|
3
|
+
framework: "2.0"
|
|
4
|
+
repo: toga2-commerce
|
|
5
|
+
project: TOGa Commerce
|
|
6
|
+
client: shared
|
|
7
|
+
type: feature
|
|
8
|
+
status: active
|
|
9
|
+
updated: 2026-06-23
|
|
10
|
+
owners: ["apeterson"]
|
|
11
|
+
files:
|
|
12
|
+
- src/fieldsConfig/index.ts
|
|
13
|
+
- src/fieldsConfig/getClientLoginFields.ts
|
|
14
|
+
- src/fieldsConfig/clientFields/COMPASS.json
|
|
15
|
+
- src/fieldsConfig/clientFields/COMPASSCANADA.json
|
|
16
|
+
- src/fieldsConfig/clientFields/QUAD.json
|
|
17
|
+
- src/hooks/useAssignClientFields.ts
|
|
18
|
+
- src/hooks/useDynamicConditionalFieldOptions.ts
|
|
19
|
+
- src/stores/useFieldsStore.ts
|
|
20
|
+
- src/components/BaseDetailField/BaseDetailField.tsx
|
|
21
|
+
related:
|
|
22
|
+
- 2.0/apps/toga2-commerce/architecture.md
|
|
23
|
+
- 2.0/apps/toga2-commerce/features/multi-tenant-theming.md
|
|
24
|
+
- 2.0/apps/toga2-commerce/features/cart-notification-emails.md
|
|
25
|
+
---
|
|
26
|
+
|
|
27
|
+
## Summary
|
|
28
|
+
|
|
29
|
+
Almost no user-facing text, field layout, or page config is hard-coded in `toga2-commerce`. Instead
|
|
30
|
+
each page's labels, field lists, validation, visibility, and static content come from **JSON config
|
|
31
|
+
resolved per tenant, per language, and per role**. There are **two layers**:
|
|
32
|
+
|
|
33
|
+
- **Layer A — global client config** (`src/fieldsConfig/clientFields/<TENANT>.json`): a small file
|
|
34
|
+
per tenant controlling the language switcher and which user API fields to fetch at login.
|
|
35
|
+
- **Layer B — the per-page `FIELDS` registry** (`src/fieldsConfig/index.ts` → `FIELDS`): the big
|
|
36
|
+
`tenant → language → role → page-section` object that every page ViewModel reads.
|
|
37
|
+
|
|
38
|
+
## Layer A — global client config
|
|
39
|
+
|
|
40
|
+
`src/fieldsConfig/clientFields/{COMPASS,COMPASSCANADA,QUAD}.json`, each:
|
|
41
|
+
|
|
42
|
+
```json
|
|
43
|
+
{
|
|
44
|
+
"languageSwitcher": { "uuid": "1", "isEnabled": true|false },
|
|
45
|
+
"userApiFields": ["firstName", "lastName", "email", "uuid", "_isAdmin", ...]
|
|
46
|
+
}
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
- `languageSwitcher.isEnabled` — **only COMPASSCANADA is `true`** (English/French). COMPASS and
|
|
50
|
+
QUAD are `false`.
|
|
51
|
+
- `userApiFields` — the allow-list of user columns fetched at login; importantly it includes the
|
|
52
|
+
tenant's **role flags**: COMPASS/COMPASSCANADA carry `_isAdmin`, `_isSupervisor`, `_isSuperAdmin`;
|
|
53
|
+
QUAD carries `_isGlobalAdmin`, `_isItShopper`, `_isBuyer`. All include `_personaIds`.
|
|
54
|
+
|
|
55
|
+
Resolved by **`src/fieldsConfig/getClientLoginFields.ts`** — a `switch(client)` returning the JSON;
|
|
56
|
+
**default is `COMPASS`** (not DEFAULT). Called from `AuthContext` as `getClientLoginFields(host)`.
|
|
57
|
+
The `languageSwitcher.isEnabled` value feeds the login-settings branch in `getLoginSettings.ts`.
|
|
58
|
+
|
|
59
|
+
## Layer B — the per-page FIELDS registry
|
|
60
|
+
|
|
61
|
+
`src/fieldsConfig/index.ts` (~1100 lines) imports every page's per-tenant field constants (which
|
|
62
|
+
themselves wrap the on-disk JSON under `pages/<Page>/viewModel/FIELDS/...`) and assembles one object:
|
|
63
|
+
|
|
64
|
+
```ts
|
|
65
|
+
export const FIELDS: Record<string, any> = {
|
|
66
|
+
COMPASS: {
|
|
67
|
+
en: {
|
|
68
|
+
ADMIN: { HEADERFIELDS, HOMEPAGEFIELDS, ORDERDETAILS, CARTPAGEFIELDS, /* ~20 sections */ },
|
|
69
|
+
SUPERUSER: { ... },
|
|
70
|
+
MANAGER: { ... },
|
|
71
|
+
USER: { ... },
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
COMPASSCANADA: { en: { ADMIN, SUPERUSER, MANAGER, USER }, fr: { ... } },
|
|
75
|
+
QUAD: { en: { GLOBALADMIN, BUYER, ITSHOPPER /*, USER */ } },
|
|
76
|
+
};
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
### Resolution dimensions & order
|
|
80
|
+
|
|
81
|
+
`FIELDS[ tenant ][ language ][ role ]` → an object of **page-section** constants
|
|
82
|
+
(`HEADERFIELDS`, `HOMEPAGEFIELDS`, `FOOTERFIELDS`, `ITEMSPAGE`, `ORDERDETAILS`, `GETSUPPORTPAGE`,
|
|
83
|
+
`BUNDLEDETAILSPAGE`, `FILTERPAGEFIELDS`, `CARTPAGEFIELDS`, `NEWUSERFORMFIELDS`, `CANTFINDUSERFIELDS`,
|
|
84
|
+
`PRIVACYPOLICYPAGEFIELDS`, `TERMSCONDITIONSPAGEFIELDS`, `ACCOUNTTABFIELDS`, `MYACCOUNTFIELDS`,
|
|
85
|
+
`ORDERHISTORYFIELDS`, `ADD/EDIT/SELECTNEWPRIMARY/SHIPPINGADDRESSESFIELDS`, `CARTOVERLAY`,
|
|
86
|
+
`ERRORFIELDS`, `LOGINSETTINGSFIELDS`, …).
|
|
87
|
+
|
|
88
|
+
- **Tenant** keys: `COMPASS`, `COMPASSCANADA`, `QUAD`.
|
|
89
|
+
- **Language** keys are **lowercase `en` / `fr`** in the registry. ⚠️ This differs from the on-disk
|
|
90
|
+
**folder** names, which are uppercase `ENGLISH` / `FRENCH`. Only COMPASSCANADA has `fr`.
|
|
91
|
+
- **Role** keys: COMPASS/COMPASSCANADA → `ADMIN`, `SUPERUSER`, `MANAGER`, `USER`;
|
|
92
|
+
QUAD → `GLOBALADMIN`, `BUYER`, `ITSHOPPER` (+ `USER`).
|
|
93
|
+
|
|
94
|
+
On disk the JSON lives at `pages/<Page>/viewModel/FIELDS/<CLIENT>/[<LANGUAGE>/][<ROLE>/]<NAME>FIELDS.json`,
|
|
95
|
+
and **not every page uses every dimension**:
|
|
96
|
+
- Pages with full role split: Home, OrderDetails, BundleView, Filter, Cart, Account.
|
|
97
|
+
- Pages with **no role layer** (role-neutral content): PrivacyPolicy, GetSupport, ItemsView,
|
|
98
|
+
TermsConditions, ResetPassword (resolved at tenant/language level, shared across roles).
|
|
99
|
+
- Language layer (`ENGLISH`/`FRENCH` folders) only exists for COMPASSCANADA; COMPASS and QUAD put
|
|
100
|
+
files directly under the tenant (their registry language is `en`).
|
|
101
|
+
|
|
102
|
+
## The resolver — `useAssignClientFields`
|
|
103
|
+
|
|
104
|
+
`src/hooks/useAssignClientFields.ts` is called by **every page ViewModel** (and several form
|
|
105
|
+
ViewModels) with `(fieldKey, language, user)`:
|
|
106
|
+
|
|
107
|
+
```ts
|
|
108
|
+
let role: string;
|
|
109
|
+
if (clientName === "COMPASS" || clientName === "COMPASSCANADA") {
|
|
110
|
+
if (user?._isAdmin === "1") role = "ADMIN";
|
|
111
|
+
else if (user?._isSuperAdmin === "1") role = "SUPERUSER";
|
|
112
|
+
else if (user?._isSupervisor === "1") role = "MANAGER";
|
|
113
|
+
else role = "USER";
|
|
114
|
+
} else { // QUAD (and any non-Compass tenant)
|
|
115
|
+
if (user?._isGlobalAdmin === "1") role = "GLOBALADMIN";
|
|
116
|
+
else if (user?._isBuyer === "1") role = "BUYER";
|
|
117
|
+
else if (user?._isItShopper === "1") role = "ITSHOPPER";
|
|
118
|
+
else role = "USER";
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// wrapped in React Query, keyed for caching:
|
|
122
|
+
const queryKey = ["clientFields", clientName, language, role];
|
|
123
|
+
const data = FIELDS?.[clientName]?.[language]?.[role] || null;
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
Inputs:
|
|
127
|
+
- **`clientName`** = `useFieldsStore.fieldKey` (the tenant `host`, set at login — see architecture doc).
|
|
128
|
+
- **`language`** = `useFieldsStore.language` (default `"en"`; set to `"fr"` etc. via `setLanguage`
|
|
129
|
+
from the login settings switcher or `MySettingsView`).
|
|
130
|
+
- **`user`** = the logged-in user (role flags).
|
|
131
|
+
|
|
132
|
+
It returns `{ fields, loading, error, resetFieldsMapCache }`; `resetFieldsMapCache` invalidates the
|
|
133
|
+
`["clientFields"]` query (used when role/language/tenant changes mid-session). Page ViewModels then
|
|
134
|
+
pluck the section they need, e.g. `fieldsCall["MYACCOUNTFIELDS"]`.
|
|
135
|
+
|
|
136
|
+
## What's inside a FIELDS JSON
|
|
137
|
+
|
|
138
|
+
Shape varies by page, but common forms:
|
|
139
|
+
- **Static content pages** (PrivacyPolicy, TermsConditions, GetSupport): headings + text/HTML blocks
|
|
140
|
+
(`label` may contain HTML).
|
|
141
|
+
- **Form pages** (ResetPassword, address forms, NewUser): `formFields[]` with
|
|
142
|
+
`inputType`, `isRequired`, `validationRequirements[]`, labels, placeholders.
|
|
143
|
+
- **Data/detail pages** (Account, OrderDetails): field descriptors mapping data to display, e.g.
|
|
144
|
+
`{ uuid, label, valueKey, valueType: "currency"|"address"|"status", icon, colSpan, classes }`,
|
|
145
|
+
plus `pageSettings` (e.g. which API fields to fetch: `fetchUserDataFields`, `fetchUserOrdersDetailsApiFields`).
|
|
146
|
+
|
|
147
|
+
Common field attributes: `uuid`, `label`, `valueKey` (path into the data object), `inputType`,
|
|
148
|
+
`valueType` (render hint), `colSpan`/`classes` (Tailwind), `isRequired`, `icon`, `options[]`.
|
|
149
|
+
|
|
150
|
+
## Consumers
|
|
151
|
+
|
|
152
|
+
- **`src/components/BaseDetailField/`** (`BaseDetailField.tsx`, `renderBaseDetailFieldLabel`,
|
|
153
|
+
`renderBaseDetailFieldValue`) — generic renderer: takes a field descriptor + data object, applies
|
|
154
|
+
`valueType` formatting (currency/address/status) and the descriptor's Tailwind classes.
|
|
155
|
+
- **`src/pages/BundleView/hooks/useBundleFields.ts`** — wraps `useAssignClientFields` and reads
|
|
156
|
+
bundle quantity rules from `BUNDLEDETAILSPAGE.pageSettings`.
|
|
157
|
+
- **`src/hooks/useDynamicConditionalFieldOptions.ts`** — swaps dependent select options at runtime
|
|
158
|
+
(e.g. state/province options change with the selected country).
|
|
159
|
+
|
|
160
|
+
## Onboarding / editing fields — practical notes
|
|
161
|
+
|
|
162
|
+
- **New tenant:** add `clientFields/<TENANT>.json`, a `getClientLoginFields` case, a `<TENANT>`
|
|
163
|
+
branch in `FIELDS` (with the tenant's role keys), and the per-page `FIELDS/<TENANT>/...` JSON +
|
|
164
|
+
the corresponding imports in `index.ts`.
|
|
165
|
+
- **New role:** extend the role-derivation in `useAssignClientFields.ts` **and** add the role key
|
|
166
|
+
under each `FIELDS[tenant][lang]` branch + per-page role folders.
|
|
167
|
+
- **New language:** set `languageSwitcher.isEnabled` in the tenant's `clientFields` JSON, add the
|
|
168
|
+
`fr`/`xx` branch to `FIELDS[tenant]`, and the `<LANGUAGE>` on-disk folders.
|
|
169
|
+
- **Editing copy:** change the page's `FIELDS/<CLIENT>/[<LANG>/][<ROLE>/]<NAME>FIELDS.json`. Remember
|
|
170
|
+
to update **all** affected role/language variants — there is no inheritance/fallback between roles.
|
|
171
|
+
|
|
172
|
+
## Gotchas
|
|
173
|
+
|
|
174
|
+
- **`en`/`fr` (registry) vs `ENGLISH`/`FRENCH` (folders)** — mismatching these is the classic bug;
|
|
175
|
+
the runtime lookup uses lowercase.
|
|
176
|
+
- **No fallback** in `FIELDS[client][language][role]` — a missing tenant/role/language combo returns
|
|
177
|
+
`null` and the page renders empty. Keep all role variants in sync.
|
|
178
|
+
- Role is derived from string `"1"` flags on the user (`user?._isAdmin === "1"`), not booleans.
|
|
179
|
+
- `getClientLoginFields` defaults to **COMPASS** for an unknown host, which can mask a
|
|
180
|
+
misconfigured tenant in local dev.
|
|
181
|
+
|
|
182
|
+
## Change history
|
|
183
|
+
- 2026-06-23 — Initial: documented the two-layer field system (global `clientFields/<TENANT>.json`
|
|
184
|
+
+ the per-page `FIELDS` registry), the `tenant→language→role` resolution in `useAssignClientFields`,
|
|
185
|
+
the `en`/`fr` vs `ENGLISH`/`FRENCH` distinction, role derivation per tenant, and field JSON shapes
|
|
186
|
+
(apeterson)
|
|
187
|
+
</content>
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Multi-Tenant Resolution & Theming
|
|
3
|
+
framework: "2.0"
|
|
4
|
+
repo: toga2-commerce
|
|
5
|
+
project: TOGa Commerce
|
|
6
|
+
client: shared
|
|
7
|
+
type: feature
|
|
8
|
+
status: active
|
|
9
|
+
updated: 2026-06-23
|
|
10
|
+
owners: ["apeterson"]
|
|
11
|
+
files:
|
|
12
|
+
- src/themeConfig/themes.json
|
|
13
|
+
- src/themeConfig/ThemeContext.tsx
|
|
14
|
+
- src/themeConfig/types.ts
|
|
15
|
+
- src/components/ThemeSwitcher/ThemeSwitcher.tsx
|
|
16
|
+
- src/components/AuthLayout/AuthLayout.tsx
|
|
17
|
+
- src/api/axiosInstance.ts
|
|
18
|
+
- src/contexts/AuthContext.tsx
|
|
19
|
+
- tailwind.config.js
|
|
20
|
+
related:
|
|
21
|
+
- 2.0/apps/toga2-commerce/architecture.md
|
|
22
|
+
- 2.0/apps/toga2-commerce/features/client-fields.md
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
## Summary
|
|
26
|
+
|
|
27
|
+
`toga2-commerce` serves multiple clients from one codebase. A request's **tenant** is derived
|
|
28
|
+
from the **hostname**, and that single tenant string fans out into three independent systems:
|
|
29
|
+
|
|
30
|
+
1. **API base URL** — `axiosInstance.ts` picks `VITE_API_<TENANT>`.
|
|
31
|
+
2. **Theme** — `ThemeContext` applies the tenant's color set as CSS variables.
|
|
32
|
+
3. **Fields/content** — the per-tenant field config (separate doc: [client-fields](client-fields.md)).
|
|
33
|
+
|
|
34
|
+
Current tenants: **COMPASS**, **COMPASSCANADA**, **QUAD**, plus a **DEFAULT** fallback theme.
|
|
35
|
+
|
|
36
|
+
## Tenant resolution — the hostname is the source of truth
|
|
37
|
+
|
|
38
|
+
The same derivation appears in three places (intentionally — there is no single shared tenant util):
|
|
39
|
+
|
|
40
|
+
```ts
|
|
41
|
+
const fullHostName = window.location.hostname.toUpperCase(); // "COMPASS.TOGACOMMERCE"
|
|
42
|
+
const host = fullHostName.split(".")[0]; // "COMPASS"
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
- **`src/api/axiosInstance.ts`** (lines ~8-11): `const host = "VITE_API_" + hostName[0];`
|
|
46
|
+
→ `baseURL = import.meta.env[host] || import.meta.env.VITE_API`. So the API endpoint is
|
|
47
|
+
tenant-specific (`VITE_API_COMPASS`, `VITE_API_QUAD`, …) with `VITE_API` as fallback.
|
|
48
|
+
- **`src/contexts/AuthContext.tsx`** (lines ~68-70): derives `host` and uses it for
|
|
49
|
+
`getClientLoginFields(host)`, persona-switcher logic, and to set `fieldKey` (see below).
|
|
50
|
+
- **`src/themeConfig/ThemeContext.tsx`** (lines ~13-15): derives `host` as the **default** theme key.
|
|
51
|
+
|
|
52
|
+
This is why the per-tenant **dev scripts** matter: `npm run compass` runs
|
|
53
|
+
`vite --host compass.togacommerce`, and `vite.config.ts` `server.allowedHosts` permits
|
|
54
|
+
`compass.togacommerce`, `compasscanada.togacommerce`, `quad.togacommerce`. Plain `localhost`
|
|
55
|
+
won't match a tenant, so it falls back (theme → DEFAULT, fields → COMPASS).
|
|
56
|
+
|
|
57
|
+
### From hostname to active theme — `fieldKey` is the real trigger
|
|
58
|
+
|
|
59
|
+
The theme is **not** applied directly from the raw hostname at mount. The chain is:
|
|
60
|
+
|
|
61
|
+
1. On login, `AuthContext` → `getLoginSettings(...)` writes `useFieldsStore.fieldKey = host`.
|
|
62
|
+
2. **`src/components/AuthLayout/AuthLayout.tsx`** runs:
|
|
63
|
+
```ts
|
|
64
|
+
const { theme, loadThemeForClient } = useThemeContext();
|
|
65
|
+
useEffect(() => { loadThemeForClient(fieldKey); }, [fieldKey, loadThemeForClient]);
|
|
66
|
+
```
|
|
67
|
+
So whenever `fieldKey` resolves, the matching theme loads.
|
|
68
|
+
3. **On first mount**, `ThemeProvider` loads `localStorage["current-client-id"]` (set by a prior
|
|
69
|
+
`loadThemeForClient`) or **`"DEFAULT"`** — not the hostname — so the very first paint before
|
|
70
|
+
login uses the last-saved or default theme.
|
|
71
|
+
|
|
72
|
+
## Theme config & data shape
|
|
73
|
+
|
|
74
|
+
- **`src/themeConfig/themes.json`** — top-level keys `DEFAULT`, `COMPASS`, `COMPASSCANADA`, `QUAD`.
|
|
75
|
+
Each entry: `{ id, name, colors: {...} }`.
|
|
76
|
+
- **`src/themeConfig/types.ts`** (`ClientTheme`) — the `colors` object is the only themed data.
|
|
77
|
+
Token groups (each typically with `default/hover/disabled/active` states, some with `bg-`,
|
|
78
|
+
`text-`, `border-`, `icon-` prefixes):
|
|
79
|
+
- `primary` (a numeric color scale, e.g. `50`/`500`/`900`)
|
|
80
|
+
- `btn` (`primary`, `tertiary`), `btn-nav`, `btn-nav-badge`, `btn-tabView-alt1`
|
|
81
|
+
- `input`, `checkBox`, `tile`, `text` (`body-primary`/`secondary`/`tertiary`)
|
|
82
|
+
|
|
83
|
+
> The theme controls **colors only**. Fonts, spacing, etc. are global Tailwind config / `@font-face`
|
|
84
|
+
> in `src/fonts/`, not per-tenant theme data.
|
|
85
|
+
|
|
86
|
+
## How the theme is applied — CSS variables on `<html>`
|
|
87
|
+
|
|
88
|
+
`ThemeContext.applyThemeToDOM` recursively flattens `themeData.colors` into CSS custom properties
|
|
89
|
+
on `document.documentElement`:
|
|
90
|
+
|
|
91
|
+
```ts
|
|
92
|
+
// leaf string → root.style.setProperty(`--${prefix}-${key}`, value)
|
|
93
|
+
// nested object → recurse with prefix `${prefix}-${key}`
|
|
94
|
+
applyNestedColors(themeData.colors, "color");
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
So `colors.btn.primary["bg-default"]` becomes `--color-btn-primary-bg-default`. **`tailwind.config.js`**
|
|
98
|
+
maps semantic color tokens to these variables (`var(--color-...)`), so components style with normal
|
|
99
|
+
Tailwind classes and automatically pick up the active tenant's palette. `loadThemeForClient` also
|
|
100
|
+
persists the chosen key to `localStorage["current-client-id"]`.
|
|
101
|
+
|
|
102
|
+
## ThemeSwitcher
|
|
103
|
+
|
|
104
|
+
`src/components/ThemeSwitcher/ThemeSwitcher.tsx` is a manual `<select>` over `availableThemes`
|
|
105
|
+
(from `Object.values(themes.json)`) calling `loadThemeForClient(e.target.value)`. It's an override
|
|
106
|
+
/ dev-testing affordance for previewing tenants — normal users get their theme from `fieldKey`.
|
|
107
|
+
|
|
108
|
+
## Per-tenant assets
|
|
109
|
+
|
|
110
|
+
Logos and some imagery are **hard-coded imports with conditional rendering** keyed on `theme?.id`
|
|
111
|
+
(e.g. AuthLayout selects the QUAD logo vs. the Compass logo). They are not part of `themes.json`.
|
|
112
|
+
|
|
113
|
+
## Onboarding a new tenant — checklist
|
|
114
|
+
|
|
115
|
+
1. **Theme** — add a new top-level key to `src/themeConfig/themes.json` with the full `colors` set
|
|
116
|
+
(copy DEFAULT/COMPASS and recolor); confirm `tailwind.config.js` tokens cover any new groups.
|
|
117
|
+
2. **API env** — add `VITE_API_<TENANT>` to the `.env.<mode>` files (else it falls back to `VITE_API`).
|
|
118
|
+
3. **Dev script + allowedHosts** — add `"<tenant>": "vite --mode development --host <tenant>.togacommerce"`
|
|
119
|
+
to `package.json` and `<tenant>.togacommerce` to `vite.config.ts` `server.allowedHosts`; add the
|
|
120
|
+
host to the local hosts file.
|
|
121
|
+
4. **Client login config** — add `src/fieldsConfig/clientFields/<TENANT>.json` and a `case` in
|
|
122
|
+
`src/fieldsConfig/getClientLoginFields.ts` (see client-fields doc).
|
|
123
|
+
5. **Fields** — add the `<TENANT>` branch to the master `FIELDS` registry and the per-page
|
|
124
|
+
`FIELDS/<TENANT>/...` JSON (see client-fields doc).
|
|
125
|
+
6. **Roles** — if the tenant's role flags differ from COMPASS/QUAD, extend the role-derivation
|
|
126
|
+
in `src/hooks/useAssignClientFields.ts` and `AuthContext`'s persona logic.
|
|
127
|
+
7. **Assets** — add tenant logos and wire the conditional `theme?.id` rendering.
|
|
128
|
+
|
|
129
|
+
## Gotchas
|
|
130
|
+
|
|
131
|
+
- Tenant detection is **hostname-only** — there is no env-var or build-flag override of the tenant
|
|
132
|
+
at runtime. Wrong host = wrong tenant.
|
|
133
|
+
- The theme's first paint uses `localStorage["current-client-id"]` or DEFAULT, *then* corrects to
|
|
134
|
+
`fieldKey` once `AuthLayout` mounts — expect a brief default-themed flash pre-login.
|
|
135
|
+
- Adding a color token group to `themes.json` does nothing visually unless `tailwind.config.js`
|
|
136
|
+
references the corresponding `--color-*` variable.
|
|
137
|
+
|
|
138
|
+
## Change history
|
|
139
|
+
- 2026-06-23 — Initial: documented hostname→tenant resolution (axios/AuthContext/ThemeContext),
|
|
140
|
+
`fieldKey`-driven theme application via AuthLayout, CSS-variable flattening of `themes.json` colors,
|
|
141
|
+
ThemeSwitcher, and the new-tenant onboarding checklist (apeterson)
|
|
142
|
+
</content>
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
|-----|---------|-------|
|
|
5
5
|
| [Worker (worker2) Architecture](architecture.md) | Worker (repo `worker2`) is an AWS Elastic Beanstalk **Worker Tier** application that processes background jobs. | worker2/Controller/Index.php, worker2/Worker/, worker2/LambdaFunctions/, _underscore/Worker.php |
|
|
6
6
|
| [ClickUp Project & Opportunity Multi-List Routing](features/clickup-project-routing.md) | Routes ClickUp tasks into the correct **secondary multi-list memberships** based on their custom-field values, via the `clickup` webhook. | worker2/Worker/Clickup/Project.php, worker2/Worker/Clickup.php |
|
|
7
|
+
| [ClickUp Work Type Automation (Committed / Conditional / Stretch)](features/clickup-work-type-automation.md) | The ClickUp webhook handler (`_Worker_Clickup`) automatically maintains each task's **Work Type** custom field — `Committed`, `Conditional`, or `Stretch` — base | worker2/Worker/Clickup.php, worker2/Tests/Worker/ClickupWorkTypeTest.php |
|
|
7
8
|
| [Creating Worker Actions](features/creating-worker-actions.md) | How to add a new callable Worker action — a PHP class whose `public static` methods are invoked as background jobs (via webhook, cron, or `_Worker::runTask()`). | worker2/Worker/, worker2/Controller/Index.php, _underscore/Worker.php |
|
|
8
9
|
| [Elite Freshservice Sync (worker2)](features/elite-freshservice-sync.md) | `_Worker_Elite` processes Freshservice webhook events and syncs them into TOGA 2. | worker2/Worker/Elite.php, worker2/Config/dev-kmaramreddy-laptop.ini |
|
|
9
10
|
| [Monitoring Framework (Orchestrator + Child Monitors)](features/monitoring-framework.md) | A unified, DB-driven monitoring framework for business-critical data flows (Compass POs, Prudential asset imports, AIG closed claims, …). | worker2/Worker/Monitor.php, worker2/Worker/Monitors/, worker2/Worker/Notification/Email.php, dbchanges2/Core/2026-05-21 - Monitors.sql |
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: ClickUp Work Type Automation (Committed / Conditional / Stretch)
|
|
3
|
+
framework: "2.0"
|
|
4
|
+
repo: worker2
|
|
5
|
+
project: Worker
|
|
6
|
+
client: shared
|
|
7
|
+
type: feature
|
|
8
|
+
status: active
|
|
9
|
+
updated: 2026-06-23
|
|
10
|
+
owners: ["jcardinal"]
|
|
11
|
+
files:
|
|
12
|
+
- worker2/Worker/Clickup.php
|
|
13
|
+
- worker2/Tests/Worker/ClickupWorkTypeTest.php
|
|
14
|
+
related:
|
|
15
|
+
- ./clickup-project-routing.md
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
## Summary
|
|
19
|
+
The ClickUp webhook handler (`_Worker_Clickup`) automatically maintains each task's **Work
|
|
20
|
+
Type** custom field — `Committed`, `Conditional`, or `Stretch` — based on the task's
|
|
21
|
+
dependencies, its status, and its due date. The core rule: a task that should be Committed is
|
|
22
|
+
demoted to **Conditional** while it still has an unfinished dependency, and promoted back to
|
|
23
|
+
**Committed** once all its dependencies are complete. The logic lives in
|
|
24
|
+
`updateTaskAndDependencies()`.
|
|
25
|
+
|
|
26
|
+
## Key files / entry points
|
|
27
|
+
- `Worker/Clickup.php` → `Webhook($payload, $headers)` — webhook entry; routes by
|
|
28
|
+
`$payload->event`.
|
|
29
|
+
- `updateTaskAndDependencies($taskId, $dependsOnTaskId = null)` — the work-type engine.
|
|
30
|
+
- `getWorkType($taskDetails)` — reads the current Work Type custom field value.
|
|
31
|
+
- `isStatusComplete(string $statusType): bool` — true when a ClickUp status type is terminal.
|
|
32
|
+
- `getTaskDetails($taskId)` — cached `GET /task/{id}` (static per-invocation cache).
|
|
33
|
+
|
|
34
|
+
## How it works
|
|
35
|
+
1. A ClickUp webhook hits the worker; `Webhook()` switches on `$payload->event`.
|
|
36
|
+
2. `updateTaskAndDependencies()` is invoked from the `taskCreated`, `taskUpdated`, and
|
|
37
|
+
`taskStatusUpdated` events. For `taskStatusUpdated` the call is **gated to terminal
|
|
38
|
+
statuses** (`isStatusComplete()`) so the dependent re-evaluation cascade only runs when a
|
|
39
|
+
task actually completes — not on every status change.
|
|
40
|
+
3. For the task, it builds `$waitingOn` — the list of dependencies (`$dependency->task_id ==
|
|
41
|
+
$taskId`) whose status is **not** complete. A dependency counts as complete when its status
|
|
42
|
+
`type` is one of `COMPLETE_STATUS_TYPES` (`done` **or** `closed`).
|
|
43
|
+
4. It also computes `$isStalledTask` (status in `on hold` / `awaiting client` / `roadblocked`).
|
|
44
|
+
5. Decision:
|
|
45
|
+
- `Committed` + (`$waitingOn` non-empty OR stalled) → set **Conditional** (+ comment).
|
|
46
|
+
- `Conditional` + `$waitingOn` empty + not stalled → set **Committed** (+ comment).
|
|
47
|
+
- `Stretch` with a due date inside the current sprint → set Committed/Conditional as above.
|
|
48
|
+
6. When a task is depended on by another (`$dependency->depends_on == $taskId`), it recurses
|
|
49
|
+
into the blocking direction so completing a task re-evaluates the tasks it was blocking. The
|
|
50
|
+
`$dependsOnTaskId` argument breaks two-node cycles.
|
|
51
|
+
|
|
52
|
+
## Data model
|
|
53
|
+
Reads/writes the `Team` database (`Tasks`, `Developers`, `Tasks_Developers`, `Sprints`) and the
|
|
54
|
+
ClickUp REST API. The Work Type itself lives in ClickUp as a custom field
|
|
55
|
+
(`CLICK_UP_CUSTOM_FIELD_ID__WORKTYPE`), not in the DB.
|
|
56
|
+
|
|
57
|
+
## Client variations
|
|
58
|
+
None — internal team/sprint tooling, uniform across clients.
|
|
59
|
+
|
|
60
|
+
## Gotchas / known issues
|
|
61
|
+
- **Two terminal status types.** ClickUp has both `done` and `closed` terminal status types. A
|
|
62
|
+
completed dependency may be `closed`, not `done`. The completion check must treat **both** as
|
|
63
|
+
finished — see `COMPLETE_STATUS_TYPES`. Checking only `== 'done'` leaves dependents stuck on
|
|
64
|
+
Conditional after their blocker is closed.
|
|
65
|
+
- **Status changes must trigger re-evaluation.** A dependency completing fires
|
|
66
|
+
`taskStatusUpdated` on the *dependency*, not the blocked task. The `taskStatusUpdated` case
|
|
67
|
+
must call `updateTaskAndDependencies()` (it recurses into dependents) or the auto-promotion
|
|
68
|
+
back to Committed never fires on its own.
|
|
69
|
+
- **No PHPUnit harness** in worker2. The regression test
|
|
70
|
+
`Tests/Worker/ClickupWorkTypeTest.php` is a plain-PHP script (reflection on
|
|
71
|
+
`isStatusComplete`) run with `php Tests/Worker/ClickupWorkTypeTest.php`.
|
|
72
|
+
|
|
73
|
+
## Change history
|
|
74
|
+
- 2026-06-23 — Fixed dependents staying Conditional after a blocker completed: completion check
|
|
75
|
+
now treats `closed` as terminal alongside `done`, and `taskStatusUpdated` re-evaluates
|
|
76
|
+
dependents (gated to terminal statuses). Added `COMPLETE_STATUS_TYPES`, `isStatusComplete()`,
|
|
77
|
+
and a plain-PHP regression test. (jcardinal)
|
|
78
|
+
|
|
79
|
+
## Related docs
|
|
80
|
+
- [ClickUp Project & Opportunity Multi-List Routing](./clickup-project-routing.md)
|
package/knowledge/INDEX.md
CHANGED
|
@@ -15,8 +15,8 @@ _Auto-generated by `knowledge.js index`. Do not hand-edit._
|
|
|
15
15
|
|
|
16
16
|
## 2.0 framework
|
|
17
17
|
|
|
18
|
-
- **_underscore** (_Underscore) _(framework core)_ —
|
|
19
|
-
- **worker2** (Worker) —
|
|
18
|
+
- **_underscore** (_Underscore) _(framework core)_ — 11 doc(s) → [2.0/apps/_underscore/INDEX.md](2.0/apps/_underscore/INDEX.md)
|
|
19
|
+
- **worker2** (Worker) — 10 doc(s) → [2.0/apps/worker2/INDEX.md](2.0/apps/worker2/INDEX.md)
|
|
20
20
|
- **api2** (API) — 4 doc(s) → [2.0/apps/api2/INDEX.md](2.0/apps/api2/INDEX.md)
|
|
21
21
|
- **dbchanges2** (Database Changes) _(framework core)_ — 1 doc(s) → [2.0/apps/dbchanges2/INDEX.md](2.0/apps/dbchanges2/INDEX.md)
|
|
22
22
|
- **toga2-supply** (TOGa Supply) — 3 doc(s) → [2.0/apps/toga2-supply/INDEX.md](2.0/apps/toga2-supply/INDEX.md)
|
|
@@ -26,7 +26,7 @@ _Auto-generated by `knowledge.js index`. Do not hand-edit._
|
|
|
26
26
|
- **talos** (TOGa IQ) — 6 doc(s) → [2.0/apps/talos/INDEX.md](2.0/apps/talos/INDEX.md)
|
|
27
27
|
- **voice-to-voice** (TOGa Voice) — 4 doc(s) → [2.0/apps/voice-to-voice/INDEX.md](2.0/apps/voice-to-voice/INDEX.md)
|
|
28
28
|
- **ai-bdr** (AI-BDR) — 4 doc(s) → [2.0/apps/ai-bdr/INDEX.md](2.0/apps/ai-bdr/INDEX.md)
|
|
29
|
-
- **toga2-commerce** (TOGa Commerce) —
|
|
29
|
+
- **toga2-commerce** (TOGa Commerce) — 5 doc(s) → [2.0/apps/toga2-commerce/INDEX.md](2.0/apps/toga2-commerce/INDEX.md)
|
|
30
30
|
- **toga25-supply** (TOGa 2.5 Supply) — 5 doc(s) → [2.0/apps/toga25-supply/INDEX.md](2.0/apps/toga25-supply/INDEX.md)
|
|
31
31
|
- **toga-blox** (TOGa Blox) — 7 doc(s) → [2.0/apps/toga-blox/INDEX.md](2.0/apps/toga-blox/INDEX.md)
|
|
32
32
|
|
package/package.json
CHANGED