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