@dev.smartpricing/message-composer-layer 4.4.1-next.0 → 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 (49) hide show
  1. package/README.md +51 -5
  2. package/app/api/brands.ts +20 -13
  3. package/app/api/client.ts +3 -6
  4. package/app/api/dynamicValues.ts +2 -1
  5. package/app/api/media.ts +6 -3
  6. package/app/api/messageGroups.ts +6 -2
  7. package/app/api/meta.ts +26 -4
  8. package/app/api/organization.ts +10 -1
  9. package/app/api/tracking.ts +2 -2
  10. package/app/components/Common/AppContextErrors.vue +2 -2
  11. package/app/components/Common/MessageGroupSelect.vue +13 -2
  12. package/app/components/Common/OrganizationGate.vue +51 -0
  13. package/app/components/Common/OrganizationSelect.vue +27 -0
  14. package/app/components/Common/WhatsappNotConfigured.vue +20 -0
  15. package/app/components/Composer/EmailComposer.vue +4 -0
  16. package/app/components/Composer/WabaSelectModal.vue +75 -0
  17. package/app/components/Composer/WhatsappComposer.vue +78 -3
  18. package/app/components/MessageSendFlow/EmailPreview.vue +3 -0
  19. package/app/components/MessageSendFlow/MessageSendFlow.vue +15 -2
  20. package/app/composables/composerContext.ts +5 -5
  21. package/app/composables/useEffectiveOrganization.ts +34 -0
  22. package/app/composables/useMessageGroupWaba.ts +73 -0
  23. package/app/composables/useMessageSendFlow.ts +46 -8
  24. package/app/composables/usePagedMessageGroups.ts +14 -2
  25. package/app/composables/useTemplateOperations.ts +10 -1
  26. package/app/composables/useTracking.ts +14 -5
  27. package/app/composables/useWhatsappConfiguration.ts +32 -0
  28. package/app/layouts/default.vue +4 -1
  29. package/app/pages/brands.vue +1 -0
  30. package/app/pages/images.vue +4 -0
  31. package/app/pages/index.vue +1 -0
  32. package/app/pages/preview/[id].vue +2 -0
  33. package/app/pages/template/whatsapp/[id].vue +1 -0
  34. package/app/queries/brands.ts +27 -7
  35. package/app/queries/compilation.ts +15 -2
  36. package/app/queries/dynamicValues.ts +13 -2
  37. package/app/queries/media.ts +8 -3
  38. package/app/queries/messageGroups.ts +31 -5
  39. package/app/queries/meta.ts +13 -8
  40. package/app/stores/appContext.ts +9 -45
  41. package/app/stores/effectiveOrganization.ts +128 -0
  42. package/app/stores/emailEditor.ts +5 -1
  43. package/app/stores/metaSubmission.ts +7 -17
  44. package/i18n/locales/de.ts +28 -0
  45. package/i18n/locales/en.ts +27 -0
  46. package/i18n/locales/es.ts +27 -0
  47. package/i18n/locales/fr.ts +28 -0
  48. package/i18n/locales/it.ts +27 -0
  49. package/package.json +2 -2
@@ -27,8 +27,33 @@ const { mainLanguage } = storeToRefs(useAppContextStore())
27
27
  const { trackEvent } = useTracking()
28
28
  const { goBackOrFallback } = useGoBack()
29
29
  const { submitToMeta, submitMessageToMeta } = useMetaSubmissionStore()
30
+ const { status: whatsappConfiguration } = useWhatsappConfiguration()
30
31
  const { confirm } = useConfirm()
31
32
  const { t } = useI18n()
33
+ const toast = useToast()
34
+
35
+ /**
36
+ * A submission needs a WABA of the Message Group's Organization: when it is
37
+ * known to have none, or the Message Group is on one the Organization no
38
+ * longer has, say so before anything is saved on the way to the submission.
39
+ * While that is not known (lookup running or failed) the submission goes
40
+ * ahead and the backend decides.
41
+ */
42
+ function canSubmitToMeta(): boolean {
43
+ const reason =
44
+ whatsappConfiguration.value === 'not_configured'
45
+ ? 'whatsapp_not_configured'
46
+ : isWabaDetached.value
47
+ ? 'whatsapp_waba_detached'
48
+ : null
49
+ if (!reason) return true
50
+ toast.add({
51
+ color: 'error',
52
+ title: t(`common.${reason}_title`),
53
+ description: t(`common.${reason}_description`),
54
+ })
55
+ return false
56
+ }
32
57
 
33
58
  // ── Init ──────────────────────────────────────────────────────────
34
59
  if (props.mode === 'create') {
@@ -41,6 +66,19 @@ const { data: messageGroupData, isPending: isFetching } =
41
66
  ? useMessageGroupQuery(props.messageGroupId)
42
67
  : { data: ref(null), isPending: ref(false) }
43
68
 
69
+ // The editor works on the Organization of the Message Group it loaded, not on
70
+ // the selector's. Claimed before the watcher below loads dependent data.
71
+ useResourceOrganization(() => messageGroupData.value?.organization_id)
72
+
73
+ // The WABA the Message Group is on, and the choice of one at its first
74
+ // submission (ADR 0015).
75
+ const {
76
+ waba: messageGroupWaba,
77
+ isDetached: isWabaDetached,
78
+ chooseWaba,
79
+ markSubmitted,
80
+ } = useMessageGroupWaba(() => messageGroupData.value?.waba_id)
81
+
44
82
  // ── Change tracking ───────────────────────────────────────────────
45
83
  const changeTracking = useChangeTracking(computed(() => store.changeTrackingId))
46
84
 
@@ -238,6 +276,10 @@ async function saveAsNew() {
238
276
  }
239
277
 
240
278
  async function requestApproval() {
279
+ // Asked before anything is saved: giving up leaves the editor as it was.
280
+ const choice = await chooseWaba()
281
+ if (!choice) return
282
+
241
283
  track('request_approval', getWaDraftProps())
242
284
 
243
285
  if (props.mode === 'create') {
@@ -246,7 +288,7 @@ async function requestApproval() {
246
288
  'draft',
247
289
  )
248
290
  if (result) {
249
- await submitToMeta(result.id.toString())
291
+ await submitToMeta(result.id.toString(), choice.wabaId)
250
292
  emit('saved', { id: result.id, name: result.name })
251
293
  notifyParent('TEMPLATE_APPROVAL_REQUESTED', { id: result.id, name: result.name })
252
294
  }
@@ -256,7 +298,7 @@ async function requestApproval() {
256
298
  messageGroup: store.messageGroupUpdateData,
257
299
  messages: store.messages,
258
300
  })
259
- await submitToMeta(store.messageGroupId)
301
+ await submitToMeta(store.messageGroupId, choice.wabaId)
260
302
  emit('saved', { id: store.messageGroupId, name: store.name })
261
303
  notifyParent('TEMPLATE_APPROVAL_REQUESTED', { id: store.messageGroupId, name: store.name })
262
304
  goBackOrFallback()
@@ -267,6 +309,10 @@ async function requestApproval() {
267
309
  async function onRequestReview(languageId: string) {
268
310
  const message = store.messages[languageId] as (WhatsappMessage & { id?: string }) | undefined
269
311
  if (!message?.id) return
312
+ if (!canSubmitToMeta()) return
313
+
314
+ const choice = await chooseWaba()
315
+ if (!choice) return
270
316
 
271
317
  const languageName = languages.value?.find((l) => l.id === languageId)?.name ?? languageId
272
318
 
@@ -278,7 +324,8 @@ async function onRequestReview(languageId: string) {
278
324
  action: async () => {
279
325
  // Decision C: persist only this language's current edits, then submit it.
280
326
  await messagesApi.update(message.id!, message)
281
- await submitMessageToMeta(message.id!)
327
+ await submitMessageToMeta(message.id!, choice.wabaId)
328
+ markSubmitted(choice.wabaId)
282
329
  // Reflect pending locally instead of refetching the group, so unsaved
283
330
  // edits in other languages are preserved.
284
331
  store.messages[languageId] = { ...message, status: 'pending' }
@@ -312,6 +359,7 @@ async function handleSaveAsNew() {
312
359
  }
313
360
 
314
361
  async function handleRequestApproval() {
362
+ if (!canSubmitToMeta()) return
315
363
  if (!(await validateFull())) return
316
364
  if (hasIncompleteTranslations.value) {
317
365
  pendingAction.value = 'request_approval'
@@ -409,6 +457,7 @@ defineExpose({
409
457
  validationErrors,
410
458
  isPending: store.isPending,
411
459
  isApproved: store.isApproved,
460
+ isWabaDetached,
412
461
  })
413
462
  </script>
414
463
 
@@ -439,6 +488,32 @@ defineExpose({
439
488
  </UAlert>
440
489
  </div>
441
490
 
491
+ <UAlert
492
+ v-if="isWabaDetached"
493
+ color="warning"
494
+ variant="subtle"
495
+ icon="i-lucide-unplug"
496
+ :title="$t('common.whatsapp_waba_detached_title')"
497
+ :description="$t('common.whatsapp_waba_detached_description')"
498
+ class="rounded-none"
499
+ data-testid="whatsapp-waba-detached"
500
+ />
501
+ <div
502
+ v-if="messageGroupWaba"
503
+ class="flex items-center gap-2 px-6 py-2 text-sm text-muted border-b border-default"
504
+ data-testid="whatsapp-message-group-waba"
505
+ >
506
+ <UIcon name="i-lucide-building-2" class="size-4 shrink-0" />
507
+ <span>{{ $t('common.whatsapp_waba_label') }}</span>
508
+ <span v-if="messageGroupWaba.name" class="font-medium text-default">
509
+ {{ messageGroupWaba.name }}
510
+ </span>
511
+ <span v-if="messageGroupWaba.phone_numbers.length">
512
+ {{ messageGroupWaba.phone_numbers.join(', ') }}
513
+ </span>
514
+ <span class="text-xs text-dimmed font-mono">{{ messageGroupWaba.id }}</span>
515
+ </div>
516
+
442
517
  <ComposerWhatsappContent
443
518
  v-if="store.activeTab === 'content'"
444
519
  :mode="mode"
@@ -22,6 +22,8 @@ const props = defineProps<{
22
22
  // The panel follows the form live; one compile per pause, not per keystroke.
23
23
  const debouncedParameters = refDebounced(toRef(props, 'parameters'), 300)
24
24
 
25
+ const organization = useEffectiveOrganizationStore()
26
+
25
27
  const { data, isPending, isLoading, error } = useQuery({
26
28
  key: () => [
27
29
  'send-flow',
@@ -30,6 +32,7 @@ const { data, isPending, isLoading, error } = useQuery({
30
32
  props.language,
31
33
  props.brandId ?? '',
32
34
  JSON.stringify(debouncedParameters.value),
35
+ organization.organizationId,
33
36
  ],
34
37
  query: () =>
35
38
  compilationApi.compile(
@@ -12,7 +12,6 @@ import EmailPreview from './EmailPreview.vue'
12
12
  import LanguageSelect from './LanguageSelect.vue'
13
13
  import ParametersForm from './ParametersForm.vue'
14
14
  import { useMessageSendFlow, type MessageSendFlowEmits } from '~/composables/useMessageSendFlow'
15
- import { useTracking } from '~/composables/useTracking'
16
15
  import type { MessageSendFlowStep } from '~/types/tracking'
17
16
  import { messageSendFlowTestIds } from '~/utils/testIds.const'
18
17
 
@@ -39,6 +38,16 @@ const props = withDefaults(
39
38
  parameters?: Record<string, string>
40
39
  /** Brand override passed to `/compile`. */
41
40
  brandId?: string
41
+ /**
42
+ * The Organization the flow works on (ADR 0014), e.g. the conversation's.
43
+ * Unset, the one the layer resolved. The flow never shows a selector.
44
+ */
45
+ organizationId?: string
46
+ /**
47
+ * The WABA the Host sends from: only WhatsApp groups on it are offered
48
+ * (ADR 0015). Unset, the groups of every WABA of the Organization.
49
+ */
50
+ wabaId?: string
42
51
  /**
43
52
  * Where to render the action bar (Cancel, Back, Next, Confirm): a selector
44
53
  * or element in the host's container, e.g. a `UModal` footer. Unset, the
@@ -53,7 +62,6 @@ const groupId = defineModel<string>('messageGroupId')
53
62
  const emit = defineEmits<MessageSendFlowEmits>()
54
63
 
55
64
  const { t } = useI18n()
56
- const { trackEvent } = useTracking()
57
65
 
58
66
  const {
59
67
  group,
@@ -70,12 +78,15 @@ const {
70
78
  compiling,
71
79
  canConfirm,
72
80
  confirm,
81
+ trackEvent,
73
82
  } = useMessageSendFlow({
74
83
  groupId,
75
84
  renderType: () => props.renderType,
76
85
  preferredLanguage: () => props.language,
77
86
  prefill: () => props.parameters,
78
87
  brandId: () => props.brandId,
88
+ organizationId: () => props.organizationId,
89
+ wabaId: () => props.wabaId,
79
90
  emit,
80
91
  })
81
92
 
@@ -152,6 +163,8 @@ function cancel() {
152
163
  <MessageGroupSelect
153
164
  v-model="groupId"
154
165
  :render-type="renderType"
166
+ :organization-id="organizationId"
167
+ :waba-id="wabaId"
155
168
  eligibility="sendable"
156
169
  class="w-full"
157
170
  />
@@ -9,19 +9,19 @@ export interface OrganizationData {
9
9
  }
10
10
 
11
11
  export interface ComposerContext {
12
- /** @deprecated No longer used — auth is cookie-based via platform layer */
13
- authenticationToken?: Ref<string>
14
12
  organization_id?: Ref<string>
15
13
  /** The Product the host embeds the composer in (ADR 0011). Required. */
16
14
  product: Ref<AppContextConfig['product']>
17
- strategy?: Ref<AppContextConfig['strategy']>
18
15
  channels?: Ref<AppContextConfig['channels']>
19
16
  languages?: Ref<string[]>
20
17
  mainLanguage?: Ref<string>
21
- dinamicValues?: Ref<Array<{ value: string; label: string }>>
22
18
  default_tags?: Ref<string[]>
19
+ /**
20
+ * The WABA the Host works on. Only a preselection: when the user has to
21
+ * pick the WABA of a Message Group, this one is proposed if it is one of
22
+ * the Organization's (ADR 0015).
23
+ */
23
24
  metaWabaId?: Ref<string>
24
- metaAccessToken?: Ref<string>
25
25
  isAdmin?: Ref<boolean>
26
26
  organizationData?: Ref<OrganizationData>
27
27
  }
@@ -0,0 +1,34 @@
1
+ import type { MaybeRefOrGetter } from 'vue'
2
+
3
+ /**
4
+ * Read-only view of the Organization the layer is working with, for the Host:
5
+ * e.g. to supply per-Organization `organizationData`. On an editor or preview
6
+ * page it is the Organization of the loaded Message Group, whatever the
7
+ * selector says. `organizationId` is `null` until `status` is `resolved`.
8
+ */
9
+ export function useEffectiveOrganization() {
10
+ const { organizationId, status } = storeToRefs(useEffectiveOrganizationStore())
11
+ return { organizationId, status }
12
+ }
13
+
14
+ /**
15
+ * Makes the Organization of the resource a page views or edits the Effective
16
+ * Organization for as long as the calling component lives, so every dependent
17
+ * query (Brands, images, dynamic values) and everything the page creates
18
+ * follows the resource, not the selector (ADR 0014). Pass the Organization
19
+ * of the loaded resource; nothing is claimed while it is still loading.
20
+ */
21
+ export function useResourceOrganization(
22
+ organizationId: MaybeRefOrGetter<string | null | undefined>,
23
+ ) {
24
+ const store = useEffectiveOrganizationStore()
25
+ const claimant = Symbol('resource-organization')
26
+
27
+ // Sync: whatever the same load triggers next must already see the claim.
28
+ watch(
29
+ () => toValue(organizationId),
30
+ (id) => store.claimResource(claimant, id),
31
+ { immediate: true, flush: 'sync' },
32
+ )
33
+ onScopeDispose(() => store.releaseResource(claimant))
34
+ }
@@ -0,0 +1,73 @@
1
+ import type { OrganizationWaba } from '@dev.smartpricing/message-composer-utils/types'
2
+ import WabaSelectModal from '~/components/Composer/WabaSelectModal.vue'
3
+
4
+ /**
5
+ * The WABA of the Message Group in the WhatsApp editor (ADR 0015): chosen at
6
+ * the first submission and fixed from then on.
7
+ *
8
+ * `storedWabaId` is the WABA the loaded Message Group is on, if any.
9
+ */
10
+ export function useMessageGroupWaba(storedWabaId: MaybeRefOrGetter<string | null | undefined>) {
11
+ const { data: wabas, refresh } = useOrganizationWabasQuery()
12
+ const { metaWabaId } = storeToRefs(useAppContextStore())
13
+ const modal = useOverlay().create(WabaSelectModal)
14
+
15
+ // A submission of one language does not read the Message Group again: its
16
+ // WABA is remembered here until it does.
17
+ const submittedWabaId = ref<string>()
18
+
19
+ const wabaId = computed(() => toValue(storedWabaId) ?? submittedWabaId.value ?? null)
20
+
21
+ /** The WABA the Message Group is on, by id alone when it is not listed. */
22
+ const waba = computed<OrganizationWaba | null>(() => {
23
+ if (!wabaId.value) return null
24
+ return (
25
+ wabas.value?.find((candidate) => candidate.id === wabaId.value) ?? {
26
+ id: wabaId.value,
27
+ name: '',
28
+ phone_numbers: [],
29
+ }
30
+ )
31
+ })
32
+
33
+ /**
34
+ * The Message Group is on a WABA that is no longer one of its
35
+ * Organization's: it cannot be submitted any more. Never true while the
36
+ * lookup is running or failed.
37
+ */
38
+ const isDetached = computed(
39
+ () =>
40
+ !!wabaId.value &&
41
+ !!wabas.value &&
42
+ !wabas.value.some((candidate) => candidate.id === wabaId.value),
43
+ )
44
+
45
+ /**
46
+ * The WABA to name in a submission. Asks the user only at the first
47
+ * submission of a Message Group whose Organization has several WABAs;
48
+ * otherwise names none and the backend resolves it. A lookup that has not
49
+ * answered yet is waited for; one that failed leaves it to the backend.
50
+ * Returns null when the user gives up.
51
+ */
52
+ async function chooseWaba(): Promise<{ wabaId?: string } | null> {
53
+ if (wabaId.value) return {}
54
+ if (!wabas.value) await refresh()
55
+ const candidates = wabas.value
56
+ if (!candidates || candidates.length < 2) return {}
57
+
58
+ const preselected = candidates.some((candidate) => candidate.id === metaWabaId.value)
59
+ ? metaWabaId.value
60
+ : undefined
61
+ const chosen = await modal.open({ wabas: candidates, preselected }).result
62
+
63
+ return chosen ? { wabaId: chosen } : null
64
+ }
65
+
66
+ /** After a submission went through, with the WABA it named, if any. */
67
+ function markSubmitted(chosen?: string) {
68
+ if (wabaId.value) return
69
+ submittedWabaId.value = chosen ?? (wabas.value?.length === 1 ? wabas.value[0]!.id : undefined)
70
+ }
71
+
72
+ return { waba, isDetached, chooseWaba, markSubmitted }
73
+ }
@@ -17,6 +17,7 @@ import { useTracking } from '~/composables/useTracking'
17
17
  /** Codes a host receives on `error`; the backend ones pass through unchanged. */
18
18
  export type MessageSendFlowErrorCode =
19
19
  | 'NOT_SENDABLE'
20
+ | 'NOT_OFFERED'
20
21
  | 'MISSING_PARAMETERS'
21
22
  | 'TEMPLATE_NOT_SUBMITTED'
22
23
  | 'TEMPLATE_NOT_APPROVED'
@@ -64,11 +65,15 @@ export function useMessageSendFlow(options: {
64
65
  preferredLanguage: MaybeRefOrGetter<string | undefined>
65
66
  prefill: MaybeRefOrGetter<Record<string, string> | undefined>
66
67
  brandId: MaybeRefOrGetter<string | undefined>
68
+ /** The Organization the flow works on; unset, the one the layer resolved. */
69
+ organizationId?: MaybeRefOrGetter<string | undefined>
70
+ /** The WABA the Host sends from: only WhatsApp groups on it are offered (ADR 0015). */
71
+ wabaId?: MaybeRefOrGetter<string | undefined>
67
72
  emit: <K extends keyof MessageSendFlowEmits>(event: K, ...args: MessageSendFlowEmits[K]) => void
68
73
  }) {
69
74
  const { groupId, emit } = options
70
75
  const { t } = useI18n()
71
- const { trackEvent } = useTracking()
76
+ const { trackEvent } = useTracking({ organizationId: options.organizationId })
72
77
 
73
78
  // --- Group -------------------------------------------------------------
74
79
  const { data: group, isLoading: groupLoading } = useMessageGroupQuery(() => groupId.value ?? '', {
@@ -87,7 +92,23 @@ export function useMessageSendFlow(options: {
87
92
  })),
88
93
  )
89
94
 
90
- const sendable = computed(() => languages.value.some((l) => l.approved))
95
+ // A group reaches the flow by id too (the Host's preselection): one of
96
+ // another Organization, or on another WABA, is not the picker's to offer.
97
+ const offered = computed(() => {
98
+ if (!group.value) return true
99
+ const organizationId = toValue(options.organizationId)
100
+ if (
101
+ organizationId &&
102
+ group.value.organization_id?.toLowerCase() !== organizationId.toLowerCase()
103
+ )
104
+ return false
105
+ const wabaId = toValue(options.wabaId)
106
+ return (
107
+ !wabaId || toValue(options.renderType) !== 'whatsapp_v1' || group.value.waba_id === wabaId
108
+ )
109
+ })
110
+
111
+ const sendable = computed(() => offered.value && languages.value.some((l) => l.approved))
91
112
  const notSendable = computed(
92
113
  () => !!groupId.value && !!group.value && !groupLoading.value && !sendable.value,
93
114
  )
@@ -114,9 +135,16 @@ export function useMessageSendFlow(options: {
114
135
 
115
136
  // --- Parameters ----------------------------------------------------------
116
137
  const parametersEnabled = () => !!groupId.value && !!selectedLanguage.value
138
+ const organization = useEffectiveOrganizationStore()
117
139
 
118
140
  const { data: parametersData, isLoading: parametersLoading } = useQuery({
119
- key: () => ['send-flow', 'parameters', groupId.value ?? '', selectedLanguage.value ?? ''],
141
+ key: () => [
142
+ 'send-flow',
143
+ 'parameters',
144
+ groupId.value ?? '',
145
+ selectedLanguage.value ?? '',
146
+ organization.organizationId,
147
+ ],
120
148
  query: () => compilationApi.getParameters(groupId.value ?? '', selectedLanguage.value ?? ''),
121
149
  enabled: parametersEnabled,
122
150
  })
@@ -170,17 +198,26 @@ export function useMessageSendFlow(options: {
170
198
  emit('error', error)
171
199
  }
172
200
 
173
- watch(notSendable, (value) => {
174
- if (value) {
175
- fail('NOT_SENDABLE')
176
- } else if (flowError.value?.code === 'NOT_SENDABLE') {
201
+ // The reason a picked group cannot enter the flow. Watched with the group,
202
+ // so another group, or another reason for the same one, is reported anew.
203
+ const groupError = computed<MessageSendFlowErrorCode | undefined>(() => {
204
+ if (!notSendable.value) return undefined
205
+ return offered.value ? 'NOT_SENDABLE' : 'NOT_OFFERED'
206
+ })
207
+ watch([groupError, () => group.value?.id], ([code]) => {
208
+ if (code) {
209
+ fail(code)
210
+ } else if (
211
+ flowError.value?.code === 'NOT_SENDABLE' ||
212
+ flowError.value?.code === 'NOT_OFFERED'
213
+ ) {
177
214
  flowError.value = undefined
178
215
  }
179
216
  })
180
217
 
181
218
  // --- Compile (once, on Confirm) --------------------------------------------
182
219
  const compiling = ref(false)
183
- const canConfirm = computed(() => !!selectedMessage.value && !compiling.value)
220
+ const canConfirm = computed(() => sendable.value && !!selectedMessage.value && !compiling.value)
184
221
 
185
222
  async function confirm() {
186
223
  flowError.value = undefined
@@ -242,5 +279,6 @@ export function useMessageSendFlow(options: {
242
279
  compiling,
243
280
  canConfirm,
244
281
  confirm,
282
+ trackEvent,
245
283
  }
246
284
  }
@@ -23,6 +23,8 @@ export interface PagedMessageGroupsOptions {
23
23
  resetOn?: WatchSource[]
24
24
  /** When false no request is made (the query stays pending). */
25
25
  enabled?: MaybeRefOrGetter<boolean>
26
+ /** Lists this Organization instead of the resolved one. */
27
+ organizationId?: MaybeRefOrGetter<string | undefined>
26
28
  }
27
29
 
28
30
  /**
@@ -42,12 +44,22 @@ export function usePagedMessageGroups(options: PagedMessageGroupsOptions) {
42
44
  per_page: options.perPage,
43
45
  }))
44
46
 
45
- const query = useMessageGroupListQuery(listOptions, { enabled: options.enabled })
47
+ const query = useMessageGroupListQuery(listOptions, {
48
+ enabled: options.enabled,
49
+ organizationId: options.organizationId,
50
+ })
46
51
 
47
52
  // The reset happens in the same tick as the change (sync flush) so the query
48
53
  // sees one key change, not two, and the user never opens an empty page.
54
+ const organization = useEffectiveOrganizationStore()
49
55
  watch(
50
- [debouncedSearch, () => toValue(options.renderType), ...(options.resetOn ?? [])],
56
+ [
57
+ debouncedSearch,
58
+ () => toValue(options.renderType),
59
+ // Another Organization is another list: its page N may not exist.
60
+ () => toValue(options.organizationId) || organization.resolvedOrganizationId,
61
+ ...(options.resetOn ?? []),
62
+ ],
51
63
  () => {
52
64
  options.page.value = 1
53
65
  },
@@ -29,6 +29,7 @@ export function useTemplateOperations(options: UseTemplateOperationsOptions = {}
29
29
  const saving = ref(false)
30
30
  const saved = ref(false)
31
31
  const appContext = useAppContextStore()
32
+ const organization = useEffectiveOrganizationStore()
32
33
 
33
34
  // Hoist composables to setup scope (they use inject() internally)
34
35
  const { mutateAsync: createMessageGroup } = useCreateMessageGroupMutation()
@@ -51,6 +52,9 @@ export function useTemplateOperations(options: UseTemplateOperationsOptions = {}
51
52
  const createdMessageGroup = await createMessageGroup({
52
53
  ...data.messageGroup,
53
54
  product,
55
+ // The Effective Organization (ADR 0014): the selected one, or the
56
+ // edited group's on "save as new".
57
+ organization_id: organization.requireOrganizationId(),
54
58
  } as PostEntity<MessageGroup>)
55
59
 
56
60
  if (createdMessageGroup?.id) {
@@ -112,7 +116,12 @@ export function useTemplateOperations(options: UseTemplateOperationsOptions = {}
112
116
  try {
113
117
  const result = await duplicateMessageGroup({
114
118
  id: sourceId,
115
- data: newName ? { name: newName } : undefined,
119
+ data: {
120
+ ...(newName && { name: newName }),
121
+ // The Effective Organization (ADR 0014): the selected one, or the
122
+ // edited group's on "save as new".
123
+ organization_id: organization.requireOrganizationId(),
124
+ },
116
125
  })
117
126
 
118
127
  saved.value = true
@@ -1,7 +1,15 @@
1
+ import type { MaybeRefOrGetter } from 'vue'
1
2
  import type { TrackingEventMap } from '~/types/tracking'
2
3
 
3
- export function useTracking() {
4
+ /** `organizationId` names the Organization of the events instead of the Effective one. */
5
+ export function useTracking(
6
+ options: { organizationId?: MaybeRefOrGetter<string | undefined> } = {},
7
+ ) {
4
8
  const { product, mainLanguage, isAdmin } = storeToRefs(useAppContextStore())
9
+ const effectiveOrganization = useEffectiveOrganizationStore()
10
+ const organizationId = computed(
11
+ () => toValue(options.organizationId) || effectiveOrganization.organizationId,
12
+ )
5
13
 
6
14
  function getDefaultProperties(): Record<string, unknown> {
7
15
  return {
@@ -17,12 +25,13 @@ export function useTracking() {
17
25
  properties?: TrackingEventMap[T],
18
26
  ): Promise<void> {
19
27
  if (!useAppConfig().composer?.tracking) return
28
+ const payload = { ...getDefaultProperties(), ...properties }
20
29
  try {
30
+ // The tracking route needs an Organization: an event fired while it is
31
+ // still being resolved (the first page view) waits instead of being lost.
32
+ const organization = await until(organizationId).toBeTruthy()
21
33
  const { trackingApi } = await import('@/api/tracking')
22
- await trackingApi.event(eventName, {
23
- ...getDefaultProperties(),
24
- ...properties,
25
- })
34
+ await trackingApi.event(eventName, organization, payload)
26
35
  } catch {
27
36
  if (import.meta.dev) {
28
37
  console.warn(`[Tracking] Failed to track "${eventName}"`, properties)
@@ -0,0 +1,32 @@
1
+ /**
2
+ * - `unavailable`: the Host's channels do not include WhatsApp
3
+ * - `checking`: the WABA lookup of the Effective Organization is running
4
+ * - `configured`: the Effective Organization has at least one WABA
5
+ * - `not_configured`: the lookup answered, the Organization has no WABA
6
+ * - `failed`: the lookup failed, so whether there is a WABA is not known
7
+ */
8
+ export type WhatsappConfigurationStatus =
9
+ | 'unavailable'
10
+ | 'checking'
11
+ | 'configured'
12
+ | 'not_configured'
13
+ | 'failed'
14
+
15
+ /**
16
+ * Whether WhatsApp is configured: the Host's channels include WhatsApp and
17
+ * the Effective Organization has at least one WABA (ADR 0015). The WABAs
18
+ * follow the Organization, so the answer changes with it.
19
+ * A lookup still running or failed is never read as "no WABA".
20
+ */
21
+ export function useWhatsappConfiguration() {
22
+ const { hasWhatsapp } = useChannels()
23
+ const { data, status: lookupStatus } = useOrganizationWabasQuery()
24
+
25
+ const status = computed<WhatsappConfigurationStatus>(() => {
26
+ if (!hasWhatsapp.value) return 'unavailable'
27
+ if (data.value) return data.value.length > 0 ? 'configured' : 'not_configured'
28
+ return lookupStatus.value === 'error' ? 'failed' : 'checking'
29
+ })
30
+
31
+ return { status }
32
+ }
@@ -1,6 +1,9 @@
1
1
  <template>
2
2
  <SNavigationPage :panel-props="{ ui: { body: 'p-0 sm:p-0 gap-0 sm:gap-0 overflow-hidden' } }">
3
3
  <CommonAppContextErrors />
4
- <slot />
4
+ <CommonWhatsappNotConfigured />
5
+ <CommonOrganizationGate>
6
+ <slot />
7
+ </CommonOrganizationGate>
5
8
  </SNavigationPage>
6
9
  </template>
@@ -10,6 +10,7 @@ const brandListRef = useTemplateRef('brandListRef')
10
10
  <template>
11
11
  <LayoutBasePage :title="t('pages.brands.title')">
12
12
  <template #actions>
13
+ <CommonOrganizationSelect />
13
14
  <UButton
14
15
  icon="ph:plus"
15
16
  :label="t('pages.brands.new_brand')"
@@ -8,6 +8,10 @@ const { t } = useI18n()
8
8
 
9
9
  <template>
10
10
  <LayoutBasePage :title="t('pages.images.title')" :test-id="imagesPageTestIds.navbar">
11
+ <template #actions>
12
+ <CommonOrganizationSelect />
13
+ </template>
14
+
11
15
  <ComposerImageGallery />
12
16
  </LayoutBasePage>
13
17
  </template>
@@ -54,6 +54,7 @@ const tabItems = computed(() => {
54
54
  @tab-change="renderType = $event as RenderType"
55
55
  >
56
56
  <template #actions>
57
+ <CommonOrganizationSelect />
57
58
  <CreateTemplateButton />
58
59
  </template>
59
60
 
@@ -13,6 +13,8 @@ definePageMeta({
13
13
  const route = useRoute()
14
14
  const { locale } = useI18n()
15
15
  const { data: messageGroup } = useMessageGroupQuery(() => route.params.id as string)
16
+ // The preview works on the Organization of the Message Group it shows.
17
+ useResourceOrganization(() => messageGroup.value?.organization_id)
16
18
 
17
19
  const selectedMessage = computed(() => {
18
20
  if (!messageGroup.value?.messages.length) return undefined
@@ -55,6 +55,7 @@ const store = useWhatsappComposerStore()
55
55
  :label="$t('pages.whatsapp.create_message_group.request_approval')"
56
56
  color="primary"
57
57
  :loading="composerRef?.saving || composerRef?.isTranslating"
58
+ :disabled="composerRef?.isWabaDetached"
58
59
  :data-testid="templateWhatsappEditTestIds.requestApprovalButton"
59
60
  />
60
61
  </template>