@antelopejs/dms-mailing 0.6.0 → 0.7.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/dist/constants.d.ts +1 -0
- package/dist/constants.js +2 -1
- package/dist/constants.js.map +1 -1
- package/dist/data/templates-table.d.ts +4 -1
- package/dist/data/templates-table.js +55 -13
- package/dist/data/templates-table.js.map +1 -1
- package/dist/data-types/categories-type.d.ts +5 -5
- package/dist/db/models/templates.model.d.ts +10 -1
- package/dist/db/models/templates.model.js +27 -1
- package/dist/db/models/templates.model.js.map +1 -1
- package/dist/db/tables/templates.table.d.ts +7 -1
- package/dist/db/tables/templates.table.js +3 -1
- package/dist/db/tables/templates.table.js.map +1 -1
- package/dist/engine/formats.d.ts +13 -0
- package/dist/engine/formats.js +85 -0
- package/dist/engine/formats.js.map +1 -0
- package/dist/engine/index.d.ts +1 -0
- package/dist/engine/index.js +1 -0
- package/dist/engine/index.js.map +1 -1
- package/dist/engine/resolve.d.ts +2 -1
- package/dist/engine/resolve.js +14 -7
- package/dist/engine/resolve.js.map +1 -1
- package/dist/engine/tokens.d.ts +2 -1
- package/dist/engine/tokens.js +3 -5
- package/dist/engine/tokens.js.map +1 -1
- package/dist/frontend-module.d.ts +2 -0
- package/dist/frontend-module.js +24 -0
- package/dist/frontend-module.js.map +1 -0
- package/dist/hooks/tenant-export.js +4 -1
- package/dist/hooks/tenant-export.js.map +1 -1
- package/dist/implementations/dms-mailing/index.d.ts +1 -0
- package/dist/implementations/dms-mailing/index.js +3 -1
- package/dist/implementations/dms-mailing/index.js.map +1 -1
- package/dist/index.js +2 -19
- package/dist/index.js.map +1 -1
- package/dist/pages/overview.d.ts +1 -1
- package/dist/pages/overview.js +11 -13
- package/dist/pages/overview.js.map +1 -1
- package/dist/pages/sends.d.ts +1 -1
- package/dist/pages/sends.js +16 -8
- package/dist/pages/sends.js.map +1 -1
- package/dist/pages/templates.js +3 -8
- package/dist/pages/templates.js.map +1 -1
- package/dist/pages/toolbar.d.ts +12 -0
- package/dist/pages/toolbar.js +41 -0
- package/dist/pages/toolbar.js.map +1 -0
- package/dist/realtime.d.ts +2 -2
- package/dist/realtime.js +8 -8
- package/dist/realtime.js.map +1 -1
- package/dist/routes/metrics.d.ts +9 -2
- package/dist/routes/metrics.js +90 -35
- package/dist/routes/metrics.js.map +1 -1
- package/dist/routes/period.d.ts +1 -0
- package/dist/routes/period.js +19 -0
- package/dist/routes/period.js.map +1 -1
- package/dist/routes/sends.js +6 -3
- package/dist/routes/sends.js.map +1 -1
- package/dist/routes/settings.js +6 -7
- package/dist/routes/settings.js.map +1 -1
- package/dist/routes/templates.d.ts +8 -4
- package/dist/routes/templates.js +40 -15
- package/dist/routes/templates.js.map +1 -1
- package/dist/services/categories.d.ts +2 -1
- package/dist/services/categories.js +9 -0
- package/dist/services/categories.js.map +1 -1
- package/dist/services/declarations.js +2 -2
- package/dist/services/declarations.js.map +1 -1
- package/dist/services/events.js +1 -1
- package/dist/services/events.js.map +1 -1
- package/dist/services/localized-text.d.ts +7 -0
- package/dist/services/localized-text.js +28 -0
- package/dist/services/localized-text.js.map +1 -0
- package/dist/services/send-stat-items.js +34 -12
- package/dist/services/send-stat-items.js.map +1 -1
- package/dist/services/send-stats.d.ts +7 -0
- package/dist/services/send-stats.js +17 -5
- package/dist/services/send-stats.js.map +1 -1
- package/dist/services/send.d.ts +2 -0
- package/dist/services/send.js +11 -12
- package/dist/services/send.js.map +1 -1
- package/dist/services/sender-declaration.d.ts +2 -0
- package/dist/services/sender-declaration.js +18 -0
- package/dist/services/sender-declaration.js.map +1 -0
- package/dist/services/starters.d.ts +2 -2
- package/dist/services/starters.js +1 -10
- package/dist/services/starters.js.map +1 -1
- package/dist/services/templates.d.ts +11 -5
- package/dist/services/templates.js +24 -6
- package/dist/services/templates.js.map +1 -1
- package/dist/services/webhook-status.d.ts +2 -1
- package/dist/services/webhook-status.js +6 -5
- package/dist/services/webhook-status.js.map +1 -1
- package/dist/types/settings.d.ts +6 -0
- package/dist/types/settings.js +25 -5
- package/dist/types/settings.js.map +1 -1
- package/dist/validation/declarations.schema.d.ts +25 -7
- package/dist/validation/declarations.schema.js +16 -3
- package/dist/validation/declarations.schema.js.map +1 -1
- package/dist/validation/settings.schema.d.ts +25 -25
- package/dist/validation/settings.schema.js +30 -6
- package/dist/validation/settings.schema.js.map +1 -1
- package/dist/validation/templates.schema.d.ts +13 -0
- package/dist/validation/templates.schema.js +6 -1
- package/dist/validation/templates.schema.js.map +1 -1
- package/frontend-vue/app/components/CategoriesInput.vue +15 -13
- package/frontend-vue/app/components/FunnelCard.vue +46 -3
- package/frontend-vue/app/components/NewTemplateModal.vue +4 -2
- package/frontend-vue/app/components/OverviewGate.vue +0 -8
- package/frontend-vue/app/components/ProviderPill.vue +50 -0
- package/frontend-vue/app/components/SendDrawer.vue +206 -183
- package/frontend-vue/app/components/StarterGallery.vue +1 -1
- package/frontend-vue/app/components/TemplateCard.vue +4 -1
- package/frontend-vue/app/components/TemplateDrawer.vue +3 -5
- package/frontend-vue/app/components/TemplateGallery.vue +57 -17
- package/frontend-vue/app/components/TemplatePreviewFrame.vue +41 -11
- package/frontend-vue/app/components/editor/EditorTemplatePane.vue +1 -3
- package/frontend-vue/app/components/editor/EditorTitle.vue +1 -7
- package/frontend-vue/app/composables/useLocalizedDefaults.ts +5 -7
- package/frontend-vue/app/composables/useMailingApi.ts +3 -3
- package/frontend-vue/app/composables/useMailingReads.ts +2 -2
- package/frontend-vue/app/composables/useTemplateCategories.ts +8 -7
- package/frontend-vue/app/types/mailing.ts +18 -0
- package/frontend-vue/app/utils/categories.ts +58 -49
- package/frontend-vue/app/utils/funnel.ts +25 -0
- package/frontend-vue/app/utils/period.ts +0 -7
- package/frontend-vue/app/utils/starters.ts +3 -1
- package/frontend-vue/app/utils/tokens.ts +6 -1
- package/frontend-vue/i18n/locales/mailing-en-GB.json +19 -28
- package/frontend-vue/i18n/locales/mailing-fr-FR.json +19 -28
- package/frontend-vue/package.json +1 -1
- package/frontend-vue/tests/categories-input.test.ts +4 -10
- package/frontend-vue/tests/categories.test.ts +55 -17
- package/frontend-vue/tests/conditions.test.ts +1 -4
- package/frontend-vue/tests/funnel-card.test.ts +27 -0
- package/frontend-vue/tests/funnel.test.ts +25 -0
- package/frontend-vue/tests/localized-defaults.test.ts +0 -29
- package/frontend-vue/tests/new-template-modal.test.ts +1 -1
- package/frontend-vue/tests/overview-gate.test.ts +1 -24
- package/frontend-vue/tests/page-reads.test.ts +9 -25
- package/frontend-vue/tests/period.test.ts +0 -12
- package/frontend-vue/tests/replay-toast.test.ts +2 -1
- package/frontend-vue/tests/send-drawer.test.ts +123 -0
- package/frontend-vue/tests/template-drawer.test.ts +1 -1
- package/frontend-vue/tests/template-gallery.test.ts +90 -3
- package/frontend-vue/tests/template-preview-frame.test.ts +92 -0
- package/package.json +6 -5
- package/skills/dms-mailing/SKILL.md +17 -4
- package/src/constants.ts +2 -0
- package/src/data/sends-table.ts +1 -1
- package/src/data/templates-table.ts +123 -19
- package/src/db/models/templates.model.ts +61 -3
- package/src/db/tables/templates.table.ts +10 -1
- package/src/engine/formats.ts +124 -0
- package/src/engine/index.ts +1 -0
- package/src/engine/resolve.ts +18 -7
- package/src/engine/tokens.ts +7 -5
- package/src/frontend-module.ts +36 -0
- package/src/hooks/tenant-export.ts +8 -1
- package/src/implementations/dms-mailing/index.ts +2 -0
- package/src/index.ts +2 -23
- package/src/pages/overview.ts +20 -16
- package/src/pages/sends.ts +27 -11
- package/src/pages/templates.ts +4 -8
- package/src/pages/toolbar.ts +77 -0
- package/src/realtime.ts +17 -8
- package/src/routes/metrics.ts +158 -45
- package/src/routes/period.ts +32 -0
- package/src/routes/sends.ts +13 -4
- package/src/routes/settings.ts +11 -8
- package/src/routes/templates.ts +72 -26
- package/src/services/categories.ts +14 -1
- package/src/services/declarations.ts +4 -3
- package/src/services/events.ts +1 -1
- package/src/services/localized-text.ts +53 -0
- package/src/services/send-stat-items.ts +60 -13
- package/src/services/send-stats.ts +37 -5
- package/src/services/send.ts +19 -20
- package/src/services/sender-declaration.ts +27 -0
- package/src/services/starters.ts +3 -15
- package/src/services/templates.ts +50 -19
- package/src/services/webhook-status.ts +3 -3
- package/src/types/settings.ts +35 -5
- package/src/validation/declarations.schema.ts +29 -3
- package/src/validation/settings.schema.ts +56 -14
- package/src/validation/templates.schema.ts +10 -0
- package/frontend-vue/app/components/PageHeader.vue +0 -94
- package/frontend-vue/app/composables/useOverviewSetup.ts +0 -21
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import {
|
|
2
2
|
Controller,
|
|
3
3
|
HTTPResult,
|
|
4
|
+
Parameter,
|
|
4
5
|
type RequestContext,
|
|
5
6
|
} from "@antelopejs/interface-api";
|
|
6
7
|
import { assertValidation } from "@antelopejs/interface-api-util";
|
|
@@ -9,6 +10,7 @@ import {
|
|
|
9
10
|
type DataControllerCallback,
|
|
10
11
|
RegisterDataController,
|
|
11
12
|
} from "@antelopejs/interface-data-api";
|
|
13
|
+
import type { Validation } from "@antelopejs/interface-data-api/components";
|
|
12
14
|
import {
|
|
13
15
|
Access,
|
|
14
16
|
AccessMode,
|
|
@@ -17,9 +19,17 @@ import {
|
|
|
17
19
|
Listable,
|
|
18
20
|
Mandatory,
|
|
19
21
|
ModelReference,
|
|
22
|
+
ModifierKey,
|
|
20
23
|
Sortable,
|
|
21
24
|
} from "@antelopejs/interface-data-api/metadata";
|
|
22
|
-
import {
|
|
25
|
+
import {
|
|
26
|
+
GetModel,
|
|
27
|
+
LocalizationModifier,
|
|
28
|
+
} from "@antelopejs/interface-database-decorators";
|
|
29
|
+
import {
|
|
30
|
+
LOCALIZED_TEXT_FALLBACK_LOCALE,
|
|
31
|
+
type LocalizedText,
|
|
32
|
+
} from "@antelopejs/interface-dms-mailing";
|
|
23
33
|
import { authenticateTenantRequest } from "@antelopejs/interface-dms/guards";
|
|
24
34
|
import { getRequestTenantId } from "@antelopejs/interface-dms/request-tenant";
|
|
25
35
|
import { TenantScopedModel } from "@antelopejs/interface-dms/tenant-scoped-model";
|
|
@@ -48,9 +58,15 @@ import { footerForCategory } from "../engine";
|
|
|
48
58
|
import {
|
|
49
59
|
MailingTemplate,
|
|
50
60
|
SendModel,
|
|
61
|
+
type TemplateFields,
|
|
51
62
|
TemplateModel,
|
|
52
63
|
TemplateVersionModel,
|
|
53
64
|
} from "../db";
|
|
65
|
+
import {
|
|
66
|
+
CONTENT_LANGUAGE_HEADER,
|
|
67
|
+
localeOfRequest,
|
|
68
|
+
withTextIn,
|
|
69
|
+
} from "../services/localized-text";
|
|
54
70
|
import { findCategory, getSettings } from "../services/settings";
|
|
55
71
|
import { findStarter, starterFields } from "../services/starters";
|
|
56
72
|
import { refreshTemplateAttention } from "../services/template-attention";
|
|
@@ -65,7 +81,10 @@ import {
|
|
|
65
81
|
} from "../services/templates";
|
|
66
82
|
import type { TemplateCategory } from "../types";
|
|
67
83
|
|
|
68
|
-
import {
|
|
84
|
+
import {
|
|
85
|
+
createTemplateSchema,
|
|
86
|
+
templateDetailsSchema,
|
|
87
|
+
} from "../validation/templates.schema";
|
|
69
88
|
import { parseBody } from "./body";
|
|
70
89
|
import { CategoryDisplay, LocaleChipsDisplay } from "./displays";
|
|
71
90
|
|
|
@@ -116,7 +135,7 @@ async function seedFields(
|
|
|
116
135
|
tenantId: string,
|
|
117
136
|
seed: TemplateSeed,
|
|
118
137
|
fallbackLocale: string,
|
|
119
|
-
): Promise<
|
|
138
|
+
): Promise<TemplateFields> {
|
|
120
139
|
const source = await resolveSource(tenantId, seed.sourceTemplateId);
|
|
121
140
|
if (source) return contentFieldsOf(source);
|
|
122
141
|
if (!seed.starterId) {
|
|
@@ -144,23 +163,43 @@ function assertKnownCategory(
|
|
|
144
163
|
throw new HTTPResult(HTTP_UNPROCESSABLE, UNKNOWN_CATEGORY);
|
|
145
164
|
}
|
|
146
165
|
|
|
147
|
-
/** The
|
|
148
|
-
|
|
149
|
-
|
|
166
|
+
/** The row `new` answers since interface-data-api 0.3: its key and fields. */
|
|
167
|
+
interface CreatedRow {
|
|
168
|
+
_id: string;
|
|
169
|
+
[field: string]: unknown;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* The creation's seed, and the body to store: slug trimmed, and the name
|
|
174
|
+
* typed in the reader's language as every language of the localized field,
|
|
175
|
+
* the fallback one included until someone translates it. A body a guard
|
|
176
|
+
* already parsed arrives as an object.
|
|
177
|
+
*/
|
|
178
|
+
function parseCreation(body: Validation.WrittenBody, locale: string) {
|
|
179
|
+
const raw = Buffer.isBuffer(body) ? parseBody(body) : body;
|
|
150
180
|
const seed = assertValidation(
|
|
151
181
|
raw,
|
|
152
182
|
(value) => createTemplateSchema.parse(value),
|
|
153
183
|
() => INVALID_TEMPLATE,
|
|
154
184
|
HTTP_BAD_REQUEST,
|
|
155
185
|
);
|
|
156
|
-
|
|
186
|
+
// Handed as an object: interface-data-api 0.3 refuses a JSON string.
|
|
187
|
+
const normalized = {
|
|
157
188
|
...raw,
|
|
158
189
|
slug: seed.slug,
|
|
159
|
-
name: seed.name,
|
|
160
|
-
}
|
|
190
|
+
name: withTextIn(undefined, locale, seed.name),
|
|
191
|
+
};
|
|
161
192
|
return { seed, normalized };
|
|
162
193
|
}
|
|
163
194
|
|
|
195
|
+
/** What the data API locks a localized field with: the controller's key. */
|
|
196
|
+
interface LocalizedController {
|
|
197
|
+
language: string;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** The key that writes every language of a localized field at once. */
|
|
201
|
+
const ALL_LANGUAGES = "*";
|
|
202
|
+
|
|
164
203
|
function withSeeding(base: DataControllerCallback): DataControllerCallback {
|
|
165
204
|
return {
|
|
166
205
|
...base,
|
|
@@ -168,10 +207,10 @@ function withSeeding(base: DataControllerCallback): DataControllerCallback {
|
|
|
168
207
|
this: unknown,
|
|
169
208
|
ctx: RequestContext,
|
|
170
209
|
params: unknown,
|
|
171
|
-
body:
|
|
210
|
+
body: Validation.WrittenBody,
|
|
172
211
|
...rest: unknown[]
|
|
173
212
|
) {
|
|
174
|
-
const { seed, normalized } = parseCreation(body);
|
|
213
|
+
const { seed, normalized } = parseCreation(body, localeOfRequest(ctx));
|
|
175
214
|
const tenantId = getRequestTenantId(ctx);
|
|
176
215
|
const model = GetModel(TemplateModel, tenantId);
|
|
177
216
|
if (await model.getBySlug(seed.slug)) {
|
|
@@ -180,18 +219,21 @@ function withSeeding(base: DataControllerCallback): DataControllerCallback {
|
|
|
180
219
|
const settings = await getSettings(tenantId);
|
|
181
220
|
assertKnownCategory(settings.categories, seed.category);
|
|
182
221
|
const fields = await seedFields(tenantId, seed, settings.fallbackLocale);
|
|
183
|
-
|
|
222
|
+
// The module's creation dialogs send one typed name; the stored name
|
|
223
|
+
// holds every language, written at once.
|
|
224
|
+
(this as LocalizedController).language = ALL_LANGUAGES;
|
|
225
|
+
const created = (await base.func.call(
|
|
184
226
|
this,
|
|
185
227
|
ctx,
|
|
186
228
|
params,
|
|
187
229
|
normalized,
|
|
188
230
|
...rest,
|
|
189
|
-
)) as
|
|
190
|
-
const templateId =
|
|
231
|
+
)) as CreatedRow;
|
|
232
|
+
const templateId = created._id;
|
|
191
233
|
await assertSlugClaimed(tenantId, seed.slug, templateId);
|
|
192
234
|
const user = await authenticateTenantRequest(ctx);
|
|
193
235
|
await initializeTemplate(tenantId, templateId, displayName(user), fields);
|
|
194
|
-
return
|
|
236
|
+
return created;
|
|
195
237
|
},
|
|
196
238
|
};
|
|
197
239
|
}
|
|
@@ -266,21 +308,78 @@ export async function assertEditable(
|
|
|
266
308
|
): Promise<void> {
|
|
267
309
|
if (body.slug !== undefined && body.slug !== current.slug)
|
|
268
310
|
throw new HTTPResult(HTTP_CONFLICT, SLUG_IMMUTABLE);
|
|
269
|
-
if (
|
|
311
|
+
if (body.name !== undefined && isBlankName(body.name))
|
|
270
312
|
throw new HTTPResult(HTTP_BAD_REQUEST, BLANK_NAME);
|
|
271
313
|
const { categories } = await getSettings(tenantId);
|
|
272
314
|
assertKnownCategory(categories, body.category);
|
|
273
315
|
}
|
|
274
316
|
|
|
275
|
-
/**
|
|
317
|
+
/**
|
|
318
|
+
* Saves what the module's drawer and editor change: the name, typed in the
|
|
319
|
+
* reader's language (its other languages kept), and the category.
|
|
320
|
+
*/
|
|
321
|
+
export async function saveDetails(
|
|
322
|
+
ctx: RequestContext,
|
|
323
|
+
template: MailingTemplate,
|
|
324
|
+
body: unknown,
|
|
325
|
+
author: string,
|
|
326
|
+
): Promise<void> {
|
|
327
|
+
const details = assertValidation(
|
|
328
|
+
body,
|
|
329
|
+
(value) => templateDetailsSchema.parse(value),
|
|
330
|
+
() => INVALID_TEMPLATE,
|
|
331
|
+
);
|
|
332
|
+
const tenantId = getRequestTenantId(ctx);
|
|
333
|
+
await assertEditable(tenantId, details, template);
|
|
334
|
+
const names = TemplateModel.namesOf(template);
|
|
335
|
+
await GetModel(TemplateModel, tenantId).updateFields(template._id, {
|
|
336
|
+
name: withTextIn(names, localeOfRequest(ctx), details.name),
|
|
337
|
+
...(details.category !== undefined && {
|
|
338
|
+
category: details.category ?? "",
|
|
339
|
+
}),
|
|
340
|
+
updatedBy: author,
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Whether an edited name holds no text: the text of the reader's language,
|
|
346
|
+
* or, from a form editing every language, all of them.
|
|
347
|
+
*/
|
|
348
|
+
function isBlankName(name: unknown): boolean {
|
|
349
|
+
const texts = typeof name === "object" && name ? Object.values(name) : [name];
|
|
350
|
+
return !texts.some((text) => typeof text === "string" && text.trim());
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/** The names trimmed, blank ones left out, the fallback language filled. */
|
|
354
|
+
function tidyNames(names: LocalizedText, locale: string): LocalizedText {
|
|
355
|
+
const tidy = Object.fromEntries(
|
|
356
|
+
Object.entries(names)
|
|
357
|
+
.map(([code, text]) => [code, text.trim()] as const)
|
|
358
|
+
.filter(([, text]) => text),
|
|
359
|
+
);
|
|
360
|
+
if (tidy[LOCALIZED_TEXT_FALLBACK_LOCALE]) return tidy;
|
|
361
|
+
const [first = ""] = Object.values(tidy);
|
|
362
|
+
return { ...tidy, [LOCALIZED_TEXT_FALLBACK_LOCALE]: tidy[locale] ?? first };
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* Once an edit of the name is written, stores it trimmed, and gives the
|
|
367
|
+
* fallback language a name when it has none, so every reader sees one.
|
|
368
|
+
*/
|
|
276
369
|
export function afterEdit(
|
|
277
370
|
model: TemplateModel,
|
|
278
371
|
id: string,
|
|
279
372
|
name: unknown,
|
|
373
|
+
locale: string,
|
|
280
374
|
): AfterWrite | undefined {
|
|
281
|
-
if (
|
|
375
|
+
if (name === undefined) return undefined;
|
|
282
376
|
return async () => {
|
|
283
|
-
await model.
|
|
377
|
+
const template = await model.get(id);
|
|
378
|
+
if (!template) return;
|
|
379
|
+
const names = TemplateModel.namesOf(template);
|
|
380
|
+
const tidy = tidyNames(names, locale);
|
|
381
|
+
if (JSON.stringify(tidy) !== JSON.stringify(names))
|
|
382
|
+
await model.updateFields(id, { name: tidy });
|
|
284
383
|
};
|
|
285
384
|
}
|
|
286
385
|
|
|
@@ -306,6 +405,11 @@ export class TemplatesTableAPI extends DataController(
|
|
|
306
405
|
@TenantScopedModel(SendModel)
|
|
307
406
|
declare sends: SendModel;
|
|
308
407
|
|
|
408
|
+
/** The language names are read, searched, sorted and written in. */
|
|
409
|
+
@Parameter(CONTENT_LANGUAGE_HEADER, "header")
|
|
410
|
+
@ModifierKey(LocalizationModifier)
|
|
411
|
+
declare language: string;
|
|
412
|
+
|
|
309
413
|
@Select()
|
|
310
414
|
@Listable()
|
|
311
415
|
@Exported()
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
BasicDataModel,
|
|
4
|
+
triggerEvent,
|
|
5
|
+
} from "@antelopejs/interface-database-decorators";
|
|
6
|
+
import type { LocalizedText } from "@antelopejs/interface-dms-mailing";
|
|
3
7
|
import { MAILING_TEMPLATES_TABLE_NAME, MailingTemplate } from "../tables";
|
|
4
8
|
import { TemplateSlugModel } from "./template-slugs.model";
|
|
5
9
|
|
|
@@ -9,22 +13,76 @@ export type TemplateAttentionFields = Pick<
|
|
|
9
13
|
"attention" | "needsAttention"
|
|
10
14
|
>;
|
|
11
15
|
|
|
16
|
+
const isLocalizedText = (value: unknown): value is LocalizedText =>
|
|
17
|
+
typeof value === "object" &&
|
|
18
|
+
value !== null &&
|
|
19
|
+
Object.values(value).every((text) => typeof text === "string");
|
|
20
|
+
|
|
21
|
+
/** Where a row keeps its modifier-held fields, the localized name among them. */
|
|
22
|
+
const MODIFIER_CONTAINER = "_internal";
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Template fields as code writes them: the name holds every language
|
|
26
|
+
* (`{ en: "Welcome", fr: "Bienvenue" }`).
|
|
27
|
+
*/
|
|
28
|
+
export type TemplateFields = Omit<Partial<MailingTemplate>, "name"> & {
|
|
29
|
+
name?: LocalizedText;
|
|
30
|
+
};
|
|
31
|
+
|
|
12
32
|
export class TemplateModel extends BasicDataModel(
|
|
13
33
|
MailingTemplate,
|
|
14
34
|
MAILING_TEMPLATES_TABLE_NAME,
|
|
15
35
|
) {
|
|
36
|
+
/** The row of `fields`, its name written in every language it holds. */
|
|
37
|
+
static fromFields(fields: TemplateFields): MailingTemplate {
|
|
38
|
+
return TemplateModel.fromPlainData(fields).localize("*", ["name"]);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** The names of `template`, one per language. */
|
|
42
|
+
static namesOf(template: MailingTemplate): LocalizedText {
|
|
43
|
+
// Unlocked with "*", the localized field holds the whole per-language record.
|
|
44
|
+
const names: unknown = template.localize("*", ["name"]).name;
|
|
45
|
+
return isLocalizedText(names) ? names : {};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The name of `template` in `locale`, else in the fallback language. */
|
|
49
|
+
static nameIn(template: MailingTemplate, locale: string): string {
|
|
50
|
+
return template.localize(locale, ["name"]).name ?? "";
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Inserts a template whose name holds every language. */
|
|
54
|
+
insertFields(fields: TemplateFields) {
|
|
55
|
+
return this.insert(TemplateModel.fromFields(fields));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Updates a template, a name given holding every language. Every write of
|
|
60
|
+
* a template goes through here rather than `update`: an update event leaves
|
|
61
|
+
* the row's modifier container (`_internal`, where the localized name
|
|
62
|
+
* lives) empty when the patch has no name, and the database merges a row
|
|
63
|
+
* shallowly, so that empty container would wipe the stored name.
|
|
64
|
+
*/
|
|
65
|
+
updateFields(id: string, fields: TemplateFields): Promise<number> {
|
|
66
|
+
const row = TemplateModel.fromFields(fields);
|
|
67
|
+
triggerEvent(row, "update");
|
|
68
|
+
const stored = TemplateModel.toDatabase(row);
|
|
69
|
+
if (fields.name === undefined) delete stored[MODIFIER_CONTAINER];
|
|
70
|
+
return this.table.get(id).update(stored).run();
|
|
71
|
+
}
|
|
72
|
+
|
|
16
73
|
/** Publishes only over the exact semantic revision that was inspected. */
|
|
17
74
|
async publishIfCurrent(
|
|
18
75
|
template: MailingTemplate,
|
|
19
|
-
patch:
|
|
76
|
+
patch: TemplateFields,
|
|
20
77
|
): Promise<boolean> {
|
|
78
|
+
const stored = TemplateModel.toDatabase(TemplateModel.fromFields(patch));
|
|
21
79
|
const outcome = await this.table
|
|
22
80
|
.atomicMutation(template._id, {
|
|
23
81
|
type: "update",
|
|
24
82
|
revisionField: "revision",
|
|
25
83
|
expectedRevision: template.revision ?? { kind: "missing" },
|
|
26
84
|
nextRevision: randomUUID(),
|
|
27
|
-
patch: { ...
|
|
85
|
+
patch: { ...stored, updatedAt: new Date() },
|
|
28
86
|
})
|
|
29
87
|
.run();
|
|
30
88
|
if (outcome === "unknown")
|
|
@@ -4,18 +4,21 @@ import {
|
|
|
4
4
|
CreationTime,
|
|
5
5
|
Field,
|
|
6
6
|
Index,
|
|
7
|
+
LocalizationModifier,
|
|
8
|
+
Localized,
|
|
7
9
|
Modifier,
|
|
8
10
|
RegisterTable,
|
|
9
11
|
Table,
|
|
10
12
|
UpdateTime,
|
|
11
13
|
} from "@antelopejs/interface-database-decorators";
|
|
12
14
|
import { TENANT_SCHEMA_NAME } from "@antelopejs/interface-dms/constants";
|
|
15
|
+
import { LOCALIZED_TEXT_FALLBACK_LOCALE } from "@antelopejs/interface-dms-mailing";
|
|
13
16
|
import type { TemplateStatus } from "../../types";
|
|
14
17
|
|
|
15
18
|
export const MAILING_TEMPLATES_TABLE_NAME = "mailing_templates";
|
|
16
19
|
|
|
17
20
|
@RegisterTable(MAILING_TEMPLATES_TABLE_NAME, TENANT_SCHEMA_NAME)
|
|
18
|
-
export class MailingTemplate extends Table {
|
|
21
|
+
export class MailingTemplate extends Table.with(LocalizationModifier) {
|
|
19
22
|
@Field("string")
|
|
20
23
|
declare _id: string;
|
|
21
24
|
|
|
@@ -32,6 +35,12 @@ export class MailingTemplate extends Table {
|
|
|
32
35
|
@Field("string")
|
|
33
36
|
declare slug: string;
|
|
34
37
|
|
|
38
|
+
/**
|
|
39
|
+
* One name per language (`{ en: "Welcome", fr: "Bienvenue" }`): read it
|
|
40
|
+
* after `template.localize(locale, ["name"])`, write every language at
|
|
41
|
+
* once through `localize("*")`.
|
|
42
|
+
*/
|
|
43
|
+
@Localized({ fallbackLocale: LOCALIZED_TEXT_FALLBACK_LOCALE })
|
|
35
44
|
@Field("string")
|
|
36
45
|
declare name: string;
|
|
37
46
|
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import type { VariableDefinition } from "../types";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* An amount in the minor unit of its currency (cents for `EUR`), like the
|
|
5
|
+
* DMS's composed-text `money` parameter: `{ value: 987420, currency: "EUR" }`
|
|
6
|
+
* reads "€9,874.20" in English, "9 874,20 €" in French.
|
|
7
|
+
*/
|
|
8
|
+
export interface MoneyValue {
|
|
9
|
+
value: number;
|
|
10
|
+
/** ISO 4217 code. An unknown code writes the amount as a plain number. */
|
|
11
|
+
currency: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** How a send writes its typed values: its locale, and the paths typed `date`. */
|
|
15
|
+
export interface ValueFormat {
|
|
16
|
+
locale: string;
|
|
17
|
+
datePaths: Set<string>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const FALLBACK_LOCALE = "en";
|
|
21
|
+
const DATE_STYLE: Intl.DateTimeFormatOptions = { dateStyle: "long" };
|
|
22
|
+
const UTC_DATE_STYLE: Intl.DateTimeFormatOptions = {
|
|
23
|
+
...DATE_STYLE,
|
|
24
|
+
timeZone: "UTC",
|
|
25
|
+
};
|
|
26
|
+
/** A calendar day: written as that day, whatever the server's time zone. */
|
|
27
|
+
const ISO_DAY = /^\d{4}-\d{2}-\d{2}$/;
|
|
28
|
+
/** A moment: written as its day in the server's time zone. */
|
|
29
|
+
const ISO_DATE_TIME = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/;
|
|
30
|
+
const DECIMAL_BASE = 10;
|
|
31
|
+
const DATE_TYPE = "date";
|
|
32
|
+
|
|
33
|
+
/** `locale` when the runtime can format for it, English otherwise. */
|
|
34
|
+
export function formattingLocale(locale: string): string {
|
|
35
|
+
try {
|
|
36
|
+
return Intl.NumberFormat.supportedLocalesOf(locale)[0] ?? FALLBACK_LOCALE;
|
|
37
|
+
} catch {
|
|
38
|
+
return FALLBACK_LOCALE;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The paths a template declares `date`: their ISO strings are written as dates. */
|
|
43
|
+
export function valueFormatOf(
|
|
44
|
+
locale: string,
|
|
45
|
+
variables: VariableDefinition[] = [],
|
|
46
|
+
): ValueFormat {
|
|
47
|
+
return {
|
|
48
|
+
locale: formattingLocale(locale),
|
|
49
|
+
datePaths: new Set(
|
|
50
|
+
variables
|
|
51
|
+
.filter((variable) => variable.type === DATE_TYPE)
|
|
52
|
+
.map((variable) => variable.path),
|
|
53
|
+
),
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function isMoneyValue(value: unknown): value is MoneyValue {
|
|
58
|
+
if (!value || typeof value !== "object") return false;
|
|
59
|
+
const candidate = value as Partial<MoneyValue>;
|
|
60
|
+
return (
|
|
61
|
+
typeof candidate.value === "number" &&
|
|
62
|
+
Number.isFinite(candidate.value) &&
|
|
63
|
+
typeof candidate.currency === "string"
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function formatMoney(money: MoneyValue, locale: string): string {
|
|
68
|
+
try {
|
|
69
|
+
const currency = new Intl.NumberFormat(locale, {
|
|
70
|
+
style: "currency",
|
|
71
|
+
currency: money.currency,
|
|
72
|
+
});
|
|
73
|
+
const digits = currency.resolvedOptions().maximumFractionDigits ?? 0;
|
|
74
|
+
return currency.format(money.value / DECIMAL_BASE ** digits);
|
|
75
|
+
} catch {
|
|
76
|
+
return new Intl.NumberFormat(locale).format(money.value);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function formatDate(
|
|
81
|
+
date: Date,
|
|
82
|
+
options: Intl.DateTimeFormatOptions,
|
|
83
|
+
locale: string,
|
|
84
|
+
): string {
|
|
85
|
+
return new Intl.DateTimeFormat(locale, options).format(date);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function isoDateOptions(value: string): Intl.DateTimeFormatOptions | null {
|
|
89
|
+
if (ISO_DAY.test(value)) return UTC_DATE_STYLE;
|
|
90
|
+
if (ISO_DATE_TIME.test(value)) return DATE_STYLE;
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** An ISO string at a `date` path as the date it names; `null` otherwise. */
|
|
95
|
+
function formatIsoDate(value: string, locale: string): string | null {
|
|
96
|
+
const options = isoDateOptions(value);
|
|
97
|
+
if (!options) return null;
|
|
98
|
+
const date = new Date(value);
|
|
99
|
+
return Number.isNaN(date.getTime())
|
|
100
|
+
? null
|
|
101
|
+
: formatDate(date, options, locale);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* A value as a recipient reads it. A money value and a `Date` format
|
|
106
|
+
* themselves in the send's locale wherever they appear; an ISO string is
|
|
107
|
+
* written as a date only at a path the template declares `date`; anything
|
|
108
|
+
* else, a string already formatted by the caller among them, as given.
|
|
109
|
+
*/
|
|
110
|
+
export function formatValue(
|
|
111
|
+
value: unknown,
|
|
112
|
+
path = "",
|
|
113
|
+
format?: ValueFormat,
|
|
114
|
+
): string {
|
|
115
|
+
const locale = format?.locale ?? FALLBACK_LOCALE;
|
|
116
|
+
if (isMoneyValue(value)) return formatMoney(value, locale);
|
|
117
|
+
if (value instanceof Date)
|
|
118
|
+
return Number.isNaN(value.getTime())
|
|
119
|
+
? String(value)
|
|
120
|
+
: formatDate(value, DATE_STYLE, locale);
|
|
121
|
+
if (typeof value === "string" && format?.datePaths.has(path))
|
|
122
|
+
return formatIsoDate(value, locale) ?? value;
|
|
123
|
+
return String(value);
|
|
124
|
+
}
|
package/src/engine/index.ts
CHANGED
package/src/engine/resolve.ts
CHANGED
|
@@ -15,6 +15,7 @@ import type {
|
|
|
15
15
|
} from "../types";
|
|
16
16
|
import { evaluateCondition } from "./conditions";
|
|
17
17
|
import { safeUrl } from "./fallback-html";
|
|
18
|
+
import { formatValue, type ValueFormat } from "./formats";
|
|
18
19
|
import { getPath, isPresent } from "./paths";
|
|
19
20
|
import { footerLinkPaths } from "./system-variables";
|
|
20
21
|
import { type InterpolationMode, interpolate } from "./tokens";
|
|
@@ -26,6 +27,7 @@ interface ResolveState {
|
|
|
26
27
|
mode: InterpolationMode;
|
|
27
28
|
missing: Set<string>;
|
|
28
29
|
hidden: string[];
|
|
30
|
+
format?: ValueFormat;
|
|
29
31
|
}
|
|
30
32
|
|
|
31
33
|
/** A blocked image source: the hero is left out rather than drawn broken. */
|
|
@@ -34,7 +36,7 @@ const BLOCKED_IMAGE = "";
|
|
|
34
36
|
type BlockResolver = (block: Block, state: ResolveState) => ResolvedBlock[];
|
|
35
37
|
|
|
36
38
|
function text(state: ResolveState, value: string): string {
|
|
37
|
-
const result = interpolate(value, state.data, state.mode);
|
|
39
|
+
const result = interpolate(value, state.data, state.mode, state.format);
|
|
38
40
|
for (const path of result.missing) state.missing.add(path);
|
|
39
41
|
return result.text;
|
|
40
42
|
}
|
|
@@ -61,9 +63,10 @@ function systemUrl(
|
|
|
61
63
|
return isPresent(value) ? safeUrl(String(value)) : "";
|
|
62
64
|
}
|
|
63
65
|
|
|
64
|
-
|
|
66
|
+
/** A list cell: a money value or a date in it is written for the locale. */
|
|
67
|
+
function cell(item: unknown, path: string, state: ResolveState): string {
|
|
65
68
|
const value = getPath(item, path);
|
|
66
|
-
return isPresent(value) ?
|
|
69
|
+
return isPresent(value) ? formatValue(value, "", state.format) : "";
|
|
67
70
|
}
|
|
68
71
|
|
|
69
72
|
function listRows(block: ListBlock, state: ResolveState): ResolvedListRow[] {
|
|
@@ -73,8 +76,8 @@ function listRows(block: ListBlock, state: ResolveState): ResolvedListRow[] {
|
|
|
73
76
|
return [];
|
|
74
77
|
}
|
|
75
78
|
return source.map((item) => ({
|
|
76
|
-
label: cell(item, block.labelPath),
|
|
77
|
-
value: cell(item, block.valuePath),
|
|
79
|
+
label: cell(item, block.labelPath, state),
|
|
80
|
+
value: cell(item, block.valuePath, state),
|
|
78
81
|
}));
|
|
79
82
|
}
|
|
80
83
|
|
|
@@ -191,14 +194,22 @@ const isUserPath = (path: string): boolean =>
|
|
|
191
194
|
|
|
192
195
|
/**
|
|
193
196
|
* Resolves `content` against `data`. A real send passes `send` so a missing
|
|
194
|
-
* value leaves a blank; previews keep the token visible.
|
|
197
|
+
* value leaves a blank; previews keep the token visible. `format` writes the
|
|
198
|
+
* typed values (money, dates) for the e-mail's locale: see `valueFormatOf`.
|
|
195
199
|
*/
|
|
196
200
|
export function resolveEmail(
|
|
197
201
|
content: LocaleContent,
|
|
198
202
|
data: Data,
|
|
199
203
|
mode: InterpolationMode = "preview",
|
|
204
|
+
format?: ValueFormat,
|
|
200
205
|
): ResolvedEmail {
|
|
201
|
-
const state: ResolveState = {
|
|
206
|
+
const state: ResolveState = {
|
|
207
|
+
data,
|
|
208
|
+
mode,
|
|
209
|
+
missing: new Set(),
|
|
210
|
+
hidden: [],
|
|
211
|
+
format,
|
|
212
|
+
};
|
|
202
213
|
const subject = text(state, content.subject);
|
|
203
214
|
const preheader = text(state, content.preheader);
|
|
204
215
|
const blocks = resolveBlocks(content.blocks, state);
|
package/src/engine/tokens.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { formatValue, type ValueFormat } from "./formats";
|
|
1
2
|
import { getPath, isPresent } from "./paths";
|
|
2
3
|
|
|
3
4
|
export const TOKEN_PATTERN = /\{\{\s*([A-Za-z0-9_.[\]]+)\s*\}\}/g;
|
|
@@ -25,14 +26,15 @@ export function extractTokens(text: string): string[] {
|
|
|
25
26
|
return [...found];
|
|
26
27
|
}
|
|
27
28
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
}
|
|
31
|
-
|
|
29
|
+
/**
|
|
30
|
+
* Replaces each token of `text` with its value in `data`, typed values
|
|
31
|
+
* written for `format`'s locale (see {@link formatValue}).
|
|
32
|
+
*/
|
|
32
33
|
export function interpolate(
|
|
33
34
|
text: string,
|
|
34
35
|
data: Record<string, unknown>,
|
|
35
36
|
mode: InterpolationMode = "preview",
|
|
37
|
+
format?: ValueFormat,
|
|
36
38
|
): InterpolationResult {
|
|
37
39
|
const missing = new Set<string>();
|
|
38
40
|
const output = text.replace(TOKEN_PATTERN, (token, path: string) => {
|
|
@@ -41,7 +43,7 @@ export function interpolate(
|
|
|
41
43
|
missing.add(path);
|
|
42
44
|
return MISSING_TOKEN[mode](token);
|
|
43
45
|
}
|
|
44
|
-
return formatValue(value);
|
|
46
|
+
return formatValue(value, path, format);
|
|
45
47
|
});
|
|
46
48
|
return { text: output, missing: [...missing] };
|
|
47
49
|
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import type { AddFrontendModuleOptions } from "@antelopejs/interface-dms/page";
|
|
3
|
+
import {
|
|
4
|
+
API_BASE_PATH,
|
|
5
|
+
FRONTEND_MODULE_NAME,
|
|
6
|
+
WEBHOOK_SECRET_HEADER,
|
|
7
|
+
} from "./constants";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The provider webhook (`/events/:provider/:tenantId`), served through the
|
|
11
|
+
* dashboard's public URL: the front proxy lets it through without an
|
|
12
|
+
* `Origin`, never sends it a session and relays the secret header, which the
|
|
13
|
+
* route checks itself.
|
|
14
|
+
*/
|
|
15
|
+
const PROVIDER_WEBHOOK_ENDPOINT = {
|
|
16
|
+
path: `${API_BASE_PATH}/events/*/*`,
|
|
17
|
+
methods: ["POST"],
|
|
18
|
+
headers: [WEBHOOK_SECRET_HEADER],
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* This module's frontend, from its configuration alone: the backend registers
|
|
23
|
+
* it with `AddFrontendModule`, and `@antelopejs/dms-frontend` calls this same
|
|
24
|
+
* function (declared as `dmsFrontend` in package.json) to build and start the
|
|
25
|
+
* frontend without a backend. It reads nothing but its arguments.
|
|
26
|
+
*/
|
|
27
|
+
export function frontendModule(): AddFrontendModuleOptions {
|
|
28
|
+
return {
|
|
29
|
+
name: FRONTEND_MODULE_NAME,
|
|
30
|
+
sourcePath: path.join(__dirname, "../frontend-vue"),
|
|
31
|
+
renderer: { name: "vue", version: "3" },
|
|
32
|
+
options: { dmsMailing: {} },
|
|
33
|
+
priority: 0,
|
|
34
|
+
publicEndpoints: [PROVIDER_WEBHOOK_ENDPOINT],
|
|
35
|
+
};
|
|
36
|
+
}
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
} from "@antelopejs/interface-dms/hooks";
|
|
11
11
|
import { MODULE_ID } from "../constants";
|
|
12
12
|
import {
|
|
13
|
+
type MailingTemplate,
|
|
13
14
|
SettingsModel,
|
|
14
15
|
SuppressionModel,
|
|
15
16
|
TemplateModel,
|
|
@@ -98,6 +99,12 @@ async function loadPage<T extends object>(
|
|
|
98
99
|
.filter((row): row is T => row !== undefined);
|
|
99
100
|
}
|
|
100
101
|
|
|
102
|
+
/** A template as exported: its name in every language. */
|
|
103
|
+
const templateCodec: RowCodec<MailingTemplate> = {
|
|
104
|
+
fromDatabase: (row) =>
|
|
105
|
+
TemplateModel.fromDatabase(row)?.localize("*", ["name"]),
|
|
106
|
+
};
|
|
107
|
+
|
|
101
108
|
export function exportedCollections(tenantId: string): ExportedCollection[] {
|
|
102
109
|
const templates = GetModel(TemplateModel, tenantId);
|
|
103
110
|
const versions = GetModel(TemplateVersionModel, tenantId);
|
|
@@ -106,7 +113,7 @@ export function exportedCollections(tenantId: string): ExportedCollection[] {
|
|
|
106
113
|
{
|
|
107
114
|
entry: TEMPLATES_ENTRY,
|
|
108
115
|
load: (offset, size) =>
|
|
109
|
-
loadPage(templates.table,
|
|
116
|
+
loadPage(templates.table, templateCodec, offset, size),
|
|
110
117
|
},
|
|
111
118
|
{
|
|
112
119
|
entry: TEMPLATE_VERSIONS_ENTRY,
|