@dev.smartpricing/message-composer-layer 4.3.2-fix.1 → 4.5.1-next.0

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.
Files changed (61) hide show
  1. package/README.md +228 -46
  2. package/app/api/brands.ts +20 -13
  3. package/app/api/client.ts +15 -6
  4. package/app/api/compilation.ts +13 -3
  5. package/app/api/dynamicValues.ts +6 -2
  6. package/app/api/media.ts +6 -3
  7. package/app/api/messageGroups.ts +29 -14
  8. package/app/api/meta.ts +26 -4
  9. package/app/api/organization.ts +10 -1
  10. package/app/api/tracking.ts +2 -2
  11. package/app/components/Common/AppContextErrors.vue +30 -0
  12. package/app/components/Common/HtmlPreviewIframe.vue +17 -3
  13. package/app/components/Common/MessageGroupSelect.vue +164 -26
  14. package/app/components/Common/MessagePreviewSlideover.vue +3 -0
  15. package/app/components/Common/OrganizationGate.vue +51 -0
  16. package/app/components/Common/OrganizationSelect.vue +27 -0
  17. package/app/components/Common/WhatsappNotConfigured.vue +20 -0
  18. package/app/components/Composer/EmailComposer.vue +4 -0
  19. package/app/components/Composer/MessageLibrary.vue +17 -7
  20. package/app/components/Composer/WabaSelectModal.vue +75 -0
  21. package/app/components/Composer/WhatsappComposer.vue +78 -3
  22. package/app/components/MessageSendFlow/EmailPreview.vue +81 -0
  23. package/app/components/MessageSendFlow/LanguageSelect.vue +39 -0
  24. package/app/components/MessageSendFlow/MessageSendFlow.vue +265 -0
  25. package/app/components/MessageSendFlow/ParametersForm.vue +51 -0
  26. package/app/components/TemplateList/BaseTable.vue +156 -108
  27. package/app/components/Whatsapp/Preview.vue +12 -4
  28. package/app/components/Whatsapp/PreviewPanel.vue +7 -1
  29. package/app/composables/composerContext.ts +9 -7
  30. package/app/composables/useEffectiveOrganization.ts +34 -0
  31. package/app/composables/useMessageGroupList.ts +186 -0
  32. package/app/composables/useMessageGroupWaba.ts +73 -0
  33. package/app/composables/useMessageSendFlow.ts +284 -0
  34. package/app/composables/usePagedMessageGroups.ts +78 -0
  35. package/app/composables/useTemplateOperations.ts +23 -4
  36. package/app/composables/useTracking.ts +14 -5
  37. package/app/composables/useWhatsappConfiguration.ts +32 -0
  38. package/app/layouts/default.vue +5 -1
  39. package/app/pages/brands.vue +1 -0
  40. package/app/pages/images.vue +4 -0
  41. package/app/pages/index.vue +1 -0
  42. package/app/pages/preview/[id].vue +2 -0
  43. package/app/pages/template/whatsapp/[id].vue +1 -0
  44. package/app/queries/brands.ts +27 -7
  45. package/app/queries/compilation.ts +16 -3
  46. package/app/queries/dynamicValues.ts +19 -2
  47. package/app/queries/media.ts +8 -3
  48. package/app/queries/messageGroups.ts +56 -17
  49. package/app/queries/meta.ts +13 -8
  50. package/app/stores/appContext.ts +18 -48
  51. package/app/stores/effectiveOrganization.ts +128 -0
  52. package/app/stores/emailEditor.ts +5 -1
  53. package/app/stores/metaSubmission.ts +7 -17
  54. package/app/types/tracking.ts +17 -0
  55. package/app/utils/testIds.const.ts +29 -0
  56. package/i18n/locales/de.ts +84 -2
  57. package/i18n/locales/en.ts +80 -2
  58. package/i18n/locales/es.ts +81 -2
  59. package/i18n/locales/fr.ts +81 -2
  60. package/i18n/locales/it.ts +82 -2
  61. package/package.json +2 -2
package/README.md CHANGED
@@ -1,75 +1,257 @@
1
- # Nuxt Minimal Starter
1
+ # @dev.smartpricing/message-composer-layer
2
2
 
3
- Look at the [Nuxt documentation](https://nuxt.com/docs/getting-started/introduction) to learn more.
3
+ The Message Composer as a [Nuxt layer](https://nuxt.com/docs/getting-started/layers). A host app extends it and gets the composer pages (message library, editor, preview, brands, images) plus embeddable components such as the **Message Send Flow**.
4
4
 
5
- ## Setup
5
+ The layer is published on the private Smartpricing npm registry together with `@dev.smartpricing/message-composer-utils` (shared types and helpers, subpath exports `/types` and `/utils`). Both packages share one version number.
6
6
 
7
- Make sure to install dependencies:
7
+ ## Host setup
8
8
 
9
- ```bash
10
- # npm
11
- npm install
9
+ 1. Install the layer and its peer:
10
+
11
+ ```bash
12
+ pnpm add @dev.smartpricing/message-composer-layer
13
+ pnpm add -D nuxt-ui-layer@github:smartpricing/smartness-nuxt-ui#v1.14.4 # peer, >= 1.3.0
14
+ ```
15
+
16
+ `nuxt-ui-layer` is extended by `@dev.smartpricing/platform-layer`, which this layer extends, so the host does not list it in `extends`.
17
+
18
+ 2. Extend it and set the API keys. `BACKEND_BASE_URL`, `PLATFORM_URL` and `CSRF_COOKIE_NAME` are declared by `@dev.smartpricing/platform-layer`, which this layer extends; set them once for both.
19
+
20
+ ```ts
21
+ // nuxt.config.ts
22
+ export default defineNuxtConfig({
23
+ extends: ['@dev.smartpricing/message-composer-layer'],
24
+ runtimeConfig: {
25
+ public: {
26
+ COMPOSER_API_URL: '', // NUXT_PUBLIC_COMPOSER_API_URL
27
+ BACKEND_BASE_URL: '', // NUXT_PUBLIC_BACKEND_BASE_URL
28
+ PLATFORM_URL: '', // NUXT_PUBLIC_PLATFORM_URL
29
+ CSRF_COOKIE_NAME: '', // NUXT_PUBLIC_CSRF_COOKIE_NAME: smt_csrf | smt_csrf_stage | smt_csrf_dev
30
+ },
31
+ },
32
+ })
33
+ ```
34
+
35
+ 3. Provide the composer context before any layer component renders (`app.vue` or a plugin). `product` is required; everything else has a default.
36
+
37
+ ```vue
38
+ <script setup lang="ts">
39
+ defineComposerContext({
40
+ product: ref('chat'), // 'connect' | 'chat' | 'pms' | 'pricing'
41
+ channels: ref(['whatsapp']),
42
+ mainLanguage: ref('it'),
43
+ // organization_id: useAuth().organizationId, // only when the Host works on one Organization
44
+ })
45
+
46
+ useNuxtApp().hook('composer:auth-error', () => {
47
+ // 401 from the composer API: the session is gone, decide what to do
48
+ })
49
+ </script>
50
+ ```
51
+
52
+ Auth is cookie-based through platform-layer. The full `ComposerContext` interface lives in `app/composables/composerContext.ts`; the design and the parent/layer contract are in [`docs/nuxt-layer-design.md`](../../docs/nuxt-layer-design.md).
53
+
54
+ See "Organization and WABA" below for what `organization_id` and `metaWabaId` do.
55
+
56
+ 4. Tracking is opt-in: set `composer.tracking: true` in the host's `app.config.ts` to have the layer post its events to `POST /tracking/event`.
57
+
58
+ The layer's pages are mounted at root (`/`, `/template/...`, `/preview/...`, `/brands`, `/images`). Remap them with `pages:extend` if the host already owns those routes.
59
+
60
+ ## Organization and WABA
12
61
 
13
- # pnpm
14
- pnpm install
62
+ No request depends on an ambient Organization: every request names the one it is for (ADR [0014](../../docs/adr/0014-organization-explicit-on-every-request.md)). The Host no longer has to know it.
15
63
 
16
- # yarn
17
- yarn install
64
+ **Organization.** `organization_id` is optional in the context.
18
65
 
19
- # bun
20
- bun install
66
+ - **Given**: the layer works on that Organization and shows no selector. Passing the key is what counts, not its value: it may arrive late (a ref filled after the first render), and until then the layer waits (`status` is `resolving`) and sends no resource request, so a Host does not need to hold the composer back until its Tenant is loaded. A Host that passes the key and never fills it leaves the composer waiting.
67
+ - **Omitted** (the key is not passed at all): the layer asks the backend which Organizations the user can reach (`GET /organizations`). With one, it uses it and looks as before. With several, the Message Group, Brands and images pages show an **Organization selector** in their header: it filters what is listed and decides where new resources are created. The choice is remembered in the browser's local storage and checked against the reachable list on load. There is no "all Organizations" view.
68
+
69
+ On an editor or preview page the Organization is always the one of the Message Group on screen, whatever the selector says, so a link to a Message Group of any Organization the user can reach works.
70
+
71
+ **Effective Organization.** `useEffectiveOrganization()` (auto-imported) is the Host's read-only view of the Organization the layer is working on: `organizationId` (`null` until resolved) and `status` (`resolving` | `resolved` | `failed` | `none`). Use it for anything the Host supplies per Organization, such as `organizationData` (the test-email sender addresses and accommodation name):
72
+
73
+ ```ts
74
+ const organizationData = ref<OrganizationData>({ senderEmails: [] })
75
+ defineComposerContext({ product: ref('chat'), organizationData })
76
+
77
+ // After `defineComposerContext()`: the composable reads the context.
78
+ const { organizationId } = useEffectiveOrganization()
79
+ watch(
80
+ organizationId,
81
+ async (id) => {
82
+ organizationData.value = id ? await loadSenders(id) : { senderEmails: [] }
83
+ },
84
+ { immediate: true },
85
+ )
21
86
  ```
22
87
 
23
- ## Development Server
88
+ **WABA.** The Host does not supply the WABA: the WABAs follow the Organization and the backend resolves them (ADR [0015](../../docs/adr/0015-message-group-waba-fixed-at-first-submission.md)). WhatsApp is configured when the Host's `channels` include `whatsapp` and the Organization has at least one WABA. A Message Group is submitted to one WABA, chosen at its first submission and fixed from then on; with several WABAs the user picks one when requesting approval. `metaWabaId` in the context is optional and is only the preselection in that picker.
24
89
 
25
- Start the development server on `http://localhost:3000`:
90
+ ### Upgrading to 4.5.0
26
91
 
27
- ```bash
28
- # npm
29
- npm run dev
92
+ Nothing is required: a Host that keeps passing `organization_id` and `metaWabaId` behaves as before.
93
+
94
+ - **Removed** from `ComposerContext` and from `AppContextConfig` in utils (no code read them): `authenticationToken`, `strategy`, `metaAccessToken`, `dinamicValues`. Remove them from the `defineComposerContext()` call. The utils types `AppContextState` and `ContextSource` are gone too.
95
+ - **Changed**: `metaWabaId` no longer decides where a WhatsApp submission goes and is no longer required for the WhatsApp channel; it preselects the WABA when the user has to pick one.
96
+ - **Deprecated**, removed in 5.0.0: the `sm-organization-id` and `x-meta-waba-id` request headers. The layer no longer sends them; the backend still accepts them from Hosts on an older layer.
97
+ - **New**: `organization_id` is optional, the Organization selector, `useEffectiveOrganization()`, and the `organizationId` and `wabaId` props of the Message Send Flow.
98
+
99
+ ## Message Send Flow
100
+
101
+ `MessageSendFlow` walks an operator from "which Message" to "here is what to send", in two steps inside a container the host owns (Smartchat: a modal):
102
+
103
+ 1. **Message**: pick a **Sendable** Message Group (at least one approved Message; groups with none are greyed out with a reason) and one of its approved languages.
104
+ 2. **Parameters**: one required input per operator parameter of the chosen Message, prefilled from the host, editable. `brand_*` keys are hidden and filled server-side. **Confirm** calls `POST /message-groups/:id/compile` once (`strict: true`, `strict_language: true`) and emits the **Sendable Message** the backend built.
105
+
106
+ The preview follows every step in a right panel: the picked Message as the guest receives it (the WhatsApp bubble for `whatsapp_v1`, the compiled email for `email_v1`), with the typed values substituted live. Plan a container at least ~1100 px wide (Nuxt UI `max-w-6xl`); the panel is 400 px, the stepper 200 px.
107
+
108
+ Back and Next move between steps, Cancel is always available, Confirm only on Parameters. The action bar renders under the steps, or wherever `actionsTo` points (a modal footer). The component has no outer padding: the host's container pads it. The component owns no overlay and no send, and it keeps its state after `compiled`: closing the container is the host's decision. There is no reset API; remount it (`:key`) to start over.
109
+
110
+ The flow works on one Organization and never shows a selector: the `organizationId` prop when the Host gives one (Smartchat: the conversation's), otherwise the one the layer resolved, which is also the one the page's selector is on. With `organizationId` the flow lists and compiles that Organization's Sendable Message Groups whatever the rest of the page is working on. With `wabaId` it offers only the WhatsApp Message Groups on the WABA the Host sends from.
30
111
 
31
- # pnpm
32
- pnpm dev
112
+ The `/compile` contract (request, response with `message`, error codes) is documented in [`docs/compile-and-send.md`](../../docs/compile-and-send.md).
33
113
 
34
- # yarn
35
- yarn dev
114
+ ### Mount
36
115
 
37
- # bun
38
- bun run dev
116
+ ```vue
117
+ <script setup lang="ts">
118
+ import type { SendableMessage } from '@dev.smartpricing/message-composer-utils/types'
119
+
120
+ const open = ref(true)
121
+ const groupId = ref<string>() // optional preselect, read back after a pick
122
+
123
+ function onCompiled(message: SendableMessage) {
124
+ open.value = false
125
+ // hand `message` to your send pipeline, see "Smartchat glue" below
126
+ }
127
+ </script>
128
+
129
+ <template>
130
+ <UModal v-model:open="open" title="New WhatsApp conversation">
131
+ <template #body>
132
+ <MessageSendFlow
133
+ v-model:message-group-id="groupId"
134
+ :organization-id="conversation.organizationId"
135
+ :waba-id="conversation.wabaId"
136
+ language="it"
137
+ :parameters="{ guest_name: 'Mario', guest_fullname: 'Mario Rossi' }"
138
+ actions-to="#send-flow-actions"
139
+ @compiled="onCompiled"
140
+ @cancel="open = false"
141
+ @error="(e) => console.warn(e.code, e.details)"
142
+ />
143
+ </template>
144
+ <template #footer>
145
+ <!-- Cancel, Back, Next and Confirm render here -->
146
+ <div id="send-flow-actions" class="w-full" />
147
+ </template>
148
+ </UModal>
149
+ </template>
39
150
  ```
40
151
 
41
- ## Production
152
+ ### Props
42
153
 
43
- Build the application for production:
154
+ | Prop | Type | Default | Description |
155
+ | ---------------- | ------------------------ | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
156
+ | `messageGroupId` | `string` (`v-model`) | `undefined` | Two-way: preselect a group, read back what the operator picked. A preselected group that is not Sendable raises `NOT_SENDABLE` and leaves the picker usable. |
157
+ | `renderType` | `RenderType` | `'whatsapp_v1'` | Channel of the Messages offered: `whatsapp_v1` or `email_v1`. Picker, languages, parameters, preview and the Sendable Message follow it. |
158
+ | `language` | `string` | `undefined` | Preferred language (see the language rule). |
159
+ | `parameters` | `Record<string, string>` | `undefined` | Editable prefill of the parameter inputs. Keys the chosen Message does not need are ignored. |
160
+ | `brandId` | `string` | `undefined` | Brand override forwarded to `/compile` as `brand_id`; defaults to the group's brand. |
161
+ | `organizationId` | `string` | `undefined` | The Organization the flow works on. Unset, the one the layer resolved. A preselected group of another Organization raises `NOT_OFFERED`. |
162
+ | `wabaId` | `string` | `undefined` | The WABA the Host sends from: with `whatsapp_v1`, only Message Groups on it are offered. Unset, the groups of every WABA. Ignored for `email_v1`. |
163
+ | `actionsTo` | `string \| HTMLElement` | `undefined` | Where the Cancel/Back/Next/Confirm bar renders, e.g. a `UModal` footer element. Unset, it renders under the steps. |
44
164
 
45
- ```bash
46
- # npm
47
- npm run build
165
+ ### Emits
166
+
167
+ | Event | Payload | When |
168
+ | ---------- | ------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
169
+ | `compiled` | `SendableMessage` | Confirm succeeded. The Sendable Message is the `message` field of the `/compile` response, untouched: `WhatsappSendableMessage` for `whatsapp_v1`, `EmailSendableMessage` for `email_v1`. |
170
+ | `cancel` | none | The operator pressed Cancel (on any step). |
171
+ | `error` | `{ code: MessageSendFlowErrorCode, message: string, details?: unknown }` | Every failure the inline alert shows, so the host can log or surface it. `message` is the layer's translated text, the same string the alert shows. |
172
+
173
+ Types: `SendableMessage` from `@dev.smartpricing/message-composer-utils/types`; `MessageSendFlowError`, `MessageSendFlowErrorCode` and `MessageSendFlowEmits` from the layer's `app/composables/useMessageSendFlow.ts`.
174
+
175
+ ### Error codes
176
+
177
+ | Code | Raised by | Meaning | `details` |
178
+ | ------------------------ | --------- | ----------------------------------------------------------------------------------------------------------------------------------- | ----------------------------- |
179
+ | `NOT_SENDABLE` | layer | The preselected or picked group has no approved Message. The picker stays usable; the alert clears once a Sendable group is picked. | none |
180
+ | `NOT_OFFERED` | layer | The preselected group belongs to another Organization than `organizationId`, or is not on `wabaId`. The picker stays usable. | none |
181
+ | `MISSING_PARAMETERS` | layer | Confirm pressed with an empty required parameter (checked client-side before calling `/compile`). | `{ missing: string[] }` |
182
+ | `MISSING_PARAMETERS` | backend | `/compile` reported a required key as absent. | the backend error body |
183
+ | `TEMPLATE_NOT_SUBMITTED` | backend | The WhatsApp template was never submitted to Meta. | the backend error body |
184
+ | `TEMPLATE_NOT_APPROVED` | backend | The Message is not approved by Meta. | the backend error body |
185
+ | `COMPILE_FAILED` | layer | Any other `/compile` failure, or a response without `message`. | the backend body or the error |
186
+
187
+ A 401 is not an `error` emit: it reaches the host through the `composer:auth-error` Nuxt hook like everywhere else in the layer.
188
+
189
+ ### Language rule
190
+
191
+ The language selected in step 1 is the `language` prop when that Message is approved, otherwise the first approved Message in the group's `messages[]` order. It is recomputed whenever the group or the `language` prop changes. Non-approved languages stay visible as disabled radio cards showing their status; only approved ones can be picked.
192
+
193
+ ### Reused blocks and their new props
194
+
195
+ - `MessageGroupSelect` (auto-import `CommonMessageGroupSelect`) gained `eligibility?: 'active' | 'sendable'`. `active` (default, the composer's own picker) selects groups whose every Message is approved; `sendable` selects groups with at least one approved Message. The disabled tooltip and the "hide inactive" filter follow the mode. The send flow uses `sendable`. It also takes `organizationId` (list that Organization instead of the resolved one) and `wabaId` (with `renderType="whatsapp_v1"`, only the groups on that WABA). Details in [`docs/message-group-select-migration.md`](../../docs/message-group-select-migration.md).
196
+ - `WhatsappPreview` gained `parameters?: Record<string, string>`: non-empty values override the organization-level resolved values, keys not in the map keep the resolved mock, an empty string shows the raw `{{key}}` placeholder.
48
197
 
49
- # pnpm
50
- pnpm build
198
+ ### Exported blocks
51
199
 
52
- # yarn
53
- yarn build
200
+ All of these are auto-imported in a host that extends the layer, so a different layout can be composed without forking:
54
201
 
55
- # bun
56
- bun run build
202
+ | Auto-import name | File | Role |
203
+ | ------------------------------- | ---------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- |
204
+ | `MessageSendFlow` | `app/components/MessageSendFlow/MessageSendFlow.vue` | The composed two-step flow. |
205
+ | `MessageSendFlowLanguageSelect` | `app/components/MessageSendFlow/LanguageSelect.vue` | Radio cards of a group's languages; props `languages`, `v-model` code. |
206
+ | `MessageSendFlowParametersForm` | `app/components/MessageSendFlow/ParametersForm.vue` | One input per key; props `parameterKeys`, `placeholders`, `loading`, `v-model` values. |
207
+ | `CommonMessageGroupSelect` | `app/components/Common/MessageGroupSelect.vue` | The picker, with `eligibility="sendable"`. |
208
+ | `WhatsappPreview` | `app/components/Whatsapp/Preview.vue` | The bubble, with the `parameters` override. |
209
+ | `MessageSendFlowEmailPreview` | `app/components/MessageSendFlow/EmailPreview.vue` | The compiled email (subject, HTML) for the typed values; props `messageGroupId`, `language`, `parameters`, `brandId`. |
210
+
211
+ The data flow (group, language rule, parameters, one compile on Confirm, error mapping) is `useMessageSendFlow()` in `app/composables/useMessageSendFlow.ts`. Test ids for every control are in `messageSendFlowTestIds` (`app/utils/testIds.const.ts`).
212
+
213
+ ### Smartchat glue
214
+
215
+ The emitted Sendable Message is the content object Smartchat already sends, so the glue is a two-field pick:
216
+
217
+ ```ts
218
+ import type { SendableMessage } from '@dev.smartpricing/message-composer-utils/types'
219
+
220
+ function onCompiled(message: SendableMessage) {
221
+ sendMessage({
222
+ contentType: message.type, // 'WhatsappTemplateMessage' | 'HtmlMessage'
223
+ content: message,
224
+ language:
225
+ message.type === 'WhatsappTemplateMessage' ? message.meta.data.language.code : language,
226
+ })
227
+ }
57
228
  ```
58
229
 
59
- Locally preview production build:
230
+ For WhatsApp, `message.meta.data` is the Meta template object (`name`, `language`, `components` with the filled parameters) and `message.meta.template` is the raw translated template stored at submission, the one Smartchat renders in the bubble. For email, `message` is the `HtmlMessage` content compile-and-send publishes (`subject`, `text` and `rawHtml` both the compiled HTML, `skipTranslation: true`); it carries no language, so the host keeps the one the operator picked from its own state.
60
231
 
61
- ```bash
62
- # npm
63
- npm run preview
232
+ ### Tracking
64
233
 
65
- # pnpm
66
- pnpm preview
234
+ With tracking enabled the flow reports `send_flow.confirm` (`message_name`, `message_category`, `language`, `parameters_count`), `send_flow.cancel` (`step`: `message` | `parameters`) and `send_flow.error` (`code`).
67
235
 
68
- # yarn
69
- yarn preview
236
+ ### Dev harness
70
237
 
71
- # bun
72
- bun run preview
238
+ `apps/frontend` (this monorepo's standalone host) has a permanent `/debug/send-flow` page that plays the host: preselect, preferred language, prefill JSON, brand id, the flow's own Organization and WABA, inline or modal container, remount, and a log of every emit.
239
+
240
+ ## Development
241
+
242
+ Run from `apps/layer`:
243
+
244
+ ```bash
245
+ pnpm typecheck # nuxt typecheck
246
+ pnpm lint # eslint app/ --fix
247
+ pnpm test:e2e # playwright
248
+ pnpm translations:check # every locale has every key
73
249
  ```
74
250
 
75
- Check out the [deployment documentation](https://nuxt.com/docs/getting-started/deployment) for more information.
251
+ There is no dev server in the layer itself: use `apps/frontend`, which extends `../layer`.
252
+
253
+ ## Release
254
+
255
+ 1. Bump `version` in `apps/layer/package.json` and `packages/message-composer-utils/package.json` together (semver: minor for a new component or prop, major for a change to the host contract).
256
+ 2. Build the tarball from `apps/layer` with `pnpm pack`; `build.sh` builds the Docker image of the standalone deployment, not the npm package.
257
+ 3. Publish both packages to the private registry.
package/app/api/brands.ts CHANGED
@@ -14,19 +14,27 @@ import { apiClient } from './client'
14
14
  export type BrandWithProperties = BrandFromDb & { properties: BrandProperty[] }
15
15
  export type BrandCreateResponse = BrandFromDb & { brand_count: number }
16
16
  export type BrandDeleteResponse = { brand_count: number }
17
+ export type BrandPreviewRequest = {
18
+ message: PostEntity<EmailMessage>
19
+ parameters?: Record<string, unknown>
20
+ brand?: BrandCascadeData | undefined
21
+ }
17
22
 
18
23
  export const brandsApi = {
19
- getAll: (options?: { sort_by?: 'created_at' | 'name'; direction?: 'asc' | 'desc' }) => {
20
- const params = new URLSearchParams()
21
- if (options?.sort_by) params.set('sort_by', options.sort_by)
22
- if (options?.direction) params.set('direction', options.direction)
23
- const query = params.size ? `?${params.toString()}` : ''
24
- return apiClient<BrandWithProperties[]>(`/brands${query}`)
24
+ getAll: (options: {
25
+ organization_id: string
26
+ sort_by?: 'created_at' | 'name'
27
+ direction?: 'asc' | 'desc'
28
+ }) => {
29
+ const params = new URLSearchParams({ organization_id: options.organization_id })
30
+ if (options.sort_by) params.set('sort_by', options.sort_by)
31
+ if (options.direction) params.set('direction', options.direction)
32
+ return apiClient<BrandWithProperties[]>(`/brands?${params.toString()}`)
25
33
  },
26
34
 
27
35
  getById: (id: string) => apiClient<BrandWithProperties>(`/brands/${id}`),
28
36
 
29
- create: (data: BrandCreateBody) =>
37
+ create: (data: BrandCreateBody & { organization_id: string }) =>
30
38
  apiClient<BrandCreateResponse>('/brands', {
31
39
  method: 'POST',
32
40
  body: data,
@@ -43,11 +51,7 @@ export const brandsApi = {
43
51
 
44
52
  delete: (id: string) => apiClient<BrandDeleteResponse>(`/brands/${id}`, { method: 'DELETE' }),
45
53
 
46
- preview: (data: {
47
- message: PostEntity<EmailMessage>
48
- parameters?: Record<string, unknown>
49
- brand?: BrandCascadeData | undefined
50
- }) =>
54
+ preview: (data: BrandPreviewRequest & { organization_id: string }) =>
51
55
  apiClient<CompileEmailResponse>('/message-groups/preview', {
52
56
  method: 'POST',
53
57
  body: data,
@@ -55,5 +59,8 @@ export const brandsApi = {
55
59
  }
56
60
 
57
61
  export const propertiesApi = {
58
- getAll: () => apiClient<Property[]>('/properties'),
62
+ getAll: (organizationId: string) =>
63
+ apiClient<Property[]>(
64
+ `/properties?${new URLSearchParams({ organization_id: organizationId })}`,
65
+ ),
59
66
  }
package/app/api/client.ts CHANGED
@@ -24,19 +24,21 @@ export const apiClient = createApiClient({
24
24
  (useRuntimeConfig().public.BACKEND_BASE_URL as string) ||
25
25
  (useRuntimeConfig().public.PLATFORM_URL as string),
26
26
  platformURL: useRuntimeConfig().public.PLATFORM_URL as string,
27
+ // The composer backend answers a dead access token with @fastify/jwt's flat
28
+ // body (`code: FST_JWT_*` at top level), never the platform's nested
29
+ // `error.code: session.*` that the default predicate looks for. Refresh on
30
+ // any 401, as Connect's client does; the retry decides.
31
+ shouldRefresh: (status) => status === 401,
27
32
  onRefreshFailed: () => {
28
33
  useNuxtApp().callHook('composer:auth-error', { statusCode: 401 })
29
34
  },
30
35
  },
36
+ // No Organization header: every request names its Organization itself, in
37
+ // the query, in the body or through the resource it acts on (ADR 0014).
38
+ // No WABA header either: a submission names its WABA in the body (ADR 0015).
31
39
  onRequest({ options }) {
32
40
  const store = useAppContextStore()
33
41
 
34
- if (store.organization_id) {
35
- options.headers.set('sm-organization-id', store.organization_id)
36
- }
37
- if (store.metaWabaId) {
38
- options.headers.set('x-meta-waba-id', store.metaWabaId)
39
- }
40
42
  if (store.product) {
41
43
  options.headers.set('sm-product', store.product)
42
44
  }
@@ -45,6 +47,13 @@ export const apiClient = createApiClient({
45
47
  }
46
48
  },
47
49
  onResponseError({ response, options }) {
50
+ // A 401 that reaches here survived the refresh attempt (or its retry): the
51
+ // session is dead. platform-layer only calls onRefreshFailed for its own
52
+ // `session.*` codes, so tell the host here for the composer's `FST_JWT_*`.
53
+ if (response.status === 401) {
54
+ useNuxtApp().callHook('composer:auth-error', { statusCode: 401 })
55
+ return
56
+ }
48
57
  if ((options as { _skipErrorToast?: boolean })._skipErrorToast) return
49
58
 
50
59
  const t = useNuxtApp().$i18n.t
@@ -5,6 +5,7 @@ import type {
5
5
  GetResolvedParametersResponse,
6
6
  CompileMessageRequest,
7
7
  CompileEmailResponse,
8
+ CompileMessageResponse,
8
9
  PreviewMessageRequest,
9
10
  } from '@dev.smartpricing/message-composer-utils/types'
10
11
  import { apiClient } from './client'
@@ -26,11 +27,20 @@ export const compilationApi = {
26
27
  `/message-groups/${messageGroupId}/parameters/resolved?language=${language}`,
27
28
  ),
28
29
 
29
- compileEmail: (messageGroupId: string, data: CompileMessageRequest) =>
30
- apiClient<CompileEmailResponse>(`/message-groups/${messageGroupId}/compile`, {
30
+ /**
31
+ * WhatsApp responses carry `message`, the Sendable Message (docs/compile-and-send.md).
32
+ * `skipErrorToast` lets a caller with its own inline error surface silence the global toast.
33
+ */
34
+ compile: (
35
+ messageGroupId: string,
36
+ data: CompileMessageRequest,
37
+ options: { skipErrorToast?: boolean } = {},
38
+ ) =>
39
+ apiClient<CompileMessageResponse>(`/message-groups/${messageGroupId}/compile`, {
31
40
  method: 'POST',
32
41
  body: data,
33
- }),
42
+ _skipErrorToast: options.skipErrorToast,
43
+ } as Record<string, unknown>),
34
44
 
35
45
  previewEmail: (data: PreviewMessageRequest) =>
36
46
  apiClient<CompileEmailResponse>('/message-groups/preview', {
@@ -1,11 +1,15 @@
1
- import type { DynamicValuesResponse } from '@dev.smartpricing/message-composer-utils/types'
1
+ import type {
2
+ DynamicValuesResponse,
3
+ MessageGroupProduct,
4
+ } from '@dev.smartpricing/message-composer-utils/types'
2
5
  import { apiClient } from './client'
3
6
 
4
7
  export const dynamicValuesApi = {
5
- get: (product: string, language: string) => {
8
+ get: (product: MessageGroupProduct, language: string, organizationId: string) => {
6
9
  const params = new URLSearchParams()
7
10
  params.set('product', product)
8
11
  params.set('language', language)
12
+ params.set('organization_id', organizationId)
9
13
  return apiClient<DynamicValuesResponse>(`/dynamic-values?${params.toString()}`)
10
14
  },
11
15
  }
package/app/api/media.ts CHANGED
@@ -2,12 +2,15 @@ import type { MediaFromDb } from '@dev.smartpricing/message-composer-utils/types
2
2
  import { apiClient } from './client'
3
3
 
4
4
  export const mediaApi = {
5
- list: () => apiClient<MediaFromDb[]>('/media'),
5
+ list: (organizationId: string) =>
6
+ apiClient<MediaFromDb[]>(`/media?${new URLSearchParams({ organization_id: organizationId })}`),
6
7
 
7
- upload: (file: File) => {
8
+ upload: (file: File, organizationId: string) => {
8
9
  const formData = new FormData()
9
- formData.append('file', file)
10
+ // Before the file: the backend reads only the fields that precede it.
11
+ formData.append('organization_id', organizationId)
10
12
  formData.append('fileName', file.name)
13
+ formData.append('file', file)
11
14
  return apiClient<MediaFromDb>('/media/upload', {
12
15
  method: 'POST',
13
16
  body: formData,
@@ -1,27 +1,38 @@
1
1
  import type {
2
2
  MessageGroupFromDb,
3
+ PaginatedResponse,
3
4
  MessageGroupWithMessages,
4
5
  MessageGroupWithMessageSummary,
5
6
  MessageGroupValidationResponse,
6
7
  PostEntity,
7
8
  MessageGroup,
8
- RenderType,
9
9
  DuplicateMessageGroupBody,
10
+ ListMessageGroupsQuery,
10
11
  } from '@dev.smartpricing/message-composer-utils/types'
11
12
  import { apiClient } from './client'
12
13
 
14
+ /**
15
+ * Query params of `GET /message-groups`, straight from the shared schema
16
+ * (ADR 0007): snake_case, as on the wire. `visibility` other than `public`
17
+ * is admin-only (403 otherwise); every field is optional here, the server
18
+ * applies the defaults.
19
+ */
20
+ export type ListMessageGroupsOptions = Partial<ListMessageGroupsQuery>
21
+
22
+ function listQueryString(options: ListMessageGroupsOptions = {}): string {
23
+ const params = new URLSearchParams()
24
+ for (const [name, value] of Object.entries(options)) {
25
+ if (value !== undefined && value !== '') params.set(name, String(value))
26
+ }
27
+ return params.size ? `?${params.toString()}` : ''
28
+ }
29
+
13
30
  export const messageGroupsApi = {
14
- getAll: (options?: { renderType?: RenderType; tags?: string[]; tagsMode?: 'and' | 'or' }) => {
15
- const params = new URLSearchParams()
16
- if (options?.renderType) params.set('render_type', options.renderType)
17
- if (options?.tags && options.tags.length > 0) {
18
- options.tags.forEach((tag) => params.append('tags', tag))
19
- }
20
- if (options?.tagsMode) params.set('tags_mode', options.tagsMode)
21
-
22
- const query = params.size ? `?${params.toString()}` : ''
23
- return apiClient<MessageGroupWithMessageSummary[]>(`/message-groups${query}`)
24
- },
31
+ /** One page of the server-side filtered list, in the Laravel envelope (ADR 0012). */
32
+ list: (options?: ListMessageGroupsOptions) =>
33
+ apiClient<PaginatedResponse<MessageGroupWithMessageSummary>>(
34
+ `/message-groups${listQueryString(options)}`,
35
+ ),
25
36
 
26
37
  getById: (id: string) => apiClient<MessageGroupWithMessages>(`/message-groups/${id}`),
27
38
 
@@ -40,10 +51,14 @@ export const messageGroupsApi = {
40
51
  delete: (id: string) => apiClient(`/message-groups/${id}`, { method: 'DELETE' }),
41
52
 
42
53
  publish: (id: string) =>
43
- apiClient<MessageGroupFromDb>(`/message-groups/${id}/publish`, { method: 'PUT' }),
54
+ apiClient<MessageGroupFromDb>(`/message-groups/${id}/publish`, {
55
+ method: 'PUT',
56
+ }),
44
57
 
45
58
  unpublish: (id: string) =>
46
- apiClient<MessageGroupFromDb>(`/message-groups/${id}/unpublish`, { method: 'PUT' }),
59
+ apiClient<MessageGroupFromDb>(`/message-groups/${id}/unpublish`, {
60
+ method: 'PUT',
61
+ }),
47
62
 
48
63
  validate: (id: string) =>
49
64
  apiClient<MessageGroupValidationResponse>(`/message-groups/${id}/validate`),
package/app/api/meta.ts CHANGED
@@ -1,22 +1,44 @@
1
1
  import type {
2
+ OrganizationWaba,
2
3
  SubmissionJob,
3
4
  SubmissionJobResponse,
5
+ SubmitToWabaBody,
4
6
  } from '@dev.smartpricing/message-composer-utils/types'
5
7
  import { apiClient } from './client'
6
8
 
9
+ const submitBody = (wabaId?: string): SubmitToWabaBody => (wabaId ? { waba_id: wabaId } : {})
10
+
7
11
  export const metaApi = {
8
- submitGroup: (messageGroupId: string) =>
12
+ /**
13
+ * The WABAs of an Organization, empty when it has none (ADR 0015). No error
14
+ * toast: a failed lookup leaves the WhatsApp configuration unknown, and the
15
+ * submission itself reports what is wrong.
16
+ */
17
+ getWabas: (organizationId: string) => {
18
+ const params = new URLSearchParams()
19
+ params.set('organization_id', organizationId)
20
+ return apiClient<OrganizationWaba[]>(`/integrations/meta/wabas?${params.toString()}`, {
21
+ _skipErrorToast: true,
22
+ } as Record<string, unknown>)
23
+ },
24
+
25
+ /**
26
+ * `wabaId` names the WABA of a first submission, when the user had to pick
27
+ * one. Left out, the backend uses the Message Group's WABA or the
28
+ * Organization's only one.
29
+ */
30
+ submitGroup: (messageGroupId: string, wabaId?: string) =>
9
31
  apiClient<SubmissionJobResponse>(`/integrations/meta/submit-group/${messageGroupId}`, {
10
32
  method: 'POST',
11
- body: {},
33
+ body: submitBody(wabaId),
12
34
  }),
13
35
 
14
- submitMessage: (messageId: string) =>
36
+ submitMessage: (messageId: string, wabaId?: string) =>
15
37
  apiClient<{ success: boolean; templateId?: string }>(
16
38
  `/integrations/meta/submit-message/${messageId}`,
17
39
  {
18
40
  method: 'POST',
19
- body: {},
41
+ body: submitBody(wabaId),
20
42
  },
21
43
  ),
22
44
 
@@ -1,5 +1,14 @@
1
+ import type { ReachableOrganization } from '@dev.smartpricing/message-composer-utils/types'
1
2
  import { apiClient } from './client'
2
3
 
3
4
  export const organizationApi = {
4
- getData: () => apiClient<Record<string, string>>('/organization/data'),
5
+ /**
6
+ * The user's Reachable Organizations: the answer is what resolves the
7
+ * Effective Organization. No error toast, the Organization gate renders
8
+ * the failure.
9
+ */
10
+ getReachable: () =>
11
+ apiClient<ReachableOrganization[]>('/organizations', {
12
+ _skipErrorToast: true,
13
+ } as Record<string, unknown>),
5
14
  }
@@ -1,9 +1,9 @@
1
1
  import { apiClient } from './client'
2
2
 
3
3
  export const trackingApi = {
4
- event: (eventName: string, properties?: Record<string, unknown>) =>
4
+ event: (eventName: string, organizationId: string, properties?: Record<string, unknown>) =>
5
5
  apiClient<{ success: boolean }>('/tracking/event', {
6
6
  method: 'POST',
7
- body: { eventName, properties },
7
+ body: { eventName, properties, organization_id: organizationId },
8
8
  }),
9
9
  }