@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
package/src/routes/templates.ts
CHANGED
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
JSONBody,
|
|
6
6
|
Parameter,
|
|
7
7
|
Post,
|
|
8
|
+
Put,
|
|
8
9
|
type RequestContext,
|
|
9
10
|
} from "@antelopejs/interface-api";
|
|
10
11
|
import { assert, assertValidation } from "@antelopejs/interface-api-util";
|
|
@@ -22,12 +23,21 @@ import {
|
|
|
22
23
|
collectContentVariablePaths,
|
|
23
24
|
resolveEmail,
|
|
24
25
|
type TemplateChange,
|
|
26
|
+
valueFormatOf,
|
|
25
27
|
} from "../engine";
|
|
26
|
-
import {
|
|
28
|
+
import {
|
|
29
|
+
type MailingTemplate,
|
|
30
|
+
SendModel,
|
|
31
|
+
type TemplateFields,
|
|
32
|
+
TemplateModel,
|
|
33
|
+
} from "../db";
|
|
34
|
+
import { saveDetails } from "../data/templates-table";
|
|
27
35
|
import { TemplatesPageController } from "../pages/templates";
|
|
28
36
|
import { forAudience, statsWindowStart } from "../services/metrics";
|
|
29
37
|
import { refreshTemplateAttention } from "../services/template-attention";
|
|
30
38
|
import { renderEmailHtml } from "../services/render";
|
|
39
|
+
import { categoriesIn } from "../services/categories";
|
|
40
|
+
import { localeOfRequest, withTextIn } from "../services/localized-text";
|
|
31
41
|
import { getSettings } from "../services/settings";
|
|
32
42
|
import { findStarter, STARTER_TEMPLATES } from "../services/starters";
|
|
33
43
|
import {
|
|
@@ -39,7 +49,6 @@ import {
|
|
|
39
49
|
import {
|
|
40
50
|
assertSlugClaimed,
|
|
41
51
|
contentFieldsOf,
|
|
42
|
-
contentOfVersion,
|
|
43
52
|
discardDraft,
|
|
44
53
|
displayName,
|
|
45
54
|
hasDraft,
|
|
@@ -55,13 +64,17 @@ import {
|
|
|
55
64
|
publishedVersionOf,
|
|
56
65
|
publishTemplate,
|
|
57
66
|
saveDraft,
|
|
67
|
+
publishedSnapshot,
|
|
68
|
+
snapshotOfVersion,
|
|
69
|
+
type TemplateDraft,
|
|
70
|
+
workingSnapshot,
|
|
58
71
|
workingContent,
|
|
59
72
|
workingVariables,
|
|
60
73
|
requireTemplate,
|
|
61
74
|
} from "../services/templates";
|
|
62
75
|
import {
|
|
63
76
|
BUSINESS_AUDIENCE,
|
|
64
|
-
type
|
|
77
|
+
type CategoryInLocale,
|
|
65
78
|
type TemplateContent,
|
|
66
79
|
type TemplateStatus,
|
|
67
80
|
type VariableDefinition,
|
|
@@ -104,14 +117,13 @@ const TARGET_STATUS: Record<
|
|
|
104
117
|
};
|
|
105
118
|
|
|
106
119
|
/** What a lifecycle action records besides the new status. */
|
|
107
|
-
const TRANSITION_FIELDS: Partial<
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
};
|
|
120
|
+
const TRANSITION_FIELDS: Partial<Record<TemplateAction, () => TemplateFields>> =
|
|
121
|
+
{
|
|
122
|
+
unpublish: () => ({ unpublishedAt: new Date() }),
|
|
123
|
+
};
|
|
112
124
|
|
|
113
125
|
export interface CategoriesResponse {
|
|
114
|
-
categories:
|
|
126
|
+
categories: CategoryInLocale[];
|
|
115
127
|
}
|
|
116
128
|
|
|
117
129
|
export interface TemplateSender {
|
|
@@ -121,6 +133,7 @@ export interface TemplateSender {
|
|
|
121
133
|
}
|
|
122
134
|
|
|
123
135
|
export interface TemplateContentResponse {
|
|
136
|
+
/** The template, its name in the reader's language. */
|
|
124
137
|
template: MailingTemplate;
|
|
125
138
|
/** The content the editor works on: the draft, else the published one. */
|
|
126
139
|
content: TemplateContent;
|
|
@@ -146,7 +159,7 @@ export interface TemplateContentResponse {
|
|
|
146
159
|
fallbackLocale: string;
|
|
147
160
|
/** Whether a send missing a variable fails rather than goes out blank. */
|
|
148
161
|
blockOnMissingVariables: boolean;
|
|
149
|
-
categories:
|
|
162
|
+
categories: CategoryInLocale[];
|
|
150
163
|
sender: TemplateSender;
|
|
151
164
|
}
|
|
152
165
|
|
|
@@ -192,6 +205,11 @@ export class TemplatesController extends Controller(
|
|
|
192
205
|
return displayName(this.user);
|
|
193
206
|
}
|
|
194
207
|
|
|
208
|
+
/** The language the reader reads names and labels in. */
|
|
209
|
+
private get locale(): string {
|
|
210
|
+
return localeOfRequest(this.ctx);
|
|
211
|
+
}
|
|
212
|
+
|
|
195
213
|
/**
|
|
196
214
|
* The tenant's categories for a category picker. Separate from `/settings`,
|
|
197
215
|
* which is guarded by the settings page permission a template editor need
|
|
@@ -200,7 +218,7 @@ export class TemplatesController extends Controller(
|
|
|
200
218
|
@Get("/categories")
|
|
201
219
|
async categories(): Promise<CategoriesResponse> {
|
|
202
220
|
const settings = await getSettings(this.tenantId);
|
|
203
|
-
return { categories: settings.categories };
|
|
221
|
+
return { categories: categoriesIn(settings.categories, this.locale) };
|
|
204
222
|
}
|
|
205
223
|
|
|
206
224
|
@Get("/starters")
|
|
@@ -236,7 +254,12 @@ export class TemplatesController extends Controller(
|
|
|
236
254
|
);
|
|
237
255
|
const localeContent = localeContentOf(starter.content, locale);
|
|
238
256
|
assert(localeContent, HTTP_UNPROCESSABLE, INVALID_CONTENT);
|
|
239
|
-
const email = resolveEmail(
|
|
257
|
+
const email = resolveEmail(
|
|
258
|
+
localeContent,
|
|
259
|
+
starter.testDataFor(locale),
|
|
260
|
+
undefined,
|
|
261
|
+
valueFormatOf(locale, starter.variables),
|
|
262
|
+
);
|
|
240
263
|
return { html: await renderEmailHtml(email, locale), locale };
|
|
241
264
|
}
|
|
242
265
|
|
|
@@ -289,7 +312,7 @@ export class TemplatesController extends Controller(
|
|
|
289
312
|
? parseContent(template)
|
|
290
313
|
: null;
|
|
291
314
|
return {
|
|
292
|
-
template,
|
|
315
|
+
template: template.localize(this.locale, ["name"]),
|
|
293
316
|
content,
|
|
294
317
|
publishedContent,
|
|
295
318
|
hasDraft: hasDraft(template),
|
|
@@ -305,7 +328,7 @@ export class TemplatesController extends Controller(
|
|
|
305
328
|
testData: parseTestData(template),
|
|
306
329
|
fallbackLocale: settings.fallbackLocale,
|
|
307
330
|
blockOnMissingVariables: settings.blockOnMissingVariables,
|
|
308
|
-
categories: settings.categories,
|
|
331
|
+
categories: categoriesIn(settings.categories, this.locale),
|
|
309
332
|
sender: {
|
|
310
333
|
name: settings.senderName,
|
|
311
334
|
email: settings.senderEmail,
|
|
@@ -431,7 +454,7 @@ export class TemplatesController extends Controller(
|
|
|
431
454
|
) {
|
|
432
455
|
await this.requireTransition(id, action);
|
|
433
456
|
const status = TARGET_STATUS[action];
|
|
434
|
-
await this.templates.
|
|
457
|
+
await this.templates.updateFields(id, {
|
|
435
458
|
status,
|
|
436
459
|
updatedBy: this.author,
|
|
437
460
|
...TRANSITION_FIELDS[action]?.(),
|
|
@@ -472,7 +495,9 @@ export class TemplatesController extends Controller(
|
|
|
472
495
|
testDataSchema.parse(value),
|
|
473
496
|
);
|
|
474
497
|
await requireTemplate(this.templates, id);
|
|
475
|
-
await this.templates.
|
|
498
|
+
await this.templates.updateFields(id, {
|
|
499
|
+
json_test_data: JSON.stringify(data),
|
|
500
|
+
});
|
|
476
501
|
return { data };
|
|
477
502
|
}
|
|
478
503
|
|
|
@@ -487,9 +512,12 @@ export class TemplatesController extends Controller(
|
|
|
487
512
|
() => INVALID_CONTENT,
|
|
488
513
|
);
|
|
489
514
|
const template = await requireTemplate(this.templates, id);
|
|
490
|
-
const content =
|
|
491
|
-
|
|
492
|
-
|
|
515
|
+
const { content, variables } = input.content
|
|
516
|
+
? {
|
|
517
|
+
...workingSnapshot(template),
|
|
518
|
+
content: input.content as TemplateContent,
|
|
519
|
+
}
|
|
520
|
+
: await this.previewSnapshot(template, input.version);
|
|
493
521
|
const settings = await getSettings(this.tenantId);
|
|
494
522
|
const locale = pickLocale(content, input.locale, settings.fallbackLocale);
|
|
495
523
|
const localeContent = localeContentOf(content, locale);
|
|
@@ -497,6 +525,8 @@ export class TemplatesController extends Controller(
|
|
|
497
525
|
const email = resolveEmail(
|
|
498
526
|
localeContent,
|
|
499
527
|
input.data ?? parseTestData(template),
|
|
528
|
+
undefined,
|
|
529
|
+
valueFormatOf(locale, variables),
|
|
500
530
|
);
|
|
501
531
|
const html = await renderEmailHtml(email, locale);
|
|
502
532
|
return {
|
|
@@ -508,14 +538,30 @@ export class TemplatesController extends Controller(
|
|
|
508
538
|
};
|
|
509
539
|
}
|
|
510
540
|
|
|
511
|
-
private
|
|
541
|
+
private previewSnapshot(
|
|
512
542
|
template: MailingTemplate,
|
|
513
543
|
version: "draft" | "published" | number | undefined,
|
|
514
|
-
): Promise<
|
|
515
|
-
if (version === "published") return
|
|
544
|
+
): Promise<TemplateDraft> | TemplateDraft {
|
|
545
|
+
if (version === "published") return publishedSnapshot(template);
|
|
516
546
|
if (typeof version === "number")
|
|
517
|
-
return
|
|
518
|
-
return
|
|
547
|
+
return snapshotOfVersion(this.tenantId, template, version);
|
|
548
|
+
return workingSnapshot(template);
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/**
|
|
552
|
+
* Renames a template in the reader's language, its other languages kept,
|
|
553
|
+
* and sets its category: what the module's drawer and editor save, each
|
|
554
|
+
* showing one language. The DMS edit form, which shows every language,
|
|
555
|
+
* writes through the table's edit route.
|
|
556
|
+
*/
|
|
557
|
+
@Put("/:id/details")
|
|
558
|
+
async details(
|
|
559
|
+
@Parameter("id", "param") id: string,
|
|
560
|
+
@JSONBody() body: unknown,
|
|
561
|
+
) {
|
|
562
|
+
const template = await requireTemplate(this.templates, id);
|
|
563
|
+
await saveDetails(this.ctx, template, body, this.author);
|
|
564
|
+
return { id };
|
|
519
565
|
}
|
|
520
566
|
|
|
521
567
|
@Post("/:id/duplicate")
|
|
@@ -529,9 +575,9 @@ export class TemplatesController extends Controller(
|
|
|
529
575
|
const source = await requireTemplate(this.templates, id);
|
|
530
576
|
const clash = await this.templates.getBySlug(slug);
|
|
531
577
|
assert(!clash, HTTP_CONFLICT, DUPLICATE_SLUG);
|
|
532
|
-
const [copyId] = await this.templates.
|
|
578
|
+
const [copyId] = await this.templates.insertFields({
|
|
533
579
|
slug,
|
|
534
|
-
name,
|
|
580
|
+
name: withTextIn(undefined, this.locale, name),
|
|
535
581
|
category: source.category,
|
|
536
582
|
status: "draft",
|
|
537
583
|
...contentFieldsOf(source),
|
|
@@ -1,6 +1,19 @@
|
|
|
1
1
|
import type { MailingTemplate } from "../db";
|
|
2
|
-
import type { TemplateCategory } from "../types";
|
|
2
|
+
import type { CategoryInLocale, TemplateCategory } from "../types";
|
|
3
3
|
import type { CategoryEntry } from "../validation/settings.schema";
|
|
4
|
+
import { textIn } from "./localized-text";
|
|
5
|
+
|
|
6
|
+
/** The categories as a reader of `locale` sees them. */
|
|
7
|
+
export function categoriesIn(
|
|
8
|
+
categories: TemplateCategory[],
|
|
9
|
+
locale: string,
|
|
10
|
+
): CategoryInLocale[] {
|
|
11
|
+
return categories.map(({ id, label, icon }) => ({
|
|
12
|
+
id,
|
|
13
|
+
label: textIn(label, locale),
|
|
14
|
+
icon,
|
|
15
|
+
}));
|
|
16
|
+
}
|
|
4
17
|
|
|
5
18
|
/** The categories a list keeps, without the removed ones it moves away. */
|
|
6
19
|
export function keptCategories(entries: CategoryEntry[]): TemplateCategory[] {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import type {
|
|
3
3
|
DeclarationOutcome,
|
|
4
|
+
LocalizedText,
|
|
4
5
|
DeclareTemplateCategoryResult,
|
|
5
6
|
DeclareTemplateResult,
|
|
6
7
|
TemplateCategoryDeclaration,
|
|
@@ -55,7 +56,7 @@ const MAX_DECLARATION_ATTEMPTS = 5;
|
|
|
55
56
|
|
|
56
57
|
/** What a declaration fingerprints: everything a user may change after it. */
|
|
57
58
|
interface DeclaredTemplateState {
|
|
58
|
-
name:
|
|
59
|
+
name: LocalizedText;
|
|
59
60
|
category: string;
|
|
60
61
|
content: TemplateContent;
|
|
61
62
|
variables: VariableDefinition[];
|
|
@@ -131,7 +132,7 @@ function planTemplate(declaration: ParsedTemplateDeclaration): TemplatePlan {
|
|
|
131
132
|
function currentFingerprint(template: MailingTemplate): string | null {
|
|
132
133
|
if (template.status !== LIVE_STATUS || hasDraft(template)) return null;
|
|
133
134
|
return fingerprintOf({
|
|
134
|
-
name: template
|
|
135
|
+
name: TemplateModel.namesOf(template),
|
|
135
136
|
category: template.category ?? "",
|
|
136
137
|
content: parseContent(template),
|
|
137
138
|
variables: parseVariables(template),
|
|
@@ -165,7 +166,7 @@ async function createDeclared(
|
|
|
165
166
|
const settings = await getSettings(tenantId);
|
|
166
167
|
assertKnownCategory(settings.categories, state.category, slug);
|
|
167
168
|
const revision = randomUUID();
|
|
168
|
-
const [id] = await model.
|
|
169
|
+
const [id] = await model.insertFields({
|
|
169
170
|
slug,
|
|
170
171
|
name: state.name,
|
|
171
172
|
category: state.category,
|
package/src/services/events.ts
CHANGED
|
@@ -135,7 +135,7 @@ async function publishEmailEvent(
|
|
|
135
135
|
send: MailingSend,
|
|
136
136
|
): Promise<void> {
|
|
137
137
|
emitEmailEvent(payload);
|
|
138
|
-
publishSendUpdated(payload.sendId);
|
|
138
|
+
publishSendUpdated(payload.tenantId, payload.sendId);
|
|
139
139
|
await notifyProblems(payload.tenantId, {
|
|
140
140
|
sendId: send._id,
|
|
141
141
|
templateSlug: send.templateSlug,
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { RequestContext } from "@antelopejs/interface-api";
|
|
2
|
+
import {
|
|
3
|
+
LOCALIZED_TEXT_FALLBACK_LOCALE,
|
|
4
|
+
type LocalizedText,
|
|
5
|
+
} from "@antelopejs/interface-dms-mailing";
|
|
6
|
+
|
|
7
|
+
/** Header the DMS frontend states the reader's language in on every request. */
|
|
8
|
+
export const CONTENT_LANGUAGE_HEADER = "x-content-language";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The language to read localized texts in: the header's, else the one every
|
|
12
|
+
* localized text falls back to.
|
|
13
|
+
*
|
|
14
|
+
* @param header Value of {@link CONTENT_LANGUAGE_HEADER}
|
|
15
|
+
*/
|
|
16
|
+
export function requestLocale(header: unknown): string {
|
|
17
|
+
return typeof header === "string" && header.length > 0
|
|
18
|
+
? header
|
|
19
|
+
: LOCALIZED_TEXT_FALLBACK_LOCALE;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** The language a request reads and writes localized texts in. */
|
|
23
|
+
export function localeOfRequest(ctx: RequestContext): string {
|
|
24
|
+
return requestLocale(ctx.rawRequest.headers[CONTENT_LANGUAGE_HEADER]);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* A localized text in `locale`, else in the fallback language, as
|
|
29
|
+
* `@Localized({ fallbackLocale })` reads a table field.
|
|
30
|
+
*/
|
|
31
|
+
export function textIn(
|
|
32
|
+
text: LocalizedText | undefined,
|
|
33
|
+
locale: string,
|
|
34
|
+
): string {
|
|
35
|
+
return text?.[locale] ?? text?.[LOCALIZED_TEXT_FALLBACK_LOCALE] ?? "";
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* `text` with `value` written in `locale`. The fallback language gets the
|
|
40
|
+
* same value while it has none, so a text first written in another language
|
|
41
|
+
* still reads in every language.
|
|
42
|
+
*/
|
|
43
|
+
export function withTextIn(
|
|
44
|
+
text: LocalizedText | undefined,
|
|
45
|
+
locale: string,
|
|
46
|
+
value: string,
|
|
47
|
+
): LocalizedText {
|
|
48
|
+
return {
|
|
49
|
+
[LOCALIZED_TEXT_FALLBACK_LOCALE]: value,
|
|
50
|
+
...text,
|
|
51
|
+
[locale]: value,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
@@ -3,6 +3,7 @@ import type {
|
|
|
3
3
|
BlockText,
|
|
4
4
|
ComposedText,
|
|
5
5
|
} from "@antelopejs/interface-dms/base/types";
|
|
6
|
+
import { deltaPercent } from "./metrics";
|
|
6
7
|
import type { SendStats } from "./send-stats";
|
|
7
8
|
|
|
8
9
|
/** Past this wait the oldest queued send is worth a warning. */
|
|
@@ -39,11 +40,24 @@ function joined(parts: ComposedText[], fallback: ComposedText): BlockText {
|
|
|
39
40
|
return parts.reduce((left, right) => text("joined", { left, right }));
|
|
40
41
|
}
|
|
41
42
|
|
|
42
|
-
|
|
43
|
-
|
|
43
|
+
/** A figure of the comparison window, as its cell writes it. */
|
|
44
|
+
type ComparedValue = NonNullable<ComposedText["params"]>[string];
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The change and the figure it is measured against ("▲ 8 % vs 120"), or the
|
|
48
|
+
* figure alone when no change can be stated (an equal or a zero figure).
|
|
49
|
+
* Nothing when the comparison is off or the window has no such figure.
|
|
50
|
+
*/
|
|
51
|
+
function comparedText(
|
|
52
|
+
delta: number | null,
|
|
53
|
+
previous: ComparedValue | null,
|
|
54
|
+
): ComposedText[] {
|
|
55
|
+
if (previous === null) return [];
|
|
56
|
+
if (!delta) return [text("versus", { previous })];
|
|
44
57
|
return [
|
|
45
|
-
text(delta > 0 ? "
|
|
58
|
+
text(delta > 0 ? "delta_up_versus" : "delta_down_versus", {
|
|
46
59
|
delta: percent(Math.abs(delta)),
|
|
60
|
+
previous,
|
|
47
61
|
}),
|
|
48
62
|
];
|
|
49
63
|
}
|
|
@@ -52,18 +66,29 @@ function sendsItem(stats: SendStats): StatGroupItem {
|
|
|
52
66
|
const tests = stats.tests
|
|
53
67
|
? [text("tests_excluded", { count: count(stats.tests) })]
|
|
54
68
|
: [];
|
|
69
|
+
const previous = stats.compared?.sends ?? null;
|
|
55
70
|
return {
|
|
56
71
|
id: "sends",
|
|
57
72
|
icon: "i-ph-paper-plane-tilt",
|
|
58
73
|
eyebrow: key("sends"),
|
|
59
74
|
value: stats.sends,
|
|
60
75
|
detail: joined(
|
|
61
|
-
[...
|
|
76
|
+
[...comparedText(stats.sendsDelta, previous), ...tests],
|
|
62
77
|
text("no_tests"),
|
|
63
78
|
),
|
|
64
79
|
};
|
|
65
80
|
}
|
|
66
81
|
|
|
82
|
+
/** The deliverability's change; none without a figure on either side. */
|
|
83
|
+
function deliverabilityCompared(stats: SendStats): ComposedText[] {
|
|
84
|
+
const previous = stats.compared?.deliverability ?? null;
|
|
85
|
+
if (!stats.sends || previous === null) return [];
|
|
86
|
+
return comparedText(
|
|
87
|
+
deltaPercent(stats.deliverability, previous),
|
|
88
|
+
percent(previous),
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
67
92
|
function deliveredItem(stats: SendStats, provider: string): StatGroupItem {
|
|
68
93
|
if (stats.isDeliveryUntracked) {
|
|
69
94
|
return {
|
|
@@ -74,6 +99,10 @@ function deliveredItem(stats: SendStats, provider: string): StatGroupItem {
|
|
|
74
99
|
detail: text("not_tracked", { provider: provider || THE_PROVIDER }),
|
|
75
100
|
};
|
|
76
101
|
}
|
|
102
|
+
const deliveredOf = text("delivered_of", {
|
|
103
|
+
delivered: stats.delivered,
|
|
104
|
+
sends: stats.sends,
|
|
105
|
+
});
|
|
77
106
|
return {
|
|
78
107
|
id: "delivered",
|
|
79
108
|
icon: "i-ph-check-circle",
|
|
@@ -82,10 +111,10 @@ function deliveredItem(stats: SendStats, provider: string): StatGroupItem {
|
|
|
82
111
|
stats.sends && stats.deliverability !== null
|
|
83
112
|
? text("rate", { rate: percent(stats.deliverability) })
|
|
84
113
|
: EMPTY_FIGURE,
|
|
85
|
-
detail:
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
114
|
+
detail: joined(
|
|
115
|
+
[...deliverabilityCompared(stats), deliveredOf],
|
|
116
|
+
deliveredOf,
|
|
117
|
+
),
|
|
89
118
|
};
|
|
90
119
|
}
|
|
91
120
|
|
|
@@ -93,13 +122,19 @@ function problemsItem(stats: SendStats): StatGroupItem {
|
|
|
93
122
|
const parts = PROBLEM_PARTS.filter((part) => stats[part]).map((part) =>
|
|
94
123
|
text(`breakdown.${part}`, { count: count(stats[part]) }),
|
|
95
124
|
);
|
|
125
|
+
const previous = stats.compared?.problems ?? null;
|
|
126
|
+
const compared = comparedText(
|
|
127
|
+
deltaPercent(stats.problems, previous),
|
|
128
|
+
previous,
|
|
129
|
+
);
|
|
130
|
+
const breakdown = parts.length ? parts : [text("no_problems")];
|
|
96
131
|
return {
|
|
97
132
|
id: "problems",
|
|
98
133
|
icon: "i-ph-warning-octagon",
|
|
99
134
|
tone: stats.problems ? "error" : undefined,
|
|
100
135
|
eyebrow: key("problems"),
|
|
101
136
|
value: stats.problems,
|
|
102
|
-
detail: joined(
|
|
137
|
+
detail: joined([...compared, ...breakdown], text("no_problems")),
|
|
103
138
|
to: stats.problems ? PROBLEMS_PAGE : undefined,
|
|
104
139
|
};
|
|
105
140
|
}
|
|
@@ -127,15 +162,26 @@ export function latencyText(ms: number): ComposedText {
|
|
|
127
162
|
});
|
|
128
163
|
}
|
|
129
164
|
|
|
165
|
+
/** The latency's change; none without a timed send on either side. */
|
|
166
|
+
function latencyCompared(stats: SendStats): ComposedText[] {
|
|
167
|
+
const previous = stats.compared?.latencyMedian ?? null;
|
|
168
|
+
if (!stats.sends || previous === null) return [];
|
|
169
|
+
return comparedText(
|
|
170
|
+
deltaPercent(stats.latencyMedian, previous),
|
|
171
|
+
latencyText(previous),
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
|
|
130
175
|
function latencyItem(stats: SendStats, provider: string): StatGroupItem {
|
|
176
|
+
const call = provider
|
|
177
|
+
? text("latency_detail", { provider })
|
|
178
|
+
: text("latency_detail_generic");
|
|
131
179
|
return {
|
|
132
180
|
id: "latency",
|
|
133
181
|
icon: "i-ph-timer",
|
|
134
182
|
eyebrow: key("latency"),
|
|
135
183
|
value: stats.sends ? latencyText(stats.latencyMedian) : EMPTY_FIGURE,
|
|
136
|
-
detail:
|
|
137
|
-
? text("latency_detail", { provider })
|
|
138
|
-
: text("latency_detail_generic"),
|
|
184
|
+
detail: joined([...latencyCompared(stats), call], call),
|
|
139
185
|
};
|
|
140
186
|
}
|
|
141
187
|
|
|
@@ -143,7 +189,8 @@ function latencyItem(stats: SendStats, provider: string): StatGroupItem {
|
|
|
143
189
|
* The five cells of the send log's stat strip, as `StatGroup` items whose
|
|
144
190
|
* texts the DMS composes in the reader's language: sends with the tests left
|
|
145
191
|
* out, deliverability, problems with their breakdown, the queue with its
|
|
146
|
-
* oldest wait, and the median API latency.
|
|
192
|
+
* oldest wait, and the median API latency. Every cell but the queue, which is
|
|
193
|
+
* what waits now, states its change against the comparison window.
|
|
147
194
|
*/
|
|
148
195
|
export function buildSendStatItems(
|
|
149
196
|
stats: SendStats,
|
|
@@ -35,6 +35,21 @@ export interface SendStats {
|
|
|
35
35
|
/** When the oldest send still queued was made; `null` with none queued. */
|
|
36
36
|
oldestQueuedAt: string | null;
|
|
37
37
|
latencyMedian: number;
|
|
38
|
+
/** The same figures over the comparison window; `null` with it turned off. */
|
|
39
|
+
compared: ComparedSendStats | null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The figures of the strip a comparison window states. The queue is left out:
|
|
44
|
+
* it is what waits now, and what waited back then has long left.
|
|
45
|
+
*/
|
|
46
|
+
export interface ComparedSendStats {
|
|
47
|
+
sends: number;
|
|
48
|
+
/** `null` without a real send, or when nothing reported deliveries. */
|
|
49
|
+
deliverability: number | null;
|
|
50
|
+
problems: number;
|
|
51
|
+
/** `null` without a real send to time. */
|
|
52
|
+
latencyMedian: number | null;
|
|
38
53
|
}
|
|
39
54
|
|
|
40
55
|
const isQueued = (send: SendSummary): boolean => send.status === "queued";
|
|
@@ -74,20 +89,36 @@ function problemBreakdown(real: SendSummary[]): ProblemBreakdown {
|
|
|
74
89
|
return { problems: bounced + failed + spam, bounced, failed, spam };
|
|
75
90
|
}
|
|
76
91
|
|
|
92
|
+
const isRealSend = (send: SendSummary): boolean => !send.isTest;
|
|
93
|
+
|
|
94
|
+
function comparedStats(
|
|
95
|
+
previous: SendSummary[],
|
|
96
|
+
tracking: ProviderTracking | null,
|
|
97
|
+
): ComparedSendStats {
|
|
98
|
+
const real = previous.filter(isRealSend);
|
|
99
|
+
const totals = aggregate(real);
|
|
100
|
+
return {
|
|
101
|
+
sends: totals.sends,
|
|
102
|
+
deliverability: isDeliveryUntracked(real, tracking)
|
|
103
|
+
? null
|
|
104
|
+
: totals.deliverability,
|
|
105
|
+
problems: problemBreakdown(real).problems,
|
|
106
|
+
latencyMedian: totals.sends ? totals.latencyMedian : null,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
77
110
|
/** `previous` is `null` when the user turned the comparison off. */
|
|
78
111
|
export function buildSendStats(
|
|
79
112
|
current: SendSummary[],
|
|
80
113
|
previous: SendSummary[] | null,
|
|
81
114
|
tracking: ProviderTracking | null = null,
|
|
82
115
|
): SendStats {
|
|
83
|
-
const real = current.filter(
|
|
116
|
+
const real = current.filter(isRealSend);
|
|
84
117
|
const totals = aggregate(real);
|
|
85
|
-
const
|
|
86
|
-
? aggregate(previous.filter((send) => !send.isTest))
|
|
87
|
-
: null;
|
|
118
|
+
const compared = previous ? comparedStats(previous, tracking) : null;
|
|
88
119
|
return {
|
|
89
120
|
sends: totals.sends,
|
|
90
|
-
sendsDelta: deltaPercent(totals.sends,
|
|
121
|
+
sendsDelta: deltaPercent(totals.sends, compared?.sends ?? null),
|
|
91
122
|
tests: current.length - real.length,
|
|
92
123
|
delivered: totals.delivered,
|
|
93
124
|
deliverability: totals.deliverability,
|
|
@@ -96,6 +127,7 @@ export function buildSendStats(
|
|
|
96
127
|
queued: totals.queued,
|
|
97
128
|
oldestQueuedAt: oldestQueuedAt(real),
|
|
98
129
|
latencyMedian: totals.latencyMedian,
|
|
130
|
+
compared,
|
|
99
131
|
};
|
|
100
132
|
}
|
|
101
133
|
|
package/src/services/send.ts
CHANGED
|
@@ -22,6 +22,8 @@ import {
|
|
|
22
22
|
missingSystemVariables,
|
|
23
23
|
renderPlainText,
|
|
24
24
|
resolveEmail,
|
|
25
|
+
type ValueFormat,
|
|
26
|
+
valueFormatOf,
|
|
25
27
|
} from "../engine";
|
|
26
28
|
import type {
|
|
27
29
|
SendTemplateParams,
|
|
@@ -43,11 +45,12 @@ import { refreshAttentionIfFlagged } from "./template-attention";
|
|
|
43
45
|
import {
|
|
44
46
|
hasDraft,
|
|
45
47
|
localeContentOf,
|
|
46
|
-
parseContent,
|
|
47
48
|
parseVariables,
|
|
48
49
|
pickLocale,
|
|
50
|
+
publishedSnapshot,
|
|
49
51
|
publishedVersionOf,
|
|
50
|
-
|
|
52
|
+
type VersionedSnapshot,
|
|
53
|
+
workingSnapshot,
|
|
51
54
|
} from "./templates";
|
|
52
55
|
|
|
53
56
|
export class SendError extends Error {
|
|
@@ -66,6 +69,8 @@ export interface PreparedSend {
|
|
|
66
69
|
requestedLocale: string;
|
|
67
70
|
/** Template version the content belongs to; 0 for a draft. */
|
|
68
71
|
version: number;
|
|
72
|
+
/** How its typed values are written: the locale, the version's `date` paths. */
|
|
73
|
+
format: ValueFormat;
|
|
69
74
|
templateId: string;
|
|
70
75
|
slug: string;
|
|
71
76
|
tenantId: string;
|
|
@@ -165,11 +170,6 @@ const nameOf = (to: EmailAddress): string | undefined =>
|
|
|
165
170
|
const toRecipients = (to: EmailAddress | EmailAddress[]): EmailAddress[] =>
|
|
166
171
|
Array.isArray(to) ? to : [to];
|
|
167
172
|
|
|
168
|
-
interface ResolvedContent {
|
|
169
|
-
content: TemplateContent;
|
|
170
|
-
version: number;
|
|
171
|
-
}
|
|
172
|
-
|
|
173
173
|
/**
|
|
174
174
|
* The content a send renders and the version it belongs to. A real send gets
|
|
175
175
|
* what customers receive; a test send gets the draft when there is one, so a
|
|
@@ -178,20 +178,17 @@ interface ResolvedContent {
|
|
|
178
178
|
function resolveContent(
|
|
179
179
|
template: MailingTemplate,
|
|
180
180
|
params: ModuleSendParams,
|
|
181
|
-
):
|
|
181
|
+
): VersionedSnapshot {
|
|
182
182
|
if (params.content)
|
|
183
183
|
return {
|
|
184
|
+
...workingSnapshot(template),
|
|
184
185
|
content: params.content,
|
|
185
186
|
version: params.version ?? DRAFT_VERSION,
|
|
186
187
|
};
|
|
187
|
-
if (params.isTest && hasDraft(template))
|
|
188
|
-
return {
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
return {
|
|
192
|
-
content: parseContent(template),
|
|
193
|
-
version: publishedVersionOf(template),
|
|
194
|
-
};
|
|
188
|
+
if (params.isTest && (hasDraft(template) || !publishedVersionOf(template)))
|
|
189
|
+
return { ...workingSnapshot(template), version: DRAFT_VERSION };
|
|
190
|
+
const version = publishedVersionOf(template);
|
|
191
|
+
return { ...publishedSnapshot(template), version };
|
|
195
192
|
}
|
|
196
193
|
|
|
197
194
|
const isManualRealSend = (params: ModuleSendParams): boolean =>
|
|
@@ -247,7 +244,7 @@ async function renderForSend(
|
|
|
247
244
|
);
|
|
248
245
|
}
|
|
249
246
|
const variables = params.variables ?? {};
|
|
250
|
-
const email = resolveEmail(localeContent, variables, "send");
|
|
247
|
+
const email = resolveEmail(localeContent, variables, "send", prepared.format);
|
|
251
248
|
const refused = refusedMissing(
|
|
252
249
|
params,
|
|
253
250
|
isBlocking,
|
|
@@ -279,13 +276,15 @@ export async function prepare(
|
|
|
279
276
|
if (missing.length) {
|
|
280
277
|
throw new SendError("missing_variables", missing.join(", "));
|
|
281
278
|
}
|
|
282
|
-
const { content, version } = resolveContent(template, params);
|
|
279
|
+
const { content, version, variables } = resolveContent(template, params);
|
|
283
280
|
const settings = await getSettings(tenantId);
|
|
281
|
+
const locale = pickLocale(content, params.locale, settings.fallbackLocale);
|
|
284
282
|
return {
|
|
285
283
|
content,
|
|
286
|
-
locale
|
|
284
|
+
locale,
|
|
287
285
|
requestedLocale: params.locale ?? "",
|
|
288
286
|
version,
|
|
287
|
+
format: valueFormatOf(locale, variables),
|
|
289
288
|
templateId: template._id,
|
|
290
289
|
slug,
|
|
291
290
|
tenantId,
|
|
@@ -476,7 +475,7 @@ async function record(
|
|
|
476
475
|
outcome.error ? { reason: outcome.error } : {},
|
|
477
476
|
),
|
|
478
477
|
});
|
|
479
|
-
publishSendCreated(sendId as string);
|
|
478
|
+
publishSendCreated(tenantId, sendId as string);
|
|
480
479
|
return sendId as string;
|
|
481
480
|
}
|
|
482
481
|
|