@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.
- package/README.md +228 -46
- package/app/api/brands.ts +20 -13
- package/app/api/client.ts +15 -6
- package/app/api/compilation.ts +13 -3
- package/app/api/dynamicValues.ts +6 -2
- package/app/api/media.ts +6 -3
- package/app/api/messageGroups.ts +29 -14
- package/app/api/meta.ts +26 -4
- package/app/api/organization.ts +10 -1
- package/app/api/tracking.ts +2 -2
- package/app/components/Common/AppContextErrors.vue +30 -0
- package/app/components/Common/HtmlPreviewIframe.vue +17 -3
- package/app/components/Common/MessageGroupSelect.vue +164 -26
- package/app/components/Common/MessagePreviewSlideover.vue +3 -0
- package/app/components/Common/OrganizationGate.vue +51 -0
- package/app/components/Common/OrganizationSelect.vue +27 -0
- package/app/components/Common/WhatsappNotConfigured.vue +20 -0
- package/app/components/Composer/EmailComposer.vue +4 -0
- package/app/components/Composer/MessageLibrary.vue +17 -7
- package/app/components/Composer/WabaSelectModal.vue +75 -0
- package/app/components/Composer/WhatsappComposer.vue +78 -3
- package/app/components/MessageSendFlow/EmailPreview.vue +81 -0
- package/app/components/MessageSendFlow/LanguageSelect.vue +39 -0
- package/app/components/MessageSendFlow/MessageSendFlow.vue +265 -0
- package/app/components/MessageSendFlow/ParametersForm.vue +51 -0
- package/app/components/TemplateList/BaseTable.vue +156 -108
- package/app/components/Whatsapp/Preview.vue +12 -4
- package/app/components/Whatsapp/PreviewPanel.vue +7 -1
- package/app/composables/composerContext.ts +9 -7
- package/app/composables/useEffectiveOrganization.ts +34 -0
- package/app/composables/useMessageGroupList.ts +186 -0
- package/app/composables/useMessageGroupWaba.ts +73 -0
- package/app/composables/useMessageSendFlow.ts +284 -0
- package/app/composables/usePagedMessageGroups.ts +78 -0
- package/app/composables/useTemplateOperations.ts +23 -4
- package/app/composables/useTracking.ts +14 -5
- package/app/composables/useWhatsappConfiguration.ts +32 -0
- package/app/layouts/default.vue +5 -1
- package/app/pages/brands.vue +1 -0
- package/app/pages/images.vue +4 -0
- package/app/pages/index.vue +1 -0
- package/app/pages/preview/[id].vue +2 -0
- package/app/pages/template/whatsapp/[id].vue +1 -0
- package/app/queries/brands.ts +27 -7
- package/app/queries/compilation.ts +16 -3
- package/app/queries/dynamicValues.ts +19 -2
- package/app/queries/media.ts +8 -3
- package/app/queries/messageGroups.ts +56 -17
- package/app/queries/meta.ts +13 -8
- package/app/stores/appContext.ts +18 -48
- package/app/stores/effectiveOrganization.ts +128 -0
- package/app/stores/emailEditor.ts +5 -1
- package/app/stores/metaSubmission.ts +7 -17
- package/app/types/tracking.ts +17 -0
- package/app/utils/testIds.const.ts +29 -0
- package/i18n/locales/de.ts +84 -2
- package/i18n/locales/en.ts +80 -2
- package/i18n/locales/es.ts +81 -2
- package/i18n/locales/fr.ts +81 -2
- package/i18n/locales/it.ts +82 -2
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -1,75 +1,257 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @dev.smartpricing/message-composer-layer
|
|
2
2
|
|
|
3
|
-
|
|
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
|
-
|
|
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
|
-
|
|
7
|
+
## Host setup
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
-
|
|
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
|
-
|
|
17
|
-
yarn install
|
|
64
|
+
**Organization.** `organization_id` is optional in the context.
|
|
18
65
|
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
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
|
-
|
|
90
|
+
### Upgrading to 4.5.0
|
|
26
91
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
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
|
-
|
|
35
|
-
yarn dev
|
|
114
|
+
### Mount
|
|
36
115
|
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
152
|
+
### Props
|
|
42
153
|
|
|
43
|
-
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
50
|
-
pnpm build
|
|
198
|
+
### Exported blocks
|
|
51
199
|
|
|
52
|
-
|
|
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
|
-
|
|
56
|
-
|
|
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
|
-
|
|
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
|
-
|
|
62
|
-
# npm
|
|
63
|
-
npm run preview
|
|
232
|
+
### Tracking
|
|
64
233
|
|
|
65
|
-
|
|
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
|
-
|
|
69
|
-
yarn preview
|
|
236
|
+
### Dev harness
|
|
70
237
|
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
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
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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: () =>
|
|
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
|
package/app/api/compilation.ts
CHANGED
|
@@ -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
|
-
|
|
30
|
-
|
|
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', {
|
package/app/api/dynamicValues.ts
CHANGED
|
@@ -1,11 +1,15 @@
|
|
|
1
|
-
import type {
|
|
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:
|
|
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: () =>
|
|
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
|
-
|
|
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,
|
package/app/api/messageGroups.ts
CHANGED
|
@@ -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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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`, {
|
|
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`, {
|
|
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
|
-
|
|
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
|
|
package/app/api/organization.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/app/api/tracking.ts
CHANGED
|
@@ -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
|
}
|