@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
@@ -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>
@@ -3,20 +3,32 @@ import type {
3
3
  BrandCreateBody,
4
4
  BrandUpdateBody,
5
5
  } from '@dev.smartpricing/message-composer-utils/types'
6
- import { brandsApi, propertiesApi } from '~/api'
6
+ import { brandsApi, propertiesApi, type BrandPreviewRequest } from '~/api'
7
7
 
8
8
  // QUERIES
9
9
  export function useBrandsQuery(options?: {
10
10
  sort_by?: 'created_at' | 'name'
11
11
  direction?: 'asc' | 'desc'
12
12
  }) {
13
+ const organization = useEffectiveOrganizationStore()
14
+
13
15
  return useQuery({
14
- key: () => ['brands', options?.sort_by ?? '', options?.direction ?? ''],
15
- query: () => brandsApi.getAll(options),
16
+ key: () => [
17
+ 'brands',
18
+ options?.sort_by ?? '',
19
+ options?.direction ?? '',
20
+ organization.organizationId,
21
+ ],
22
+ query: () =>
23
+ brandsApi.getAll({ ...options, organization_id: organization.requireOrganizationId() }),
16
24
  staleTime: 2 * 60 * 1000,
17
25
  })
18
26
  }
19
27
 
28
+ /**
29
+ * A Brand by id. No Organization in the key: the request names none, it acts
30
+ * on the Organization of the Brand itself (ADR 0014).
31
+ */
20
32
  export function useBrandQuery(id: MaybeRefOrGetter<string>) {
21
33
  return useQuery({
22
34
  key: () => ['brands', toValue(id)],
@@ -27,19 +39,24 @@ export function useBrandQuery(id: MaybeRefOrGetter<string>) {
27
39
  }
28
40
 
29
41
  export function usePropertiesQuery() {
42
+ const organization = useEffectiveOrganizationStore()
43
+
30
44
  return useQuery({
31
- key: () => ['properties'],
32
- query: () => propertiesApi.getAll(),
45
+ key: () => ['properties', organization.organizationId],
46
+ query: () => propertiesApi.getAll(organization.requireOrganizationId()),
33
47
  staleTime: 10 * 60 * 1000,
34
48
  })
35
49
  }
36
50
 
37
51
  // MUTATIONS
52
+ /** A new Brand names its Organization (ADR 0014): the Effective one. */
38
53
  export function useCreateBrandMutation() {
39
54
  const queryCache = useQueryCache()
55
+ const organization = useEffectiveOrganizationStore()
40
56
 
41
57
  return useMutation({
42
- mutation: (data: BrandCreateBody) => brandsApi.create(data),
58
+ mutation: (data: BrandCreateBody) =>
59
+ brandsApi.create({ ...data, organization_id: organization.requireOrganizationId() }),
43
60
  onSuccess: () => {
44
61
  queryCache.invalidateQueries({ key: ['brands'] })
45
62
  },
@@ -70,7 +87,10 @@ export function useDeleteBrandMutation() {
70
87
  }
71
88
 
72
89
  export function useBrandPreviewMutation() {
90
+ const organization = useEffectiveOrganizationStore()
91
+
73
92
  return useMutation({
74
- mutation: brandsApi.preview,
93
+ mutation: (data: BrandPreviewRequest) =>
94
+ brandsApi.preview({ ...data, organization_id: organization.requireOrganizationId() }),
75
95
  })
76
96
  }
@@ -5,11 +5,18 @@ import { compilationApi } from '~/api'
5
5
  import type {
6
6
  CompileMessageRequest,
7
7
  PreviewMessageRequest,
8
+ TestEmailRequest,
8
9
  } from '@dev.smartpricing/message-composer-utils/types'
9
10
 
10
11
  export function useSendTestEmailMutation() {
12
+ const organization = useEffectiveOrganizationStore()
13
+
11
14
  return useMutation({
12
- mutation: compilationApi.sendTestEmail,
15
+ mutation: (data: TestEmailRequest) =>
16
+ compilationApi.sendTestEmail({
17
+ ...data,
18
+ organization_id: organization.requireOrganizationId(),
19
+ }),
13
20
  })
14
21
  }
15
22
 
@@ -42,7 +49,13 @@ export function useCompileEmailMutation() {
42
49
  }
43
50
 
44
51
  export function usePreviewEmailMutation() {
52
+ const organization = useEffectiveOrganizationStore()
53
+
45
54
  return useMutation({
46
- mutation: (data: PreviewMessageRequest) => compilationApi.previewEmail(data),
55
+ mutation: (data: PreviewMessageRequest) =>
56
+ compilationApi.previewEmail({
57
+ ...data,
58
+ organization_id: organization.requireOrganizationId(),
59
+ }),
47
60
  })
48
61
  }
@@ -6,13 +6,24 @@ import { useAppContextStore } from '~/stores/appContext'
6
6
  export function useDynamicValuesQuery() {
7
7
  const { product, mainLanguage } = storeToRefs(useAppContextStore())
8
8
 
9
+ const organization = useEffectiveOrganizationStore()
10
+
9
11
  return useQuery({
10
- key: () => ['dynamic-values', product.value ?? '', mainLanguage.value],
12
+ key: () => [
13
+ 'dynamic-values',
14
+ product.value ?? '',
15
+ mainLanguage.value,
16
+ organization.organizationId,
17
+ ],
11
18
  // No Product, no request: the picker stays empty and the store lists the
12
19
  // missing Product in `validationErrors`.
13
20
  query: () => {
14
21
  if (!product.value) return Promise.resolve<DynamicValuesResponse>({})
15
- return dynamicValuesApi.get(product.value, mainLanguage.value || 'en')
22
+ return dynamicValuesApi.get(
23
+ product.value,
24
+ mainLanguage.value || 'en',
25
+ organization.requireOrganizationId(),
26
+ )
16
27
  },
17
28
  staleTime: 30 * 60 * 1000,
18
29
  })
@@ -2,15 +2,20 @@ import { useMutation, useQuery } from '@pinia/colada'
2
2
  import { mediaApi } from '~/api'
3
3
 
4
4
  export function useMediaQuery() {
5
+ const organization = useEffectiveOrganizationStore()
6
+
5
7
  return useQuery({
6
- key: ['media'],
7
- query: mediaApi.list,
8
+ key: () => ['media', organization.organizationId],
9
+ query: () => mediaApi.list(organization.requireOrganizationId()),
8
10
  })
9
11
  }
10
12
 
13
+ /** An uploaded image names its Organization (ADR 0014): the Effective one. */
11
14
  export function useUploadMediaMutation() {
15
+ const organization = useEffectiveOrganizationStore()
16
+
12
17
  return useMutation({
13
- mutation: mediaApi.upload,
18
+ mutation: (file: File) => mediaApi.upload(file, organization.requireOrganizationId()),
14
19
  })
15
20
  }
16
21
 
@@ -23,21 +23,47 @@ function enabledOption(enabled?: MaybeRefOrGetter<boolean>) {
23
23
  /**
24
24
  * One page of the server-side filtered list, in the Laravel envelope (ADR 0012).
25
25
  * The previous page stays as placeholder data while the next one loads, so
26
- * paging and filtering never flash an empty table.
26
+ * paging and filtering never flash an empty table. The Organization closes
27
+ * every key, so each Organization has its own cache (ADR 0014). A list is
28
+ * filtered by the resolved Organization, never by the one an editor page
29
+ * claimed for its resource, which may still be held while the list mounts.
30
+ * `organizationId` lists another Organization instead (the Message Send Flow's).
27
31
  */
28
32
  export function useMessageGroupListQuery(
29
33
  options: MaybeRefOrGetter<ListMessageGroupsOptions>,
30
- queryOptions: { enabled?: MaybeRefOrGetter<boolean> } = {},
34
+ queryOptions: {
35
+ enabled?: MaybeRefOrGetter<boolean>
36
+ organizationId?: MaybeRefOrGetter<string | undefined>
37
+ } = {},
31
38
  ) {
39
+ const organization = useEffectiveOrganizationStore()
40
+ const listOrganizationId = () =>
41
+ toValue(queryOptions.organizationId) || organization.resolvedOrganizationId
42
+
32
43
  return useQuery({
33
- key: () => ['message-groups', JSON.stringify(toValue(options))],
34
- query: () => messageGroupsApi.list(toValue(options)),
44
+ key: () => ['message-groups', JSON.stringify(toValue(options)), listOrganizationId()],
45
+ query: () => {
46
+ // No "all Organizations" list in the layer: never ask without the filter.
47
+ const organization_id = listOrganizationId()
48
+ if (!organization_id) {
49
+ throw new Error(
50
+ '[message-composer] No Organization resolved yet: the list was not requested',
51
+ )
52
+ }
53
+ return messageGroupsApi.list({ ...toValue(options), organization_id })
54
+ },
35
55
  staleTime: 0,
36
- placeholderData: (previousData) => previousData,
56
+ // Never across Organizations: a switch must not show the previous one's rows.
57
+ placeholderData: (previousData, previousEntry) =>
58
+ previousEntry?.key.at(-1) === listOrganizationId() ? previousData : undefined,
37
59
  ...enabledOption(queryOptions.enabled),
38
60
  })
39
61
  }
40
62
 
63
+ /**
64
+ * A Message Group by id. No Organization in the key: the request names none,
65
+ * it acts on the Organization of the Message Group itself (ADR 0014).
66
+ */
41
67
  export function useMessageGroupQuery(
42
68
  id: MaybeRefOrGetter<string>,
43
69
  queryOptions: { enabled?: MaybeRefOrGetter<boolean> } = {},
@@ -1,13 +1,18 @@
1
- import { useMutation, useQueryCache } from '@pinia/colada'
1
+ import { useQuery } from '@pinia/colada'
2
2
  import { metaApi } from '~/api'
3
3
 
4
- export function useSubmitToMetaMutation() {
5
- const queryCache = useQueryCache()
4
+ /**
5
+ * The WABAs of the Effective Organization (ADR 0015). Asked only when the
6
+ * Host's channels include WhatsApp and an Organization is resolved.
7
+ */
8
+ export function useOrganizationWabasQuery() {
9
+ const organization = useEffectiveOrganizationStore()
10
+ const { hasWhatsapp } = useChannels()
6
11
 
7
- return useMutation({
8
- mutation: metaApi.submitGroup,
9
- onSuccess: (_, messageGroupId) => {
10
- queryCache.invalidateQueries({ key: ['message-groups', messageGroupId] })
11
- },
12
+ return useQuery({
13
+ key: () => ['organization-wabas', organization.organizationId],
14
+ query: () => metaApi.getWabas(organization.requireOrganizationId()),
15
+ enabled: () => hasWhatsapp.value && !!organization.organizationId,
16
+ staleTime: 5 * 60 * 1000,
12
17
  })
13
18
  }
@@ -1,36 +1,30 @@
1
- import { ref, computed, watch } from 'vue'
1
+ import { computed, watch } from 'vue'
2
2
  import {
3
3
  MESSAGE_GROUP_PRODUCTS,
4
4
  type AppContextConfig,
5
- type AppContextState,
6
5
  } from '@dev.smartpricing/message-composer-utils/types'
7
6
 
8
7
  export const useAppContextStore = defineStore('appContext', () => {
9
8
  const ctx = useComposerContext()
10
9
 
11
- const isInitialized = ref<boolean>(false)
12
-
13
- // Proxy from injection with defaults (same interface as before)
10
+ // Proxy from injection with defaults
14
11
  const organization_id = computed(() => ctx.organization_id?.value ?? '')
12
+ // A Host that passes the key owns the choice of Organization, even while
13
+ // the value is still empty (ADR 0014). Fixed for the session, like the key.
14
+ const hostOwnsOrganization = ctx.organization_id !== undefined
15
15
  // The Product is required by the context type (ADR 0011), but a host can
16
16
  // still omit it at runtime: read defensively and list it in
17
17
  // `validationErrors` rather than crashing or falling back to a fake Product.
18
18
  const product = computed<AppContextConfig['product'] | undefined>(() => ctx.product?.value)
19
- const strategy = computed<NonNullable<AppContextConfig['strategy']>>(
20
- () => ctx.strategy?.value ?? 'native',
21
- )
22
19
  const channels = computed<NonNullable<AppContextConfig['channels']>>(
23
20
  () => ctx.channels?.value ?? ['email', 'whatsapp'],
24
21
  )
25
22
  const languages = computed<string[]>(() => ctx.languages?.value ?? ['en', 'de', 'fr', 'it', 'es'])
26
23
  const mainLanguage = computed(() => ctx.mainLanguage?.value ?? 'en')
27
- const dinamicValues = computed<Array<{ value: string; label: string }>>(
28
- () => ctx.dinamicValues?.value ?? [],
29
- )
30
24
  const default_tags = computed<string[]>(() => ctx.default_tags?.value ?? [])
31
25
  const isAdmin = computed(() => ctx.isAdmin?.value ?? false)
32
- const metaWabaId = computed(() => ctx.metaWabaId?.value ?? '')
33
- const metaAccessToken = computed(() => ctx.metaAccessToken?.value ?? '')
26
+ // The Host's preselection among the Organization's WABAs (ADR 0015).
27
+ const metaWabaId = computed(() => ctx.metaWabaId?.value || undefined)
34
28
 
35
29
  // Sync i18n locale when mainLanguage changes
36
30
  watch(
@@ -43,20 +37,6 @@ export const useAppContextStore = defineStore('appContext', () => {
43
37
  { immediate: true },
44
38
  )
45
39
 
46
- // Computed context object for convenience
47
- const context = computed<AppContextState>(() => ({
48
- product: product.value,
49
- strategy: strategy.value,
50
- channels: channels.value,
51
- languages: languages.value,
52
- mainLanguage: mainLanguage.value,
53
- metaWabaId: metaWabaId.value,
54
- metaAccessToken: metaAccessToken.value,
55
- organization_id: organization_id.value,
56
- default_tags: default_tags.value,
57
- initializationSource: 'injection',
58
- }))
59
-
60
40
  // Validation
61
41
  const validationErrors = computed<Record<string, string>>(() => {
62
42
  const errors: Record<string, string> = {}
@@ -65,36 +45,20 @@ export const useAppContextStore = defineStore('appContext', () => {
65
45
  errors.product = `Product is required: pass \`product\` (${MESSAGE_GROUP_PRODUCTS.join(' | ')}) in the composer context`
66
46
  }
67
47
 
68
- if (channels.value?.includes('whatsapp') && !metaWabaId.value) {
69
- errors.metaConfig = 'Meta WABA ID is required for WhatsApp'
70
- }
71
-
72
48
  return errors
73
49
  })
74
50
 
75
- // Initialize — parent is responsible for providing valid context
76
- async function initialize() {
77
- isInitialized.value = true
78
- }
79
-
80
51
  return {
81
52
  product,
82
- strategy,
83
53
  channels,
84
54
  languages,
85
55
  mainLanguage,
86
- dinamicValues,
87
- metaWabaId,
88
- metaAccessToken,
89
56
  organization_id,
57
+ hostOwnsOrganization,
90
58
  default_tags,
91
59
  isAdmin,
60
+ metaWabaId,
92
61
 
93
- isInitialized: computed(() => isInitialized.value),
94
-
95
- context,
96
62
  validationErrors,
97
-
98
- initialize,
99
63
  }
100
64
  })
@@ -0,0 +1,128 @@
1
+ import type { ReachableOrganization } from '@dev.smartpricing/message-composer-utils/types'
2
+
3
+ /** Local storage key of the Organization last picked in the selector. */
4
+ const REMEMBERED_ORGANIZATION_KEY = 'message-composer:organization'
5
+
6
+ /**
7
+ * - `resolving`: the Reachable Organizations lookup is running, or the Host
8
+ * that owns the choice has not supplied its Organization yet
9
+ * - `resolved`: there is an Organization to work on
10
+ * - `failed`: the lookup failed, `retry()` runs it again
11
+ * - `none`: the user reaches no Organization
12
+ */
13
+ export type OrganizationResolutionStatus = 'resolving' | 'resolved' | 'failed' | 'none'
14
+
15
+ /**
16
+ * The Effective Organization (ADR 0014): the Organization of the resource
17
+ * being viewed or edited while a page claims one, otherwise the resolved one.
18
+ * A Host that passes `organization_id` owns the choice: the resolved one is
19
+ * its value, and nothing is resolved until that value arrives. Otherwise it
20
+ * is the user's only Reachable Organization; else the remembered selection if
21
+ * still reachable; else the first Reachable Organization.
22
+ */
23
+ export const useEffectiveOrganizationStore = defineStore('effectiveOrganization', () => {
24
+ const appContext = useAppContextStore()
25
+ const { organization_id: hostOrganizationId } = storeToRefs(appContext)
26
+ const { hostOwnsOrganization } = appContext
27
+ const remembered = useLocalStorage(REMEMBERED_ORGANIZATION_KEY, '')
28
+
29
+ /** Undefined until the lookup has answered. */
30
+ const reachable = shallowRef<ReachableOrganization[]>()
31
+ const lookupState = ref<'idle' | 'loading' | 'failed'>('idle')
32
+
33
+ async function lookUp() {
34
+ if (lookupState.value === 'loading') return
35
+ lookupState.value = 'loading'
36
+ try {
37
+ // Imported on demand: the API client reads the runtime config when its
38
+ // module loads, and this store is reachable from plugins that load
39
+ // before the Nuxt app exists (same reason as in `useTracking`).
40
+ const { organizationApi } = await import('~/api/organization')
41
+ reachable.value = await organizationApi.getReachable()
42
+ lookupState.value = 'idle'
43
+ } catch {
44
+ lookupState.value = 'failed'
45
+ }
46
+ }
47
+
48
+ // A Host that supplies the Organization needs no lookup, however late its
49
+ // value arrives: falling back to a Reachable Organization meanwhile would
50
+ // send requests to one the Host did not mean (PAY-2882). Plain state rather
51
+ // than a query: the store outlives whichever component first asks for it.
52
+ if (!hostOwnsOrganization) void lookUp()
53
+
54
+ const reachableOrganizations = computed(() => reachable.value ?? [])
55
+
56
+ const resolvedOrganizationId = computed<string | null>(() => {
57
+ if (hostOwnsOrganization) return hostOrganizationId.value || null
58
+
59
+ const organizations = reachableOrganizations.value
60
+ // Reading `remembered` here is what validates it on load: a selection that
61
+ // is no longer reachable is ignored.
62
+ const selected =
63
+ organizations.length > 1 ? organizations.find((o) => o.id === remembered.value) : undefined
64
+ return (selected ?? organizations[0])?.id ?? null
65
+ })
66
+
67
+ /** The Organization of the resource on screen, and the component that claimed it. */
68
+ const resourceClaim = shallowRef<{ claimant: symbol; organizationId: string }>()
69
+
70
+ const organizationId = computed<string | null>(
71
+ () => resourceClaim.value?.organizationId ?? resolvedOrganizationId.value,
72
+ )
73
+
74
+ /**
75
+ * The Effective Organization for a request that has to name it (ADR 0014).
76
+ * Throws while none is resolved, so a resource request never leaves without
77
+ * its Organization (PAY-2882): callers wait for it to be resolved.
78
+ */
79
+ function requireOrganizationId(): string {
80
+ if (!organizationId.value) {
81
+ throw new Error('[message-composer] No Organization resolved yet: the request was not sent')
82
+ }
83
+ return organizationId.value
84
+ }
85
+
86
+ /** See `useResourceOrganization`: pages do not call this directly. */
87
+ function claimResource(claimant: symbol, id: string | null | undefined) {
88
+ if (id) resourceClaim.value = { claimant, organizationId: id }
89
+ else releaseResource(claimant)
90
+ }
91
+
92
+ // Only the claimant releases: a page that mounts before the previous one is
93
+ // gone must not lose its claim to the other's cleanup.
94
+ function releaseResource(claimant: symbol) {
95
+ if (resourceClaim.value?.claimant === claimant) resourceClaim.value = undefined
96
+ }
97
+
98
+ const status = computed<OrganizationResolutionStatus>(() => {
99
+ if (organizationId.value) return 'resolved'
100
+ if (lookupState.value === 'failed') return 'failed'
101
+ if (lookupState.value === 'idle' && reachable.value) return 'none'
102
+ return 'resolving'
103
+ })
104
+
105
+ /** The selector is offered only when the choice is the user's to make. */
106
+ const isSelectable = computed(
107
+ () => !hostOwnsOrganization && reachableOrganizations.value.length > 1,
108
+ )
109
+
110
+ function select(id: string) {
111
+ if (reachableOrganizations.value.some((o) => o.id === id)) {
112
+ remembered.value = id
113
+ }
114
+ }
115
+
116
+ return {
117
+ organizationId,
118
+ requireOrganizationId,
119
+ resolvedOrganizationId,
120
+ reachableOrganizations,
121
+ status,
122
+ isSelectable,
123
+ select,
124
+ retry: lookUp,
125
+ claimResource,
126
+ releaseResource,
127
+ }
128
+ })
@@ -199,7 +199,11 @@ export const useEmailEditorStore = defineStore('emailEditor', () => {
199
199
  sort_by?: 'created_at' | 'name'
200
200
  direction?: 'asc' | 'desc'
201
201
  }) {
202
- brands.value = await brandsApi.getAll(options)
202
+ // The Brands of the Effective Organization: the edited Message Group's.
203
+ brands.value = await brandsApi.getAll({
204
+ ...options,
205
+ organization_id: useEffectiveOrganizationStore().requireOrganizationId(),
206
+ })
203
207
  return brands.value
204
208
  }
205
209