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

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 (52) 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/Editor/RichTextEditor.vue +14 -2
  19. package/app/components/MessageSendFlow/EmailPreview.vue +3 -0
  20. package/app/components/MessageSendFlow/MessageSendFlow.vue +15 -2
  21. package/app/components/Whatsapp/Preview.vue +5 -12
  22. package/app/components/Whatsapp/Translator/Header.vue +4 -19
  23. package/app/composables/composerContext.ts +5 -5
  24. package/app/composables/useEffectiveOrganization.ts +34 -0
  25. package/app/composables/useMessageGroupWaba.ts +73 -0
  26. package/app/composables/useMessageSendFlow.ts +46 -8
  27. package/app/composables/usePagedMessageGroups.ts +14 -2
  28. package/app/composables/useTemplateOperations.ts +10 -1
  29. package/app/composables/useTracking.ts +14 -5
  30. package/app/composables/useWhatsappConfiguration.ts +32 -0
  31. package/app/layouts/default.vue +4 -1
  32. package/app/pages/brands.vue +1 -0
  33. package/app/pages/images.vue +4 -0
  34. package/app/pages/index.vue +1 -0
  35. package/app/pages/preview/[id].vue +2 -0
  36. package/app/pages/template/whatsapp/[id].vue +1 -0
  37. package/app/queries/brands.ts +27 -7
  38. package/app/queries/compilation.ts +15 -2
  39. package/app/queries/dynamicValues.ts +13 -2
  40. package/app/queries/media.ts +8 -3
  41. package/app/queries/messageGroups.ts +31 -5
  42. package/app/queries/meta.ts +13 -8
  43. package/app/stores/appContext.ts +9 -45
  44. package/app/stores/effectiveOrganization.ts +128 -0
  45. package/app/stores/emailEditor.ts +5 -1
  46. package/app/stores/metaSubmission.ts +7 -17
  47. package/i18n/locales/de.ts +30 -0
  48. package/i18n/locales/en.ts +29 -0
  49. package/i18n/locales/es.ts +29 -0
  50. package/i18n/locales/fr.ts +30 -0
  51. package/i18n/locales/it.ts +29 -0
  52. 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"
@@ -13,6 +13,7 @@ import {
13
13
  convertToWhatsappHeaderText,
14
14
  convertToWhatsappMarkdown,
15
15
  getTipTapExtensions,
16
+ isWhatsappHeaderMultiline,
16
17
  sanitizeContent,
17
18
  } from '@dev.smartpricing/message-composer-utils/utils'
18
19
  import EmojiPickerButton from '../Common/EmojiPickerButton.vue'
@@ -278,14 +279,21 @@ const toolbarItems = computed(() => {
278
279
  // WhatsApp markdown-aware character count
279
280
  // Meta counts the final markdown output (with *bold*, _italic_ marks),
280
281
  // so we must display that count instead of TipTap's document-model count.
281
- const isWhatsapp = computed(() => props.preset === 'whatsapp' || props.preset === 'whatsapp-header')
282
+ const isWhatsappHeader = computed(() => props.preset === 'whatsapp-header')
283
+ const isWhatsapp = computed(() => props.preset === 'whatsapp' || isWhatsappHeader.value)
282
284
  const whatsappCharCount = computed(() => {
283
285
  if (!isWhatsapp.value || !props.characterLimit || !text.value) return 0
284
- return props.preset === 'whatsapp-header'
286
+ return isWhatsappHeader.value
285
287
  ? convertToWhatsappHeaderText(text.value).length
286
288
  : convertToWhatsappMarkdown(text.value).length
287
289
  })
288
290
 
291
+ // A multi-line header is still saved (its lines are joined into one when
292
+ // sent to Meta), so the editor warns instead of raising a validation error.
293
+ const hasHeaderLineBreaks = computed(
294
+ () => isWhatsappHeader.value && isWhatsappHeaderMultiline(text.value),
295
+ )
296
+
289
297
  // Handlers
290
298
  function onSelectEmoji(emoji: EmojiExt) {
291
299
  editor.value?.commands.insertContent(emoji.i)
@@ -491,6 +499,10 @@ defineExpose({
491
499
  </span>
492
500
  </p>
493
501
 
502
+ <p v-if="hasHeaderLineBreaks" class="paragraph-xs text-warning-700 pt-1.5 px-1">
503
+ {{ $t('editor.validation.header_line_breaks') }}
504
+ </p>
505
+
494
506
  <!-- AI Generate slideover -->
495
507
  </div>
496
508
  </template>
@@ -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
  />
@@ -4,12 +4,10 @@ import type {
4
4
  WaButtonTypeV1,
5
5
  WhatsappMessage,
6
6
  } from '@dev.smartpricing/message-composer-utils/types'
7
- import { generateHTML } from '@tiptap/vue-3'
8
7
  import { computed } from 'vue'
9
8
  import {
9
+ convertToWhatsappHeaderText,
10
10
  generateWhatsappHtml,
11
- getTipTapExtensions,
12
- sanitizeContent,
13
11
  } from '@dev.smartpricing/message-composer-utils/utils'
14
12
  import { useResolvedParametersQuery } from '~/queries/compilation'
15
13
 
@@ -57,15 +55,10 @@ const bodyHtml = computed(() => {
57
55
  return replaceMentionsWithValues(html)
58
56
  })
59
57
 
60
- const headerHtml = computed(() => {
58
+ // Plain text, exactly as Meta receives it
59
+ const headerText = computed(() => {
61
60
  if (props.message.body.header.type !== 'text') return ''
62
- if (!props.message.body.header.content) return ''
63
- const extensions = getTipTapExtensions({ preset: 'whatsapp-header' })
64
- const html = generateHTML(
65
- sanitizeContent(props.message.body.header.content, extensions),
66
- extensions,
67
- ).replaceAll('<p></p>', '<p>&nbsp;</p>')
68
- return replaceMentionsWithValues(html)
61
+ return replaceMentionsWithValues(convertToWhatsappHeaderText(props.message.body.header.content))
69
62
  })
70
63
 
71
64
  const currentTime = computed(() => {
@@ -131,7 +124,7 @@ const getIcon = (type: WaButtonTypeV1) => {
131
124
  v-if="message.body.header.type === 'text'"
132
125
  class="text-sm text-primary-950 break-all"
133
126
  >
134
- <p v-html="headerHtml" />
127
+ <p>{{ headerText }}</p>
135
128
  </div>
136
129
  <div v-else-if="message.body.header.type === 'image'" class="mb-2">
137
130
  <img
@@ -1,10 +1,6 @@
1
1
  <script setup lang="ts">
2
2
  import type { JSONContent } from '@tiptap/vue-3'
3
- import { generateHTML } from '@tiptap/vue-3'
4
- import {
5
- getTipTapExtensions,
6
- sanitizeContent,
7
- } from '@dev.smartpricing/message-composer-utils/utils'
3
+ import { convertToWhatsappHeaderText } from '@dev.smartpricing/message-composer-utils/utils'
8
4
  import RichTextEditor from '@/components/Editor/RichTextEditor.vue'
9
5
  import { useAppContextStore } from '@/stores/appContext'
10
6
  import { WA_HEADER_TEXT_MAX_LENGTH } from '@dev.smartpricing/message-composer-utils/types'
@@ -22,26 +18,15 @@ const targetContent = defineModel<JSONContent | undefined>({
22
18
  })
23
19
 
24
20
  const appContext = useAppContextStore()
25
-
26
- // Generate HTML for preview of base language with rich text support
27
- function generatePreviewHtml(content: JSONContent | undefined): string {
28
- if (!content) return ''
29
- const extensions = getTipTapExtensions({ preset: 'whatsapp-header' })
30
- return generateHTML(sanitizeContent(content, extensions), extensions).replaceAll(
31
- '<p></p>',
32
- '<p>&nbsp;</p>',
33
- )
34
- }
35
21
  </script>
36
22
 
37
23
  <template>
38
24
  <div class="flex gap-8 items-start" :data-testid="whatsappTranslatorHeaderTestIds.container">
39
25
  <!-- Base language preview (read-only) -->
40
26
  <div class="flex-1 min-w-0" :data-testid="whatsappTranslatorHeaderTestIds.basePreview">
41
- <div
42
- class="paragraph-sm text-primary-700 break-words"
43
- v-html="generatePreviewHtml(baseContent)"
44
- />
27
+ <div class="paragraph-sm text-primary-700 break-words">
28
+ {{ convertToWhatsappHeaderText(baseContent) }}
29
+ </div>
45
30
  </div>
46
31
 
47
32
  <!-- Translation editor (editable) -->
@@ -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