@dev.smartpricing/message-composer-layer 4.5.1-next.3 → 4.5.1-next.4

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 (73) hide show
  1. package/README.md +133 -4
  2. package/app/api/automations.ts +74 -0
  3. package/app/api/index.ts +1 -0
  4. package/app/api/messageGroups.ts +5 -1
  5. package/app/app.config.ts +10 -0
  6. package/app/components/Automations/ActionCard.vue +174 -0
  7. package/app/components/Automations/ActionEditor.vue +398 -0
  8. package/app/components/Automations/Actions.vue +148 -0
  9. package/app/components/Automations/CloneModal.vue +78 -0
  10. package/app/components/Automations/Conditions.vue +155 -0
  11. package/app/components/Automations/Create.vue +114 -0
  12. package/app/components/Automations/Data.vue +46 -0
  13. package/app/components/Automations/DeleteModal.vue +80 -0
  14. package/app/components/Automations/Edit.vue +145 -0
  15. package/app/components/Automations/Form.vue +282 -0
  16. package/app/components/Automations/List.vue +370 -0
  17. package/app/components/Automations/PageGate.vue +50 -0
  18. package/app/components/Automations/SaveProblems.vue +54 -0
  19. package/app/components/Automations/Unavailable.vue +71 -0
  20. package/app/components/Automations/UnavailableBadge.vue +35 -0
  21. package/app/components/Common/MessageGroupSelect.vue +30 -1
  22. package/app/components/Composer/EmailComposer.vue +5 -1
  23. package/app/components/Composer/WabaSelectModal.vue +3 -1
  24. package/app/components/Composer/WhatsappComposer.vue +5 -1
  25. package/app/components/Filters/DateInput.vue +57 -0
  26. package/app/components/Filters/Editor.vue +163 -0
  27. package/app/components/Filters/Row.vue +119 -0
  28. package/app/components/Filters/Summary.vue +215 -0
  29. package/app/components/Filters/UnavailableRow.vue +74 -0
  30. package/app/components/Filters/ValueInput.vue +215 -0
  31. package/app/components/Layout/BasePage.vue +2 -0
  32. package/app/components/TemplateList/BaseTable.vue +1 -22
  33. package/app/composables/apiErrorMessages.ts +1 -0
  34. package/app/composables/automationsContext.ts +42 -0
  35. package/app/composables/composerContext.ts +3 -3
  36. package/app/composables/useAutomationClone.ts +33 -0
  37. package/app/composables/useAutomationEditorTrip.ts +77 -0
  38. package/app/composables/useAutomationSaveProblems.ts +82 -0
  39. package/app/composables/useAutomationUnavailable.ts +98 -0
  40. package/app/composables/useAutomationsGate.ts +68 -0
  41. package/app/composables/useBuiltInOptionLists.ts +44 -0
  42. package/app/composables/useEditorTripBack.ts +15 -0
  43. package/app/composables/useMessageGroupWaba.ts +7 -10
  44. package/app/composables/useTemplateOperations.ts +8 -3
  45. package/app/middleware/automations-enabled.ts +12 -0
  46. package/app/pages/automations/[id].vue +87 -0
  47. package/app/pages/automations/index.vue +28 -0
  48. package/app/pages/automations/new.vue +53 -0
  49. package/app/pages/template/email/[id].vue +5 -0
  50. package/app/pages/template/email/create.vue +5 -0
  51. package/app/pages/template/whatsapp/[id].vue +5 -0
  52. package/app/pages/template/whatsapp/create.vue +5 -0
  53. package/app/queries/automations.ts +206 -0
  54. package/app/queries/index.ts +1 -0
  55. package/app/queries/messageGroups.ts +1 -3
  56. package/app/stores/appContext.ts +1 -1
  57. package/app/stores/editorTrip.ts +67 -0
  58. package/app/types/app-config.d.ts +3 -0
  59. package/app/types/tracking.ts +33 -0
  60. package/app/utils/automations/form.ts +389 -0
  61. package/app/utils/automations/metadata.ts +71 -0
  62. package/app/utils/automations/unavailable.ts +90 -0
  63. package/app/utils/filters/data/countries.json +998 -0
  64. package/app/utils/filters/data/languages.json +754 -0
  65. package/app/utils/filters/rows.ts +367 -0
  66. package/app/utils/testIds.const.ts +107 -1
  67. package/i18n/locales/de.ts +269 -1
  68. package/i18n/locales/en.ts +255 -1
  69. package/i18n/locales/es.ts +258 -1
  70. package/i18n/locales/fr.ts +264 -1
  71. package/i18n/locales/it.ts +260 -1
  72. package/package.json +4 -4
  73. package/app.config.ts +0 -5
@@ -0,0 +1,389 @@
1
+ import type {
2
+ Automation,
3
+ AutomationAction,
4
+ AutomationActionConfig,
5
+ AutomationActionInput,
6
+ AutomationActionStatus,
7
+ AutomationChannel,
8
+ AutomationEngineRejectedDetails,
9
+ AutomationPartlySavedDetails,
10
+ AutomationSaveProblem,
11
+ AutomationTrigger,
12
+ Condition,
13
+ CreateAutomationBody,
14
+ DateReferenceDirection,
15
+ UpdateAutomationBody,
16
+ } from '@dev.smartpricing/message-composer-utils/types'
17
+ import { automationMetadataForm, metadataFormValue } from './metadata'
18
+
19
+ /**
20
+ * The Automation form's own model and what it turns into on a save. The
21
+ * engine's shapes (a delay in signed seconds, `template` for a Message Group)
22
+ * stay in here.
23
+ */
24
+
25
+ export const SECONDS_PER_DAY = 24 * 60 * 60
26
+
27
+ export const DEFAULT_SENDING_TIME = '09:00:00Z'
28
+
29
+ export const AUTOMATION_RECURRENCES = ['daily', 'monthly', 'yearly'] as const
30
+
31
+ export type AutomationRecurrence = (typeof AUTOMATION_RECURRENCES)[number]
32
+
33
+ export interface AutomationFormAction {
34
+ /**
35
+ * What the form tells its Actions apart by. It is the layer's alone: an
36
+ * Action that is not saved yet has no `id`, and this is never sent as one.
37
+ */
38
+ key: string
39
+ /** The id of the stored Action; none while the Action is not saved yet. */
40
+ id?: string
41
+ /**
42
+ * The `config` of the stored Action. The form shows only part of it: the
43
+ * rest goes back as it is on a save.
44
+ */
45
+ storedConfig?: AutomationActionConfig
46
+ status: AutomationActionStatus
47
+ channel: AutomationChannel
48
+ messageGroupId: string
49
+ messageGroupName: string
50
+ /** The key of the Date Reference the delay counts from. */
51
+ dateReference: string
52
+ direction: DateReferenceDirection
53
+ /** The delay, in whole days before or after the Date Reference. */
54
+ days: number
55
+ sendingTimeUTC: string
56
+ recurrence?: AutomationRecurrence
57
+ strictDelay: boolean
58
+ preventDuplicates: boolean
59
+ }
60
+
61
+ export interface AutomationFormState {
62
+ name: string
63
+ triggerType: string
64
+ conditions: Condition[]
65
+ actions: AutomationFormAction[]
66
+ /** The values the Automation asks for (`metadata.form`), by the key of their field. */
67
+ data: Record<string, string>
68
+ }
69
+
70
+ /**
71
+ * A copy the form can change freely. The form's state is plain data, and it
72
+ * can hold Vue proxies, which `structuredClone` refuses.
73
+ */
74
+ export function cloneFormData<T>(value: T): T {
75
+ return JSON.parse(JSON.stringify(value)) as T
76
+ }
77
+
78
+ /** A new Automation starts from its Trigger's default Conditions (ADR 0017). */
79
+ export function newAutomationForm(trigger: AutomationTrigger): AutomationFormState {
80
+ return {
81
+ name: '',
82
+ triggerType: trigger.type,
83
+ conditions: cloneFormData(trigger.default_conditions),
84
+ actions: [],
85
+ data: {},
86
+ }
87
+ }
88
+
89
+ export function newAutomationFormAction(
90
+ trigger: AutomationTrigger,
91
+ channel: AutomationChannel,
92
+ ): AutomationFormAction {
93
+ const dateReference = trigger.date_references[0]
94
+
95
+ return {
96
+ key: crypto.randomUUID(),
97
+ status: 'active',
98
+ channel,
99
+ messageGroupId: '',
100
+ messageGroupName: '',
101
+ dateReference: dateReference?.key ?? '',
102
+ direction: dateReference?.directions[0]?.direction ?? 'after',
103
+ days: 0,
104
+ sendingTimeUTC: DEFAULT_SENDING_TIME,
105
+ strictDelay: false,
106
+ preventDuplicates: true,
107
+ }
108
+ }
109
+
110
+ /** The one Trigger the form edits: the Automation Engine can hold several, the pages write one. */
111
+ function triggerTypeOf(automation: Automation): string {
112
+ return [automation.settings.trigger.type].flat()[0] ?? ''
113
+ }
114
+
115
+ /** What the form shows of an Action's `config`. */
116
+ type AutomationFormSchedule = Omit<AutomationFormAction, 'key' | 'id' | 'storedConfig' | 'status'>
117
+
118
+ function toFormSchedule(config: AutomationActionConfig): AutomationFormSchedule {
119
+ const delay = typeof config.delay === 'number' ? config.delay : 0
120
+ // The sign of the delay is its direction; `delayDirection` keeps it at 0.
121
+ const direction =
122
+ delay < 0 || (delay === 0 && config.delayDirection === 'before') ? 'before' : 'after'
123
+
124
+ return {
125
+ channel: config.channel as AutomationChannel,
126
+ messageGroupId: config.template,
127
+ messageGroupName: typeof config.templateName === 'string' ? config.templateName : '',
128
+ dateReference: typeof config.dateTimeReference === 'string' ? config.dateTimeReference : '',
129
+ direction,
130
+ days: Math.abs(delay) / SECONDS_PER_DAY,
131
+ sendingTimeUTC:
132
+ typeof config.sendingTimeUTC === 'string' ? config.sendingTimeUTC : DEFAULT_SENDING_TIME,
133
+ recurrence: AUTOMATION_RECURRENCES.find((recurrence) => recurrence === config.recurrence),
134
+ strictDelay: config.strictDelay === true,
135
+ preventDuplicates: config.preventDuplicates === true,
136
+ }
137
+ }
138
+
139
+ function toFormAction({ id, status, config }: AutomationAction): AutomationFormAction {
140
+ return { key: id, id, storedConfig: config, status, ...toFormSchedule(config) }
141
+ }
142
+
143
+ /** A stored Automation in the form. Its Conditions come as they are (ADR 0018). */
144
+ export function toAutomationForm(automation: Automation): AutomationFormState {
145
+ return cloneFormData({
146
+ name: automation.name,
147
+ triggerType: triggerTypeOf(automation),
148
+ conditions: automation.settings.conditions as Condition[],
149
+ actions: automation.actions.map(toFormAction),
150
+ data: Object.fromEntries(
151
+ (automationMetadataForm(automation.metadata)?.fields ?? []).map(({ key }) => [
152
+ key,
153
+ metadataFormValue(automation.metadata, key),
154
+ ]),
155
+ ),
156
+ })
157
+ }
158
+
159
+ /** "before:checkinDate": a Date Reference and the direction of the delay, as one select value. */
160
+ export function dateReferenceValue(reference: {
161
+ direction: DateReferenceDirection
162
+ dateReference: string
163
+ }): string {
164
+ return `${reference.direction}:${reference.dateReference}`
165
+ }
166
+
167
+ export function parseDateReferenceValue(value: string): {
168
+ direction: DateReferenceDirection
169
+ dateReference: string
170
+ } {
171
+ const separator = value.indexOf(':')
172
+ return {
173
+ direction: value.slice(0, separator) === 'before' ? 'before' : 'after',
174
+ dateReference: value.slice(separator + 1),
175
+ }
176
+ }
177
+
178
+ /** The label of an Action's Date Reference; its key when the Trigger no longer lists it. */
179
+ export function dateReferenceLabel(action: AutomationFormAction, trigger: AutomationTrigger) {
180
+ return (
181
+ trigger.date_references
182
+ .find((reference) => reference.key === action.dateReference)
183
+ ?.directions.find((entry) => entry.direction === action.direction)?.label ??
184
+ action.dateReference
185
+ )
186
+ }
187
+
188
+ /** The sending time on the user's clock: the engine keeps it in UTC. */
189
+ export function localSendingTime(sendingTimeUTC: string): string {
190
+ return new Date(`2000-01-01T${sendingTimeUTC}`).toLocaleTimeString([], {
191
+ hour: '2-digit',
192
+ minute: '2-digit',
193
+ hour12: false,
194
+ })
195
+ }
196
+
197
+ function toActionConfig(action: AutomationFormSchedule): AutomationActionInput['config'] {
198
+ // The sign of the delay is its direction; `delayDirection` keeps it at 0.
199
+ const delay =
200
+ action.direction === 'before' ? -action.days * SECONDS_PER_DAY : action.days * SECONDS_PER_DAY
201
+
202
+ return {
203
+ template: action.messageGroupId,
204
+ templateName: action.messageGroupName,
205
+ channel: action.channel,
206
+ delay: delay || 0,
207
+ dateTimeReference: action.dateReference,
208
+ delayDirection: action.days === 0 ? action.direction : undefined,
209
+ sendingTimeUTC: action.sendingTimeUTC,
210
+ recurrence: action.recurrence,
211
+ strictDelay: action.strictDelay || undefined,
212
+ preventDuplicates: action.preventDuplicates,
213
+ }
214
+ }
215
+
216
+ /**
217
+ * A stored Action goes back with its `config` as stored, and over it only
218
+ * what the user changed: the form shows a default where the `config` has no
219
+ * value (no sending time, no name), and a default written back would change
220
+ * an Action the user did not touch.
221
+ */
222
+ function toActionInput(action: AutomationFormAction): AutomationActionInput {
223
+ const config = toActionConfig(action)
224
+ const { storedConfig } = action
225
+ if (!storedConfig) return { status: action.status, config }
226
+
227
+ const unchanged = toActionConfig(toFormSchedule(storedConfig))
228
+ const changes = Object.fromEntries(
229
+ Object.entries(config).filter(([key, value]) => value !== unchanged[key]),
230
+ )
231
+ return {
232
+ status: action.status,
233
+ config: {
234
+ ...storedConfig,
235
+ channel: config.channel,
236
+ dateTimeReference: config.dateTimeReference,
237
+ ...changes,
238
+ },
239
+ }
240
+ }
241
+
242
+ /** A create without its scope: the mutation names the Organization and the Product. */
243
+ export type NewAutomation = Omit<CreateAutomationBody, 'organization_id' | 'product'>
244
+
245
+ /**
246
+ * The body of a create. No recipient, sender or trigger source: the backend
247
+ * writes them (ADR 0017).
248
+ */
249
+ export function toCreateAutomationBody(form: AutomationFormState): NewAutomation {
250
+ return {
251
+ name: form.name.trim(),
252
+ settings: {
253
+ trigger: { type: form.triggerType },
254
+ conditions: form.conditions,
255
+ },
256
+ actions: form.actions.map(toActionInput),
257
+ }
258
+ }
259
+
260
+ /** An update without its Product: the mutation names it, and the Automation by its id. */
261
+ export type AutomationChanges = Omit<UpdateAutomationBody, 'product'>
262
+
263
+ /**
264
+ * The body of an update of `stored`: every Action the form holds, a stored
265
+ * one with its `id` and a new one without (ADR 0016). What `settings` holds
266
+ * that the form does not show goes back as stored. Of `metadata` only the
267
+ * asked-for values the user changed are sent: the backend merges them into
268
+ * the stored one, and keeps the rest.
269
+ */
270
+ export function toUpdateAutomationBody(
271
+ form: AutomationFormState,
272
+ stored: Automation,
273
+ ): AutomationChanges {
274
+ const sameTrigger = triggerTypeOf(stored) === form.triggerType
275
+ const changedData = Object.entries(form.data).filter(
276
+ ([key, value]) => value !== metadataFormValue(stored.metadata, key),
277
+ )
278
+
279
+ return {
280
+ name: form.name.trim(),
281
+ ...(changedData.length > 0 && { metadata: Object.fromEntries(changedData) }),
282
+ settings: {
283
+ ...stored.settings,
284
+ trigger: { ...(sameTrigger && stored.settings.trigger), type: form.triggerType },
285
+ conditions: form.conditions,
286
+ },
287
+ actions: form.actions.map((action) => ({ ...toActionInput(action), id: action.id })),
288
+ }
289
+ }
290
+
291
+ /**
292
+ * The properties of `Automation setup`, as Smartconnect's pages send them.
293
+ * `trigger` is the form's, when the Automation Catalog still lists it.
294
+ */
295
+ export function automationSetupProperties(
296
+ form: AutomationFormState,
297
+ trigger: AutomationTrigger | undefined,
298
+ ) {
299
+ const action = form.actions[0]
300
+ const dateReference = action?.dateReference ?? ''
301
+
302
+ return {
303
+ // The backend writes the sender: this is the template it will write.
304
+ automation_sender:
305
+ trigger?.channels.find((entry) => entry.channel === action?.channel)?.sender ?? '',
306
+ automation_name: form.name.trim(),
307
+ automation_trigger: form.triggerType,
308
+ automation_type: dateReference,
309
+ automation_rules_audience: form.conditions.length > 0 ? 'segmented' : 'all',
310
+ automation_subject: action?.messageGroupName ?? '',
311
+ automation_moment: dateReference,
312
+ automation_channel: action?.channel ?? '',
313
+ automation_status: action?.status ?? '',
314
+ automation_days: action?.days ?? 0,
315
+ created_by: 'user',
316
+ } as const
317
+ }
318
+
319
+ /**
320
+ * The `problems` of a refused save (422 `AUTOMATION_ACTIONS_INVALID`), or
321
+ * `undefined` for any other error. The entries are not checked one by one: a
322
+ * client skips a `target` or a `reason` it does not know.
323
+ */
324
+ export function automationSaveProblems(error: unknown): AutomationSaveProblem[] | undefined {
325
+ const data = (error as { data?: { code?: unknown; details?: { problems?: unknown } } } | null)
326
+ ?.data
327
+ if (data?.code !== 'AUTOMATION_ACTIONS_INVALID') return undefined
328
+
329
+ const problems = data.details?.problems
330
+ return Array.isArray(problems) ? (problems as AutomationSaveProblem[]) : []
331
+ }
332
+
333
+ /**
334
+ * Why the Automation Engine refused a request (422
335
+ * `AUTOMATION_ENGINE_REJECTED`), in the engine's own words, or `undefined`
336
+ * for any other error.
337
+ */
338
+ export function automationEngineRejected(error: unknown): string | undefined {
339
+ const data = (
340
+ error as {
341
+ data?: { code?: unknown; details?: Partial<AutomationEngineRejectedDetails> }
342
+ } | null
343
+ )?.data
344
+ if (data?.code !== 'AUTOMATION_ENGINE_REJECTED') return undefined
345
+
346
+ const { message = '', errors } = data.details ?? {}
347
+ const reasons = Array.isArray(errors)
348
+ ? errors.map(({ field, message }) => `${field}: ${message}`)
349
+ : []
350
+ return reasons.length > 0 ? reasons.join('; ') : message
351
+ }
352
+
353
+ /**
354
+ * What the Automation Engine holds after an update that was saved only in
355
+ * part (502 `UPSTREAM_ERROR` with the current state, ADR 0016), or
356
+ * `undefined` for any other error.
357
+ */
358
+ export function automationPartlySaved(error: unknown): AutomationPartlySavedDetails | undefined {
359
+ const data = (
360
+ error as {
361
+ data?: { code?: unknown; details?: Partial<AutomationPartlySavedDetails> }
362
+ } | null
363
+ )?.data
364
+ const details = data?.details
365
+ if (data?.code !== 'UPSTREAM_ERROR') return undefined
366
+ if (!details?.automation?.id || !Array.isArray(details.action_ids)) return undefined
367
+
368
+ return details as AutomationPartlySavedDetails
369
+ }
370
+
371
+ /**
372
+ * The form's Actions after an update that was saved only in part. Each
373
+ * Action of `sent`, the list that was submitted, takes the id and the stored
374
+ * `config` the Automation Engine now has for it, so that saving again creates
375
+ * nothing twice. What the user sees and typed is not touched.
376
+ */
377
+ export function withPartlySavedActions(
378
+ actions: AutomationFormAction[],
379
+ sent: AutomationFormAction[],
380
+ { automation, action_ids }: AutomationPartlySavedDetails,
381
+ ): AutomationFormAction[] {
382
+ const idByKey = new Map(sent.map((action, position) => [action.key, action_ids[position]]))
383
+
384
+ return actions.map((action) => {
385
+ const id = idByKey.get(action.key)
386
+ const stored = automation.actions.find((candidate) => candidate.id === id)
387
+ return stored ? { ...action, id: stored.id, storedConfig: stored.config } : action
388
+ })
389
+ }
@@ -0,0 +1,71 @@
1
+ import type { Automation } from '@dev.smartpricing/message-composer-utils/types'
2
+
3
+ /**
4
+ * What the pages read of an Automation's `metadata`, the Automation Engine's
5
+ * free JSON: whether it is a System Automation, and the values it asks its
6
+ * user for.
7
+ */
8
+
9
+ type AutomationMetadata = Automation['metadata']
10
+
11
+ /**
12
+ * Whether the Automation is a System Automation: one its Host created, whose
13
+ * user can only activate its Actions and fill in what it asks for (ADR 0016).
14
+ * The one place in the layer that reads the marker.
15
+ */
16
+ export function isSystemAutomation(automation: { metadata?: AutomationMetadata }): boolean {
17
+ return automation.metadata?.locked === true
18
+ }
19
+
20
+ /**
21
+ * One value an Automation asks its user for. Its label is the Host's text,
22
+ * or a key of the Host's translations, and whatever its stored `type` it is a line of text, as in Smartconnect.
23
+ */
24
+ export interface AutomationMetadataFormField {
25
+ /** The metadata key the value is stored under. */
26
+ key: string
27
+ label: string
28
+ /** Shown on the field. Nothing stops a save over it, as in Smartconnect. */
29
+ required: boolean
30
+ }
31
+
32
+ export interface AutomationMetadataForm {
33
+ description?: string
34
+ fields: AutomationMetadataFormField[]
35
+ }
36
+
37
+ /**
38
+ * The stored `metadata.form`, or `undefined` when the Automation asks for
39
+ * nothing. Whoever seeded the Automation wrote it: what is not a field with a
40
+ * key is skipped, not refused.
41
+ */
42
+ export function automationMetadataForm(
43
+ metadata: AutomationMetadata,
44
+ ): AutomationMetadataForm | undefined {
45
+ const form = metadata?.form as { description?: unknown; fields?: unknown } | null | undefined
46
+ if (typeof form !== 'object' || form === null || !Array.isArray(form.fields)) return undefined
47
+
48
+ const fields = form.fields.flatMap((field: unknown): AutomationMetadataFormField[] => {
49
+ const { key, label, required } = (field ?? {}) as Record<string, unknown>
50
+ if (typeof key !== 'string' || !key) return []
51
+ return [
52
+ {
53
+ key,
54
+ label: typeof label === 'string' ? label : key,
55
+ required: required === true,
56
+ },
57
+ ]
58
+ })
59
+ if (fields.length === 0) return undefined
60
+
61
+ return {
62
+ description: typeof form.description === 'string' ? form.description : undefined,
63
+ fields,
64
+ }
65
+ }
66
+
67
+ /** A stored value as the form's text input shows it; empty when there is none. */
68
+ export function metadataFormValue(metadata: AutomationMetadata, key: string): string {
69
+ const value = metadata?.[key]
70
+ return value === undefined || value === null ? '' : String(value)
71
+ }
@@ -0,0 +1,90 @@
1
+ import type {
2
+ AutomationChannel,
3
+ AutomationTrigger,
4
+ } from '@dev.smartpricing/message-composer-utils/types'
5
+ import type { AutomationFormAction } from './form'
6
+
7
+ /**
8
+ * What an Automation can hold that is no longer available, and which of it
9
+ * stops a save. Nothing is dropped or rewritten: a thing that only stays is
10
+ * "kept", and the user is told.
11
+ */
12
+
13
+ export type AutomationUnavailableKind =
14
+ | 'trigger'
15
+ | 'date_reference'
16
+ | 'condition_unknown_field'
17
+ | 'condition_not_editable'
18
+ | 'option'
19
+ | 'channel'
20
+ | 'message_group_inactive'
21
+ | 'message_group_active'
22
+
23
+ /** The kinds a save does not go through with. Every other one is kept. */
24
+ const BLOCKING_KINDS: readonly AutomationUnavailableKind[] = [
25
+ 'trigger',
26
+ 'date_reference',
27
+ 'condition_unknown_field',
28
+ 'message_group_active',
29
+ ]
30
+
31
+ export function unavailableBlocks(kind: AutomationUnavailableKind): boolean {
32
+ return BLOCKING_KINDS.includes(kind)
33
+ }
34
+
35
+ /** One unavailable thing, as the alert lists it and as its badge says it. */
36
+ export interface AutomationUnavailableItem {
37
+ /** Tells the items of one list apart. */
38
+ key: string
39
+ kind: AutomationUnavailableKind
40
+ blocks: boolean
41
+ /** Where the thing is: "Trigger", `Message "Welcome"`. */
42
+ where: string
43
+ /** The full sentence: what is wrong and what to do. */
44
+ text: string
45
+ /** The id of the element the thing is at. */
46
+ target?: string
47
+ /** The thing is a Condition: showing it opens the filter editor. */
48
+ inFilters?: boolean
49
+ }
50
+
51
+ /** What tells whether an Action holds something unavailable. */
52
+ export interface ActionAvailability {
53
+ trigger: AutomationTrigger
54
+ /** The Channels the Organization cannot use. */
55
+ unusableChannels: readonly AutomationChannel[]
56
+ /**
57
+ * Whether a Message Group is approved in every language, by its id in lower
58
+ * case. One that is not in there is not known yet, or could not be read:
59
+ * it is not taken for unapproved.
60
+ */
61
+ approvals: ReadonlyMap<string, boolean> | undefined
62
+ }
63
+
64
+ export function isDateReferenceListed(
65
+ action: AutomationFormAction,
66
+ trigger: AutomationTrigger,
67
+ ): boolean {
68
+ return trigger.date_references.some(({ key }) => key === action.dateReference)
69
+ }
70
+
71
+ /** The unavailable things one Action holds, in the order they are shown. */
72
+ export function actionUnavailableKinds(
73
+ action: AutomationFormAction,
74
+ { trigger, unusableChannels, approvals }: ActionAvailability,
75
+ ): AutomationUnavailableKind[] {
76
+ const kinds: AutomationUnavailableKind[] = []
77
+
78
+ if (!isDateReferenceListed(action, trigger)) kinds.push('date_reference')
79
+
80
+ // An Action on a Channel that cannot be used is not sent and shows as not
81
+ // Active, with a switch that does not work: it is never asked to be turned off.
82
+ const unusableChannel = unusableChannels.includes(action.channel)
83
+ if (unusableChannel) kinds.push('channel')
84
+ if (approvals?.get(action.messageGroupId.toLowerCase()) === false) {
85
+ const active = action.status === 'active' && !unusableChannel
86
+ kinds.push(active ? 'message_group_active' : 'message_group_inactive')
87
+ }
88
+
89
+ return kinds
90
+ }