@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/metrics.ts
CHANGED
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
import { OverviewPageController } from "../pages/overview";
|
|
24
24
|
import { readCapabilities } from "../services/provider";
|
|
25
25
|
import { trackingOf } from "../services/send-stats";
|
|
26
|
+
import { localeOfRequest } from "../services/localized-text";
|
|
26
27
|
import { findSettings } from "../services/settings";
|
|
27
28
|
import { attentionFeedItem, buildAttentionItems } from "../services/attention";
|
|
28
29
|
import {
|
|
@@ -44,7 +45,12 @@ import {
|
|
|
44
45
|
type TotalsExtractor,
|
|
45
46
|
} from "../services/metrics";
|
|
46
47
|
import { SEND_AUDIENCE_QUERY_KEY, type SendAudience } from "../types";
|
|
47
|
-
import {
|
|
48
|
+
import {
|
|
49
|
+
comparisonLabel,
|
|
50
|
+
comparisonRange,
|
|
51
|
+
currentRange,
|
|
52
|
+
isComparisonWanted,
|
|
53
|
+
} from "./period";
|
|
48
54
|
|
|
49
55
|
const UNKNOWN_METRIC = "$dms_mailing.errors.unknown_metric";
|
|
50
56
|
const TEMPLATES_PATH = "/modules/mailing/templates";
|
|
@@ -140,12 +146,18 @@ export interface FunnelStepResponse {
|
|
|
140
146
|
rate: number | null;
|
|
141
147
|
/** `false` when nothing reports the events the step counts. */
|
|
142
148
|
isMeasured: boolean;
|
|
149
|
+
/** The step's count over the comparison window, when both measure it. */
|
|
150
|
+
previousCount?: number;
|
|
151
|
+
/** The change of the count, in percent; absent from an empty comparison. */
|
|
152
|
+
delta?: number;
|
|
143
153
|
}
|
|
144
154
|
|
|
145
155
|
/** The sends of the window the figures are compared with. */
|
|
146
156
|
export interface ComparedWindow {
|
|
147
157
|
range: DateRange;
|
|
148
158
|
sends: SendSummary[];
|
|
159
|
+
/** What the comparison series is called: the previous period, year… */
|
|
160
|
+
label: string;
|
|
149
161
|
}
|
|
150
162
|
|
|
151
163
|
/** What the Overview's first-run checklist reads. */
|
|
@@ -173,6 +185,13 @@ export class MetricsController extends Controller(`${API_BASE_PATH}/metrics`) {
|
|
|
173
185
|
return getRequestTenantId(this.ctx);
|
|
174
186
|
}
|
|
175
187
|
|
|
188
|
+
/** The tenant's templates, their names in the reader's language. */
|
|
189
|
+
private async namedTemplates(): Promise<MailingTemplate[]> {
|
|
190
|
+
const locale = localeOfRequest(this.ctx);
|
|
191
|
+
const templates = await this.templates.getAll();
|
|
192
|
+
return templates.map((template) => template.localize(locale, ["name"]));
|
|
193
|
+
}
|
|
194
|
+
|
|
176
195
|
/**
|
|
177
196
|
* Which sends this request describes. Declared by the calling page, never
|
|
178
197
|
* inferred: the Overview asks for the business view, the Sends page for the
|
|
@@ -193,7 +212,11 @@ export class MetricsController extends Controller(`${API_BASE_PATH}/metrics`) {
|
|
|
193
212
|
): Promise<ComparedWindow | null> {
|
|
194
213
|
if (!isComparisonWanted(this.ctx)) return null;
|
|
195
214
|
const previousRange = comparisonRange(this.ctx, range);
|
|
196
|
-
return {
|
|
215
|
+
return {
|
|
216
|
+
range: previousRange,
|
|
217
|
+
sends: await this.load(previousRange),
|
|
218
|
+
label: comparisonLabel(this.ctx),
|
|
219
|
+
};
|
|
197
220
|
}
|
|
198
221
|
|
|
199
222
|
@Get("/kpi/:metric")
|
|
@@ -231,37 +254,58 @@ export class MetricsController extends Controller(`${API_BASE_PATH}/metrics`) {
|
|
|
231
254
|
const metric =
|
|
232
255
|
TOP_METRICS[this.ctx.url.searchParams.get("metric") ?? ""] ??
|
|
233
256
|
DEFAULT_TOP_METRIC;
|
|
234
|
-
const
|
|
235
|
-
|
|
236
|
-
this.
|
|
257
|
+
const range = currentRange(this.ctx);
|
|
258
|
+
const [sends, previous, templates] = await Promise.all([
|
|
259
|
+
this.load(range),
|
|
260
|
+
this.loadComparison(range),
|
|
261
|
+
this.namedTemplates(),
|
|
237
262
|
]);
|
|
238
|
-
const
|
|
239
|
-
|
|
240
|
-
|
|
263
|
+
const bySlug = (send: SendSummary) => send.templateSlug;
|
|
264
|
+
const before = previous ? totalsBy(previous.sends, bySlug) : null;
|
|
265
|
+
const items = [...groupBy(sends, bySlug)].map(([slug, rows]) =>
|
|
266
|
+
toTemplateItem(
|
|
267
|
+
slug,
|
|
268
|
+
aggregate(rows),
|
|
269
|
+
templates,
|
|
270
|
+
metric,
|
|
271
|
+
before?.get(slug),
|
|
272
|
+
),
|
|
241
273
|
);
|
|
242
274
|
return { items: ranked(items, MAX_TOP_TEMPLATES) };
|
|
243
275
|
}
|
|
244
276
|
|
|
245
277
|
@Get("/domains")
|
|
246
278
|
async domains() {
|
|
247
|
-
const
|
|
248
|
-
const
|
|
249
|
-
|
|
250
|
-
|
|
279
|
+
const range = currentRange(this.ctx);
|
|
280
|
+
const [sends, previous] = await Promise.all([
|
|
281
|
+
this.load(range),
|
|
282
|
+
this.loadComparison(range),
|
|
283
|
+
]);
|
|
284
|
+
const byDomain = (send: SendSummary) => domainOf(send.recipientEmail);
|
|
285
|
+
const before = previous ? totalsBy(previous.sends, byDomain) : null;
|
|
286
|
+
const items = [...groupBy(sends, byDomain)].map(([domain, rows]) =>
|
|
287
|
+
toDomainItem(domain, aggregate(rows), before?.get(domain)),
|
|
288
|
+
);
|
|
251
289
|
return { items: ranked(items, MAX_TOP_DOMAINS) };
|
|
252
290
|
}
|
|
253
291
|
|
|
254
292
|
@Get("/funnel")
|
|
255
293
|
async funnel() {
|
|
256
294
|
const kind = readFunnelKind(this.ctx.url.searchParams.get("kind"));
|
|
257
|
-
const
|
|
258
|
-
|
|
259
|
-
this.
|
|
295
|
+
const range = currentRange(this.ctx);
|
|
296
|
+
const [sends, previous, templates, tracking] = await Promise.all([
|
|
297
|
+
this.load(range),
|
|
298
|
+
this.loadComparison(range),
|
|
299
|
+
this.namedTemplates(),
|
|
260
300
|
readTracking(),
|
|
261
301
|
]);
|
|
262
|
-
const
|
|
302
|
+
const funnelOf = (rows: SendSummary[]): FunnelStepResponse[] => {
|
|
303
|
+
const kept = filterByKind(rows, templates, kind);
|
|
304
|
+
return funnelSteps(aggregate(kept), isDeliveryUntracked(kept, tracking));
|
|
305
|
+
};
|
|
306
|
+
const steps = funnelOf(sends);
|
|
263
307
|
return {
|
|
264
|
-
steps:
|
|
308
|
+
steps: previous ? comparedSteps(steps, funnelOf(previous.sends)) : steps,
|
|
265
309
|
};
|
|
266
310
|
}
|
|
267
311
|
|
|
@@ -269,7 +313,7 @@ export class MetricsController extends Controller(`${API_BASE_PATH}/metrics`) {
|
|
|
269
313
|
async attention() {
|
|
270
314
|
const range = currentRange(this.ctx);
|
|
271
315
|
const [templates, rows, tracking, settings] = await Promise.all([
|
|
272
|
-
this.
|
|
316
|
+
this.namedTemplates(),
|
|
273
317
|
this.sends.listBetween(range.from, range.to),
|
|
274
318
|
readTracking(),
|
|
275
319
|
findSettings(this.tenantId),
|
|
@@ -292,7 +336,7 @@ export class MetricsController extends Controller(`${API_BASE_PATH}/metrics`) {
|
|
|
292
336
|
async setup(): Promise<SetupFactsResponse> {
|
|
293
337
|
const [capabilities, templates, settings, testSends] = await Promise.all([
|
|
294
338
|
readCapabilities(),
|
|
295
|
-
this.
|
|
339
|
+
this.namedTemplates(),
|
|
296
340
|
findSettings(this.tenantId),
|
|
297
341
|
this.sends.table
|
|
298
342
|
.filter((row) => row.key("isTest").eq(true))
|
|
@@ -366,21 +410,22 @@ export function volumeChart(
|
|
|
366
410
|
const sent = current.filter(leftForProvider);
|
|
367
411
|
const previousSent = previous?.sends.filter(leftForProvider) ?? null;
|
|
368
412
|
const series = dailySeries(sent, range.from, range.to);
|
|
369
|
-
const
|
|
413
|
+
const comparisonSeries =
|
|
370
414
|
previous && previousSent
|
|
371
|
-
? dailySeries(previousSent, previous.range.from, previous.range.to)
|
|
372
|
-
: null;
|
|
373
|
-
return {
|
|
374
|
-
...comparedFigure(sent.length, previousSent ? previousSent.length : null),
|
|
375
|
-
series: [{ name: "$dms_mailing.metrics.sends", data: series }],
|
|
376
|
-
comparisonSeries: comparison
|
|
377
415
|
? [
|
|
378
416
|
{
|
|
379
|
-
name:
|
|
380
|
-
data: alignSeries(
|
|
417
|
+
name: previous.label,
|
|
418
|
+
data: alignSeries(
|
|
419
|
+
dailySeries(previousSent, previous.range.from, previous.range.to),
|
|
420
|
+
series,
|
|
421
|
+
),
|
|
381
422
|
},
|
|
382
423
|
]
|
|
383
|
-
: []
|
|
424
|
+
: [];
|
|
425
|
+
return {
|
|
426
|
+
...comparedFigure(sent.length, previousSent ? previousSent.length : null),
|
|
427
|
+
series: [{ name: "$dms_mailing.metrics.sends", data: series }],
|
|
428
|
+
comparisonSeries,
|
|
384
429
|
};
|
|
385
430
|
}
|
|
386
431
|
|
|
@@ -397,6 +442,28 @@ export function funnelSteps(
|
|
|
397
442
|
}));
|
|
398
443
|
}
|
|
399
444
|
|
|
445
|
+
/**
|
|
446
|
+
* The steps with their count over the comparison window, step by step. A step
|
|
447
|
+
* either window does not measure states no comparison: a zero there would
|
|
448
|
+
* read as a fall that never happened.
|
|
449
|
+
*/
|
|
450
|
+
export function comparedSteps(
|
|
451
|
+
current: FunnelStepResponse[],
|
|
452
|
+
previous: FunnelStepResponse[],
|
|
453
|
+
): FunnelStepResponse[] {
|
|
454
|
+
return current.map((step) => {
|
|
455
|
+
const before = previous.find((candidate) => candidate.id === step.id);
|
|
456
|
+
if (!step.isMeasured || !before?.isMeasured) return step;
|
|
457
|
+
const compared: FunnelStepResponse = {
|
|
458
|
+
...step,
|
|
459
|
+
previousCount: before.count,
|
|
460
|
+
};
|
|
461
|
+
const delta = deltaPercent(step.count, before.count);
|
|
462
|
+
if (delta !== null) compared.delta = delta;
|
|
463
|
+
return compared;
|
|
464
|
+
});
|
|
465
|
+
}
|
|
466
|
+
|
|
400
467
|
/** Which sends the funnel counts: all, or one side of the marketing split. */
|
|
401
468
|
export type FunnelKind = "all" | "marketing" | "transactional";
|
|
402
469
|
|
|
@@ -441,6 +508,35 @@ async function readTracking(): Promise<ProviderTracking | null> {
|
|
|
441
508
|
return trackingOf(await readCapabilities());
|
|
442
509
|
}
|
|
443
510
|
|
|
511
|
+
/** The totals of each group of sends, by the key `keyOf` gives a send. */
|
|
512
|
+
export function totalsBy(
|
|
513
|
+
sends: SendSummary[],
|
|
514
|
+
keyOf: (send: SendSummary) => string,
|
|
515
|
+
): Map<string, Totals> {
|
|
516
|
+
return new Map(
|
|
517
|
+
[...groupBy(sends, keyOf)].map(([key, rows]) => [key, aggregate(rows)]),
|
|
518
|
+
);
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/**
|
|
522
|
+
* A ranked row's change against the comparison window: `null` when the row
|
|
523
|
+
* had too few deliveries there to be ranked, its figure being then a guess.
|
|
524
|
+
*/
|
|
525
|
+
export function rowDelta(
|
|
526
|
+
value: number,
|
|
527
|
+
previous: Totals | undefined,
|
|
528
|
+
metric: TopMetric,
|
|
529
|
+
): number | null {
|
|
530
|
+
if (!previous || previous.delivered < metric.minDeliveries) return null;
|
|
531
|
+
return deltaPercent(value, metric.extract(previous));
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/** The row with its change; with none to state, the row carries no delta. */
|
|
535
|
+
function withDelta(item: TopListItem, delta: number | null): TopListItem {
|
|
536
|
+
if (delta !== null) item.delta = delta;
|
|
537
|
+
return item;
|
|
538
|
+
}
|
|
539
|
+
|
|
444
540
|
/** The rows that can be ranked, best first, at most `limit` of them. */
|
|
445
541
|
export function ranked(
|
|
446
542
|
items: Array<TopListItem | null>,
|
|
@@ -457,37 +553,54 @@ const deliveredCount = (delivered: number): ComposedText => ({
|
|
|
457
553
|
params: { count: { type: "count", value: delivered } },
|
|
458
554
|
});
|
|
459
555
|
|
|
556
|
+
/** The domains are ranked on their open rate, as the templates by default. */
|
|
557
|
+
const DOMAIN_METRIC = DEFAULT_TOP_METRIC;
|
|
558
|
+
|
|
559
|
+
/**
|
|
560
|
+
* A domain's row. `previous`, its totals over the comparison window when the
|
|
561
|
+
* toolbar compares, gives the row its change.
|
|
562
|
+
*/
|
|
460
563
|
export function toDomainItem(
|
|
461
564
|
domain: string,
|
|
462
565
|
totals: Totals,
|
|
566
|
+
previous?: Totals,
|
|
463
567
|
): TopListItem | null {
|
|
464
|
-
|
|
568
|
+
const value = DOMAIN_METRIC.extract(totals);
|
|
569
|
+
if (value === null || totals.delivered < DOMAIN_METRIC.minDeliveries)
|
|
465
570
|
return null;
|
|
466
|
-
return
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
571
|
+
return withDelta(
|
|
572
|
+
{
|
|
573
|
+
id: domain,
|
|
574
|
+
title: domain,
|
|
575
|
+
description: deliveredCount(totals.delivered),
|
|
576
|
+
value,
|
|
577
|
+
},
|
|
578
|
+
rowDelta(value, previous, DOMAIN_METRIC),
|
|
579
|
+
);
|
|
472
580
|
}
|
|
473
581
|
|
|
582
|
+
/** A template's row, its change read from `previous` as a domain's. */
|
|
474
583
|
export function toTemplateItem(
|
|
475
584
|
slug: string,
|
|
476
585
|
totals: Totals,
|
|
477
586
|
templates: MailingTemplate[],
|
|
478
587
|
metric: TopMetric,
|
|
588
|
+
previous?: Totals,
|
|
479
589
|
): TopListItem | null {
|
|
480
590
|
const value = metric.extract(totals);
|
|
481
591
|
if (value === null || totals.delivered < metric.minDeliveries) return null;
|
|
482
592
|
const template = templates.find((candidate) => candidate.slug === slug);
|
|
483
|
-
return
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
593
|
+
return withDelta(
|
|
594
|
+
{
|
|
595
|
+
id: slug,
|
|
596
|
+
title: template?.name ?? slug,
|
|
597
|
+
description: {
|
|
598
|
+
key: TEMPLATE_VOLUME_KEY,
|
|
599
|
+
params: { slug, count: { type: "count", value: totals.delivered } },
|
|
600
|
+
},
|
|
601
|
+
value,
|
|
602
|
+
to: template ? `${TEMPLATES_PATH}/${template._id}` : undefined,
|
|
489
603
|
},
|
|
490
|
-
value,
|
|
491
|
-
|
|
492
|
-
};
|
|
604
|
+
rowDelta(value, previous, metric),
|
|
605
|
+
);
|
|
493
606
|
}
|
package/src/routes/period.ts
CHANGED
|
@@ -12,6 +12,17 @@ const DAY_MS = 86_400_000;
|
|
|
12
12
|
const DEFAULT_WINDOW_DAYS = 30;
|
|
13
13
|
/** The DMS period selector's `comparison` value when the user turned it off. */
|
|
14
14
|
const NO_COMPARISON = "none";
|
|
15
|
+
const PREVIOUS_YEAR = "previous-year";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* What the comparison series is called, by the selector's `comparison`. A
|
|
19
|
+
* custom window, or a caller naming none, is neither of the two.
|
|
20
|
+
*/
|
|
21
|
+
const COMPARISON_SERIES_LABELS: Record<string, string> = {
|
|
22
|
+
"previous-period": "$dms_mailing.metrics.previous",
|
|
23
|
+
[PREVIOUS_YEAR]: "$dms_mailing.metrics.previous_year",
|
|
24
|
+
};
|
|
25
|
+
const COMPARED_SERIES_LABEL = "$dms_mailing.metrics.compared";
|
|
15
26
|
|
|
16
27
|
function readQuery(ctx: RangeContext, key: string): string | undefined {
|
|
17
28
|
return ctx.url.searchParams.get(key) ?? undefined;
|
|
@@ -53,6 +64,24 @@ export function isComparisonWanted(ctx: RangeContext): boolean {
|
|
|
53
64
|
return readQuery(ctx, "comparison") !== NO_COMPARISON;
|
|
54
65
|
}
|
|
55
66
|
|
|
67
|
+
/** The label of the comparison series, for the comparison the caller asks. */
|
|
68
|
+
export function comparisonLabel(ctx: RangeContext): string {
|
|
69
|
+
const comparison = readQuery(ctx, "comparison") ?? "previous-period";
|
|
70
|
+
return COMPARISON_SERIES_LABELS[comparison] ?? COMPARED_SERIES_LABEL;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function yearBefore(date: Date): Date {
|
|
74
|
+
const shifted = new Date(date.getTime());
|
|
75
|
+
shifted.setUTCFullYear(shifted.getUTCFullYear() - 1);
|
|
76
|
+
return shifted;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The window the current one is compared with. The DMS selector resolves it
|
|
81
|
+
* itself and sends `compareFrom` / `compareTo`, for the previous year as for
|
|
82
|
+
* the previous period; a caller sending only `comparison` gets the same
|
|
83
|
+
* window a year back, or the equal-length span just before.
|
|
84
|
+
*/
|
|
56
85
|
export function comparisonRange(
|
|
57
86
|
ctx: RangeContext,
|
|
58
87
|
current: DateRange,
|
|
@@ -60,6 +89,9 @@ export function comparisonRange(
|
|
|
60
89
|
const from = parseDate(readQuery(ctx, "compareFrom"));
|
|
61
90
|
const to = parseDate(readQuery(ctx, "compareTo"));
|
|
62
91
|
if (from && to) return boundedWindow(from, to);
|
|
92
|
+
if (readQuery(ctx, "comparison") === PREVIOUS_YEAR) {
|
|
93
|
+
return { from: yearBefore(current.from), to: yearBefore(current.to) };
|
|
94
|
+
}
|
|
63
95
|
const span = current.to.getTime() - current.from.getTime();
|
|
64
96
|
return {
|
|
65
97
|
from: new Date(current.from.getTime() - span),
|
package/src/routes/sends.ts
CHANGED
|
@@ -35,9 +35,10 @@ import {
|
|
|
35
35
|
SendModel,
|
|
36
36
|
TemplateModel,
|
|
37
37
|
} from "../db";
|
|
38
|
-
import { resolveEmail } from "../engine";
|
|
38
|
+
import { resolveEmail, valueFormatOf } from "../engine";
|
|
39
39
|
import type { InterpolationMode } from "../engine/tokens";
|
|
40
40
|
import { SendsPageController } from "../pages/sends";
|
|
41
|
+
import { localeOfRequest } from "../services/localized-text";
|
|
41
42
|
import { readCapabilities } from "../services/provider";
|
|
42
43
|
import { buildProviderBanner } from "../services/provider-banner";
|
|
43
44
|
import { renderEmailHtml } from "../services/render";
|
|
@@ -65,6 +66,7 @@ import {
|
|
|
65
66
|
pickLocale,
|
|
66
67
|
publishedVersionOf,
|
|
67
68
|
requireTemplate,
|
|
69
|
+
snapshotOfVersion,
|
|
68
70
|
} from "../services/templates";
|
|
69
71
|
import type {
|
|
70
72
|
SendStatus,
|
|
@@ -383,7 +385,9 @@ export class SendsController extends Controller(API_BASE_PATH) {
|
|
|
383
385
|
return {
|
|
384
386
|
send,
|
|
385
387
|
events,
|
|
386
|
-
template: template
|
|
388
|
+
template: template
|
|
389
|
+
? summarize(template.localize(localeOfRequest(this.ctx), ["name"]))
|
|
390
|
+
: null,
|
|
387
391
|
suppression,
|
|
388
392
|
};
|
|
389
393
|
}
|
|
@@ -396,7 +400,7 @@ export class SendsController extends Controller(API_BASE_PATH) {
|
|
|
396
400
|
async html(@Parameter("id", "param") id: string): Promise<SendHtmlResponse> {
|
|
397
401
|
const send = await this.requireSend(id);
|
|
398
402
|
const template = await requireTemplate(this.templates, send.templateId);
|
|
399
|
-
const content = await
|
|
403
|
+
const { content, variables } = await snapshotOfVersion(
|
|
400
404
|
this.tenantId,
|
|
401
405
|
template,
|
|
402
406
|
send.templateVersion,
|
|
@@ -405,7 +409,12 @@ export class SendsController extends Controller(API_BASE_PATH) {
|
|
|
405
409
|
const locale = pickLocale(content, send.locale, settings.fallbackLocale);
|
|
406
410
|
const localeContent = localeContentOf(content, locale);
|
|
407
411
|
assert(localeContent, HTTP_UNPROCESSABLE, INVALID_CONTENT);
|
|
408
|
-
const email = resolveEmail(
|
|
412
|
+
const email = resolveEmail(
|
|
413
|
+
localeContent,
|
|
414
|
+
variablesOf(send),
|
|
415
|
+
SEND_MODE,
|
|
416
|
+
valueFormatOf(locale, variables),
|
|
417
|
+
);
|
|
409
418
|
const version = send.templateVersion;
|
|
410
419
|
return {
|
|
411
420
|
html: await renderEmailHtml(email, locale),
|
package/src/routes/settings.ts
CHANGED
|
@@ -71,7 +71,11 @@ const retentionDaysSchema = z.coerce
|
|
|
71
71
|
.min(MIN_RETENTION_DAYS)
|
|
72
72
|
.max(MAX_RETENTION_DAYS);
|
|
73
73
|
|
|
74
|
-
/**
|
|
74
|
+
/**
|
|
75
|
+
* The settings as the form reads them, with the values derived for display.
|
|
76
|
+
* The DMS form reads and writes localized values whole
|
|
77
|
+
* (`x-content-language: *`): category labels travel with every language.
|
|
78
|
+
*/
|
|
75
79
|
export interface MailingSettingsResponse extends MailingSettingsValues {
|
|
76
80
|
/** Where the provider posts its events; read-only. */
|
|
77
81
|
webhookUrl: string;
|
|
@@ -217,14 +221,13 @@ export class MailingSettingsController extends Controller(
|
|
|
217
221
|
*/
|
|
218
222
|
@Put("")
|
|
219
223
|
async update(@RawBody() raw: Buffer): Promise<SettingsWriteResponse> {
|
|
220
|
-
const patch = this.readPatch(raw);
|
|
224
|
+
const { categories: entries, ...patch } = this.readPatch(raw);
|
|
221
225
|
const current = await getSettings(this.tenantId);
|
|
222
226
|
const merged = mergeSettingsPatch(current, patch);
|
|
223
|
-
const
|
|
224
|
-
const
|
|
225
|
-
const
|
|
226
|
-
|
|
227
|
-
? await this.reassignCategories(values.categories, categoryMoves(entries))
|
|
227
|
+
const categories = entries ? keptCategories(entries) : current.categories;
|
|
228
|
+
const saved = await saveSettings(this.tenantId, { ...merged, categories });
|
|
229
|
+
const reassigned = entries
|
|
230
|
+
? await this.reassignCategories(categories, categoryMoves(entries))
|
|
228
231
|
: 0;
|
|
229
232
|
const [notice] = [...reassignedNotice(reassigned), ...senderNotice(saved)];
|
|
230
233
|
return { ...saved, ...(notice && { notice }) };
|
|
@@ -324,7 +327,7 @@ export class MailingSettingsController extends Controller(
|
|
|
324
327
|
const templates = await this.templates.getAll();
|
|
325
328
|
const changes = reassignRemovedCategories(templates, categories, moves);
|
|
326
329
|
for (const { templateId, category } of changes) {
|
|
327
|
-
await this.templates.
|
|
330
|
+
await this.templates.updateFields(templateId, { category });
|
|
328
331
|
}
|
|
329
332
|
return changes.length;
|
|
330
333
|
}
|