@antelopejs/dms-mailing 0.7.1 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +46 -31
- package/dist/automation/email-event-trigger.d.ts +2 -2
- package/dist/automation/send-template-action.d.ts +11 -3
- package/dist/automation/send-template-action.js +1 -1
- package/dist/automation/send-template-action.js.map +1 -1
- package/dist/crons/jobs.d.ts +1 -1
- package/dist/data/body.d.ts +2 -1
- package/dist/data/body.js +4 -2
- package/dist/data/body.js.map +1 -1
- package/dist/data/read-only-routes.d.ts +4 -4
- package/dist/data/sends-table.d.ts +4 -4
- package/dist/data/suppressions-table.d.ts +4 -4
- package/dist/data/templates-table.d.ts +35 -12
- package/dist/data/templates-table.js +30 -24
- package/dist/data/templates-table.js.map +1 -1
- package/dist/data-types/categories-type.d.ts +1 -2
- package/dist/data-types/categories-type.js +3 -5
- package/dist/data-types/categories-type.js.map +1 -1
- package/dist/data-types/category-type.d.ts +1 -2
- package/dist/data-types/category-type.js +3 -5
- package/dist/data-types/category-type.js.map +1 -1
- package/dist/db/models/send-events.model.d.ts +7 -5
- package/dist/db/models/sends.model.d.ts +7 -5
- package/dist/db/models/sends.model.js.map +1 -1
- package/dist/db/models/settings.model.d.ts +7 -5
- package/dist/db/models/suppressions.model.d.ts +7 -5
- package/dist/db/models/template-versions.model.d.ts +7 -5
- package/dist/db/models/template-versions.model.js +4 -3
- package/dist/db/models/template-versions.model.js.map +1 -1
- package/dist/db/models/templates.model.d.ts +7 -5
- package/dist/db/models/templates.model.js +2 -1
- package/dist/db/models/templates.model.js.map +1 -1
- package/dist/db/models/webhook-status.model.d.ts +7 -5
- package/dist/db/tables/templates.table.js.map +1 -1
- package/dist/engine/changes.js +8 -18
- package/dist/engine/changes.js.map +1 -1
- package/dist/engine/conditions.d.ts +2 -1
- package/dist/engine/conditions.js +5 -5
- package/dist/engine/conditions.js.map +1 -1
- package/dist/engine/coverage.d.ts +2 -1
- package/dist/engine/coverage.js.map +1 -1
- package/dist/engine/fallback-html.js +8 -8
- package/dist/engine/fallback-html.js.map +1 -1
- package/dist/engine/formats.d.ts +4 -6
- package/dist/engine/formats.js +22 -8
- package/dist/engine/formats.js.map +1 -1
- package/dist/engine/paths.d.ts +4 -2
- package/dist/engine/paths.js +16 -5
- package/dist/engine/paths.js.map +1 -1
- package/dist/engine/plain-text.js +2 -4
- package/dist/engine/plain-text.js.map +1 -1
- package/dist/engine/resolve.d.ts +2 -1
- package/dist/engine/resolve.js +1 -1
- package/dist/engine/resolve.js.map +1 -1
- package/dist/engine/system-variables.d.ts +2 -1
- package/dist/engine/system-variables.js +1 -1
- package/dist/engine/system-variables.js.map +1 -1
- package/dist/engine/tokens.d.ts +2 -1
- package/dist/engine/tokens.js.map +1 -1
- package/dist/hooks/tenant-export.js +10 -9
- package/dist/hooks/tenant-export.js.map +1 -1
- package/dist/pages/sends.js.map +1 -1
- package/dist/pages/templates.js +3 -1
- package/dist/pages/templates.js.map +1 -1
- package/dist/pages/toolbar.js.map +1 -1
- package/dist/routes/events.js +6 -4
- package/dist/routes/events.js.map +1 -1
- package/dist/routes/index.d.ts +1 -0
- package/dist/routes/index.js +1 -0
- package/dist/routes/index.js.map +1 -1
- package/dist/routes/metrics.js +31 -31
- package/dist/routes/metrics.js.map +1 -1
- package/dist/routes/period.js +5 -5
- package/dist/routes/period.js.map +1 -1
- package/dist/routes/sends.js +4 -5
- package/dist/routes/sends.js.map +1 -1
- package/dist/routes/settings.d.ts +1 -1
- package/dist/routes/settings.js +16 -14
- package/dist/routes/settings.js.map +1 -1
- package/dist/routes/suppressions.js +9 -6
- package/dist/routes/suppressions.js.map +1 -1
- package/dist/routes/template-content.d.ts +27 -0
- package/dist/routes/template-content.js +3 -0
- package/dist/routes/template-content.js.map +1 -0
- package/dist/routes/templates.d.ts +7 -30
- package/dist/routes/templates.js +7 -6
- package/dist/routes/templates.js.map +1 -1
- package/dist/routes/zod-json.d.ts +3 -0
- package/dist/routes/zod-json.js +21 -0
- package/dist/routes/zod-json.js.map +1 -0
- package/dist/services/attention.js +16 -8
- package/dist/services/attention.js.map +1 -1
- package/dist/services/catalog.js +4 -2
- package/dist/services/catalog.js.map +1 -1
- package/dist/services/categories.js +6 -6
- package/dist/services/categories.js.map +1 -1
- package/dist/services/checked-send.js +16 -9
- package/dist/services/checked-send.js.map +1 -1
- package/dist/services/declaration-error.d.ts +1 -1
- package/dist/services/declaration-error.js.map +1 -1
- package/dist/services/declarations.js +4 -1
- package/dist/services/declarations.js.map +1 -1
- package/dist/services/events.js +13 -7
- package/dist/services/events.js.map +1 -1
- package/dist/services/localized-text.d.ts +2 -1
- package/dist/services/localized-text.js +2 -1
- package/dist/services/localized-text.js.map +1 -1
- package/dist/services/metrics.js +2 -5
- package/dist/services/metrics.js.map +1 -1
- package/dist/services/render.js.map +1 -1
- package/dist/services/send-import.js +7 -3
- package/dist/services/send-import.js.map +1 -1
- package/dist/services/send.js +7 -7
- package/dist/services/send.js.map +1 -1
- package/dist/services/settings.d.ts +4 -2
- package/dist/services/settings.js +5 -4
- package/dist/services/settings.js.map +1 -1
- package/dist/services/starters.d.ts +3 -2
- package/dist/services/starters.js +5 -5
- package/dist/services/starters.js.map +1 -1
- package/dist/services/template-stats.js +5 -2
- package/dist/services/template-stats.js.map +1 -1
- package/dist/services/templates.d.ts +2 -1
- package/dist/services/templates.js +8 -4
- package/dist/services/templates.js.map +1 -1
- package/dist/services/webhook-status.d.ts +2 -2
- package/dist/services/webhook-status.js +6 -4
- package/dist/services/webhook-status.js.map +1 -1
- package/dist/types/json.d.ts +1 -0
- package/dist/types/json.js +3 -0
- package/dist/types/json.js.map +1 -0
- package/dist/types/settings.d.ts +2 -2
- package/dist/validation/declarations.schema.d.ts +44 -44
- package/dist/validation/declarations.schema.js +6 -5
- package/dist/validation/declarations.schema.js.map +1 -1
- package/dist/validation/guards.d.ts +3 -0
- package/dist/validation/guards.js +15 -0
- package/dist/validation/guards.js.map +1 -0
- package/dist/validation/json.d.ts +5 -0
- package/dist/validation/json.js +54 -0
- package/dist/validation/json.js.map +1 -0
- package/dist/validation/json.schema.d.ts +2 -0
- package/dist/validation/json.schema.js +9 -0
- package/dist/validation/json.schema.js.map +1 -0
- package/dist/validation/send-event-type.schema.d.ts +2 -0
- package/dist/validation/send-event-type.schema.js +11 -0
- package/dist/validation/send-event-type.schema.js.map +1 -0
- package/dist/validation/settings.schema.js +1 -1
- package/dist/validation/settings.schema.js.map +1 -1
- package/dist/validation/templates.schema.d.ts +44 -42
- package/dist/validation/templates.schema.js +4 -3
- package/dist/validation/templates.schema.js.map +1 -1
- package/frontend-vue/app/build/components/TemplateDrawerFooter.vue +45 -0
- package/frontend-vue/app/build/components/TemplateDrawerPerformance.vue +102 -0
- package/frontend-vue/app/build/components/TemplateDrawerVariables.vue +110 -0
- package/frontend-vue/app/build/composables/useDrawerStats.ts +78 -0
- package/frontend-vue/app/components/DuplicateModal.vue +3 -11
- package/frontend-vue/app/components/FixAddressModal.vue +2 -1
- package/frontend-vue/app/components/NewTemplateModal.vue +3 -11
- package/frontend-vue/app/components/RealSendModal.vue +6 -17
- package/frontend-vue/app/components/RenderingModal.vue +2 -1
- package/frontend-vue/app/components/SendDrawer.vue +5 -4
- package/frontend-vue/app/components/StarterGallery.vue +3 -11
- package/frontend-vue/app/components/TemplateDrawer.vue +40 -269
- package/frontend-vue/app/components/TestSendModal.vue +7 -18
- package/frontend-vue/app/components/VersionHistory.vue +2 -7
- package/frontend-vue/app/components/WebhookStatus.vue +2 -1
- package/frontend-vue/app/components/editor/EditorWorkspace.vue +2 -7
- package/frontend-vue/build/backend-boundary.ts +122 -0
- package/frontend-vue/dms.frontend.build.ts +7 -0
- package/frontend-vue/eslint.config.mjs +13 -0
- package/frontend-vue/package.json +4 -2
- package/frontend-vue/pnpm-lock.yaml +250 -61
- package/frontend-vue/tests/backend-boundary.test.ts +203 -0
- package/frontend-vue/tests/dnd.test.ts +1 -2
- package/frontend-vue/tests/editor-block-list.test.ts +136 -53
- package/frontend-vue/tests/helpers/auth-fetch.ts +123 -0
- package/frontend-vue/tests/helpers/nuxt-globals.ts +17 -1
- package/frontend-vue/tests/new-template-modal.test.ts +41 -6
- package/frontend-vue/tests/page-reads.test.ts +26 -6
- package/frontend-vue/tests/send-actions.test.ts +0 -1
- package/frontend-vue/tests/send-drawer.test.ts +2 -6
- package/frontend-vue/tests/send-modals.test.ts +8 -5
- package/frontend-vue/tests/sortable-list.test.ts +54 -62
- package/frontend-vue/tests/suppression.test.ts +1 -2
- package/frontend-vue/tests/template-drawer.test.ts +27 -18
- package/frontend-vue/tests/template-gallery.test.ts +7 -5
- package/frontend-vue/tests/template-preview-frame.test.ts +2 -5
- package/package.json +16 -20
- package/skills/dms-mailing/SKILL.md +1 -1
- package/skills/dms-mailing/cases.json +49 -0
- package/src/automation/email-event-trigger.ts +2 -2
- package/src/automation/send-template-action.ts +33 -14
- package/src/crons/jobs.ts +1 -1
- package/src/data/body.ts +7 -3
- package/src/data/templates-table.ts +55 -56
- package/src/data-types/categories-type.ts +2 -2
- package/src/data-types/category-type.ts +2 -2
- package/src/db/models/sends.model.ts +2 -2
- package/src/db/models/template-versions.model.ts +10 -4
- package/src/db/models/templates.model.ts +6 -3
- package/src/db/tables/templates.table.ts +1 -1
- package/src/engine/changes.ts +11 -28
- package/src/engine/conditions.ts +14 -8
- package/src/engine/coverage.ts +2 -1
- package/src/engine/fallback-html.ts +13 -13
- package/src/engine/formats.ts +35 -21
- package/src/engine/paths.ts +41 -7
- package/src/engine/plain-text.ts +2 -4
- package/src/engine/resolve.ts +12 -4
- package/src/engine/system-variables.ts +6 -4
- package/src/engine/tokens.ts +3 -3
- package/src/hooks/tenant-export.ts +11 -13
- package/src/pages/sends.ts +5 -3
- package/src/pages/templates.ts +4 -2
- package/src/pages/toolbar.ts +4 -2
- package/src/routes/events.ts +10 -10
- package/src/routes/index.ts +1 -0
- package/src/routes/metrics.ts +35 -33
- package/src/routes/period.ts +5 -5
- package/src/routes/sends.ts +23 -26
- package/src/routes/settings.ts +21 -17
- package/src/routes/suppressions.ts +12 -8
- package/src/routes/template-content.ts +45 -0
- package/src/routes/templates.ts +16 -49
- package/src/routes/zod-json.ts +28 -0
- package/src/services/attention.ts +20 -18
- package/src/services/catalog.ts +4 -8
- package/src/services/categories.ts +6 -6
- package/src/services/checked-send.ts +17 -19
- package/src/services/declaration-error.ts +1 -1
- package/src/services/declarations.ts +7 -6
- package/src/services/events.ts +18 -13
- package/src/services/localized-text.ts +4 -2
- package/src/services/metrics.ts +5 -7
- package/src/services/notify.ts +1 -1
- package/src/services/render.ts +5 -1
- package/src/services/send-import.ts +16 -11
- package/src/services/send.ts +10 -13
- package/src/services/settings.ts +17 -16
- package/src/services/starters.ts +12 -8
- package/src/services/template-stats.ts +11 -4
- package/src/services/templates.ts +24 -15
- package/src/services/webhook-status.ts +13 -8
- package/src/types/json.ts +1 -0
- package/src/types/settings.ts +2 -2
- package/src/validation/declarations.schema.ts +6 -5
- package/src/validation/guards.ts +17 -0
- package/src/validation/json.schema.ts +7 -0
- package/src/validation/json.ts +66 -0
- package/src/validation/send-event-type.schema.ts +10 -0
- package/src/validation/settings.schema.ts +7 -2
- package/src/validation/templates.schema.ts +9 -4
- package/frontend-vue/tests/i18n-editor.test.ts +0 -50
- package/frontend-vue/tests/i18n.test.ts +0 -138
package/src/routes/metrics.ts
CHANGED
|
@@ -63,16 +63,16 @@ const TEMPLATE_VOLUME_KEY = "$dms_mailing.metrics.template_volume";
|
|
|
63
63
|
const FIRST_ROW_OFFSET = 0;
|
|
64
64
|
const SINGLE_ROW = 1;
|
|
65
65
|
|
|
66
|
-
const KPI_EXTRACTORS
|
|
67
|
-
sends
|
|
68
|
-
deliverability
|
|
69
|
-
"open-rate"
|
|
70
|
-
"click-rate"
|
|
71
|
-
bounces
|
|
72
|
-
unsubscribes
|
|
73
|
-
queued
|
|
74
|
-
latency
|
|
75
|
-
|
|
66
|
+
const KPI_EXTRACTORS = new Map<string, TotalsExtractor>([
|
|
67
|
+
["sends", (totals) => totals.sends],
|
|
68
|
+
["deliverability", (totals) => totals.deliverability],
|
|
69
|
+
["open-rate", (totals) => totals.openRate],
|
|
70
|
+
["click-rate", (totals) => totals.clickRate],
|
|
71
|
+
["bounces", (totals) => totals.bounced],
|
|
72
|
+
["unsubscribes", (totals) => totals.unsubscribed],
|
|
73
|
+
["queued", (totals) => totals.queued],
|
|
74
|
+
["latency", (totals) => totals.latencyMedian],
|
|
75
|
+
]);
|
|
76
76
|
|
|
77
77
|
/** The KPIs read from delivery events: unmeasured when nothing reports them. */
|
|
78
78
|
const DELIVERY_METRICS = new Set(["deliverability", "open-rate", "click-rate"]);
|
|
@@ -83,18 +83,23 @@ export interface TopMetric {
|
|
|
83
83
|
minDeliveries: number;
|
|
84
84
|
}
|
|
85
85
|
|
|
86
|
-
const
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
minDeliveries: MIN_RANKED_DELIVERIES,
|
|
90
|
-
},
|
|
91
|
-
"click-rate": {
|
|
92
|
-
extract: (totals) => totals.clickRate,
|
|
93
|
-
minDeliveries: MIN_RANKED_DELIVERIES,
|
|
94
|
-
},
|
|
95
|
-
sends: { extract: (totals) => totals.sends, minDeliveries: 0 },
|
|
86
|
+
const OPEN_RATE_METRIC: TopMetric = {
|
|
87
|
+
extract: (totals) => totals.openRate,
|
|
88
|
+
minDeliveries: MIN_RANKED_DELIVERIES,
|
|
96
89
|
};
|
|
97
|
-
|
|
90
|
+
|
|
91
|
+
const TOP_METRICS = new Map<string, TopMetric>([
|
|
92
|
+
["open-rate", OPEN_RATE_METRIC],
|
|
93
|
+
[
|
|
94
|
+
"click-rate",
|
|
95
|
+
{
|
|
96
|
+
extract: (totals) => totals.clickRate,
|
|
97
|
+
minDeliveries: MIN_RANKED_DELIVERIES,
|
|
98
|
+
},
|
|
99
|
+
],
|
|
100
|
+
["sends", { extract: (totals) => totals.sends, minDeliveries: 0 }],
|
|
101
|
+
]);
|
|
102
|
+
const DEFAULT_TOP_METRIC = OPEN_RATE_METRIC;
|
|
98
103
|
|
|
99
104
|
interface FunnelStepDefinition {
|
|
100
105
|
id: string;
|
|
@@ -223,7 +228,7 @@ export class MetricsController extends Controller(`${API_BASE_PATH}/metrics`) {
|
|
|
223
228
|
async kpi(
|
|
224
229
|
@Parameter("metric", "param") metric: string,
|
|
225
230
|
): Promise<KpiCardData> {
|
|
226
|
-
const extract = KPI_EXTRACTORS
|
|
231
|
+
const extract = KPI_EXTRACTORS.get(metric);
|
|
227
232
|
assert(extract, HTTP_NOT_FOUND, UNKNOWN_METRIC);
|
|
228
233
|
const range = currentRange(this.ctx);
|
|
229
234
|
const [current, previous, tracking] = await Promise.all([
|
|
@@ -252,7 +257,7 @@ export class MetricsController extends Controller(`${API_BASE_PATH}/metrics`) {
|
|
|
252
257
|
@Get("/top-templates")
|
|
253
258
|
async topTemplates() {
|
|
254
259
|
const metric =
|
|
255
|
-
TOP_METRICS
|
|
260
|
+
TOP_METRICS.get(this.ctx.url.searchParams.get("metric") ?? "") ??
|
|
256
261
|
DEFAULT_TOP_METRIC;
|
|
257
262
|
const range = currentRange(this.ctx);
|
|
258
263
|
const [sends, previous, templates] = await Promise.all([
|
|
@@ -467,22 +472,19 @@ export function comparedSteps(
|
|
|
467
472
|
/** Which sends the funnel counts: all, or one side of the marketing split. */
|
|
468
473
|
export type FunnelKind = "all" | "marketing" | "transactional";
|
|
469
474
|
|
|
470
|
-
const FUNNEL_KINDS: Record<string, FunnelKind> = {
|
|
471
|
-
all: "all",
|
|
472
|
-
marketing: "marketing",
|
|
473
|
-
transactional: "transactional",
|
|
474
|
-
};
|
|
475
|
-
|
|
476
|
-
export function readFunnelKind(raw: string | null): FunnelKind {
|
|
477
|
-
return FUNNEL_KINDS[raw ?? ""] ?? "all";
|
|
478
|
-
}
|
|
479
|
-
|
|
480
475
|
const KIND_KEEPS: Record<FunnelKind, (category: string) => boolean> = {
|
|
481
476
|
all: () => true,
|
|
482
477
|
marketing: (category) => isMarketingCategory(category),
|
|
483
478
|
transactional: (category) => !isMarketingCategory(category),
|
|
484
479
|
};
|
|
485
480
|
|
|
481
|
+
const isFunnelKind = (raw: string): raw is FunnelKind =>
|
|
482
|
+
Object.hasOwn(KIND_KEEPS, raw);
|
|
483
|
+
|
|
484
|
+
export function readFunnelKind(raw: string | null): FunnelKind {
|
|
485
|
+
return raw !== null && isFunnelKind(raw) ? raw : "all";
|
|
486
|
+
}
|
|
487
|
+
|
|
486
488
|
/**
|
|
487
489
|
* Sends of the templates on one side of the split. A template's category
|
|
488
490
|
* decides: "marketing" is the campaign side, everything else transactional.
|
package/src/routes/period.ts
CHANGED
|
@@ -18,10 +18,10 @@ const PREVIOUS_YEAR = "previous-year";
|
|
|
18
18
|
* What the comparison series is called, by the selector's `comparison`. A
|
|
19
19
|
* custom window, or a caller naming none, is neither of the two.
|
|
20
20
|
*/
|
|
21
|
-
const COMPARISON_SERIES_LABELS
|
|
22
|
-
"previous-period"
|
|
23
|
-
[PREVIOUS_YEAR
|
|
24
|
-
|
|
21
|
+
const COMPARISON_SERIES_LABELS = new Map([
|
|
22
|
+
["previous-period", "$dms_mailing.metrics.previous"],
|
|
23
|
+
[PREVIOUS_YEAR, "$dms_mailing.metrics.previous_year"],
|
|
24
|
+
]);
|
|
25
25
|
const COMPARED_SERIES_LABEL = "$dms_mailing.metrics.compared";
|
|
26
26
|
|
|
27
27
|
function readQuery(ctx: RangeContext, key: string): string | undefined {
|
|
@@ -67,7 +67,7 @@ export function isComparisonWanted(ctx: RangeContext): boolean {
|
|
|
67
67
|
/** The label of the comparison series, for the comparison the caller asks. */
|
|
68
68
|
export function comparisonLabel(ctx: RangeContext): string {
|
|
69
69
|
const comparison = readQuery(ctx, "comparison") ?? "previous-period";
|
|
70
|
-
return COMPARISON_SERIES_LABELS
|
|
70
|
+
return COMPARISON_SERIES_LABELS.get(comparison) ?? COMPARED_SERIES_LABEL;
|
|
71
71
|
}
|
|
72
72
|
|
|
73
73
|
function yearBefore(date: Date): Date {
|
package/src/routes/sends.ts
CHANGED
|
@@ -10,7 +10,7 @@ import {
|
|
|
10
10
|
} from "@antelopejs/interface-api";
|
|
11
11
|
import { assert, assertValidation } from "@antelopejs/interface-api-util";
|
|
12
12
|
import { AuthUserWithPermission } from "@antelopejs/interface-dms/guards";
|
|
13
|
-
import { ZodError } from "zod";
|
|
13
|
+
import { ZodError, type z } from "zod";
|
|
14
14
|
import { getRequestTenantId } from "@antelopejs/interface-dms/request-tenant";
|
|
15
15
|
import { TenantScopedModel } from "@antelopejs/interface-dms/tenant-scoped-model";
|
|
16
16
|
import type { User } from "@antelopejs/interface-dms/auth/db";
|
|
@@ -68,13 +68,10 @@ import {
|
|
|
68
68
|
requireTemplate,
|
|
69
69
|
snapshotOfVersion,
|
|
70
70
|
} from "../services/templates";
|
|
71
|
-
import type {
|
|
72
|
-
|
|
73
|
-
SuppressionSummary,
|
|
74
|
-
TemplateContent,
|
|
75
|
-
TemplateStatus,
|
|
76
|
-
} from "../types";
|
|
71
|
+
import type { SendStatus, SuppressionSummary, TemplateStatus } from "../types";
|
|
72
|
+
import type { JsonObject } from "../types/json";
|
|
77
73
|
import {
|
|
74
|
+
type previewVersionSchema,
|
|
78
75
|
realSendSchema,
|
|
79
76
|
replayFailedSchema,
|
|
80
77
|
replaySchema,
|
|
@@ -98,12 +95,6 @@ const DRAFT_VERSION = 0;
|
|
|
98
95
|
/** Renders as the recipient got it, not as the editor previews it. */
|
|
99
96
|
const SEND_MODE: InterpolationMode = "send";
|
|
100
97
|
|
|
101
|
-
interface TestSendInput {
|
|
102
|
-
locale: string;
|
|
103
|
-
content?: TemplateContent;
|
|
104
|
-
data?: Record<string, unknown>;
|
|
105
|
-
}
|
|
106
|
-
|
|
107
98
|
export interface SendTemplateSummary {
|
|
108
99
|
id: string;
|
|
109
100
|
name: string;
|
|
@@ -194,10 +185,10 @@ const sameAddress = (left: string, right: string): boolean =>
|
|
|
194
185
|
normalizeAddress(left) === normalizeAddress(right);
|
|
195
186
|
|
|
196
187
|
/** A bad address gets its own message; any other invalid field is the content's. */
|
|
197
|
-
function sendInputError(error:
|
|
198
|
-
const isRecipient =
|
|
199
|
-
|
|
200
|
-
|
|
188
|
+
function sendInputError(error: ZodError): string {
|
|
189
|
+
const isRecipient = error.issues.some(
|
|
190
|
+
(issue) => issue.path[0] === RECIPIENT_FIELD,
|
|
191
|
+
);
|
|
201
192
|
return isRecipient ? INVALID_RECIPIENT : INVALID_CONTENT;
|
|
202
193
|
}
|
|
203
194
|
|
|
@@ -265,7 +256,8 @@ export class SendsController extends Controller(API_BASE_PATH) {
|
|
|
265
256
|
const input = assertValidation(
|
|
266
257
|
body,
|
|
267
258
|
(value) => testSendSchema.parse(value),
|
|
268
|
-
|
|
259
|
+
(error) =>
|
|
260
|
+
error instanceof ZodError ? sendInputError(error) : INVALID_CONTENT,
|
|
269
261
|
);
|
|
270
262
|
const template = await requireTemplate(this.templates, id);
|
|
271
263
|
const ownAddressSuppressed = await this.ownSuppression(input.to);
|
|
@@ -274,9 +266,9 @@ export class SendsController extends Controller(API_BASE_PATH) {
|
|
|
274
266
|
tenantId: this.tenantId,
|
|
275
267
|
to: input.to,
|
|
276
268
|
allowSuppressed: [this.user.email],
|
|
277
|
-
locale:
|
|
278
|
-
variables:
|
|
279
|
-
content:
|
|
269
|
+
locale: input.locale,
|
|
270
|
+
variables: input.data,
|
|
271
|
+
content: input.content,
|
|
280
272
|
version: requestedVersionOf(input.version),
|
|
281
273
|
isTest: true,
|
|
282
274
|
source: TEST_SEND_SOURCE,
|
|
@@ -311,7 +303,8 @@ export class SendsController extends Controller(API_BASE_PATH) {
|
|
|
311
303
|
const input = assertValidation(
|
|
312
304
|
body,
|
|
313
305
|
(value) => realSendSchema.parse(value),
|
|
314
|
-
|
|
306
|
+
(error) =>
|
|
307
|
+
error instanceof ZodError ? sendInputError(error) : INVALID_CONTENT,
|
|
315
308
|
);
|
|
316
309
|
const template = await requireTemplate(this.templates, id);
|
|
317
310
|
const result = await this.send(
|
|
@@ -578,12 +571,16 @@ export class SendsController extends Controller(API_BASE_PATH) {
|
|
|
578
571
|
* The version a test of a published version records: a number names one,
|
|
579
572
|
* "draft" or "published" let the send decide.
|
|
580
573
|
*/
|
|
581
|
-
function requestedVersionOf(
|
|
582
|
-
|
|
574
|
+
function requestedVersionOf(
|
|
575
|
+
version: z.infer<typeof previewVersionSchema> | undefined,
|
|
576
|
+
): number | undefined {
|
|
577
|
+
return version === "draft" || version === "published" ? undefined : version;
|
|
583
578
|
}
|
|
584
579
|
|
|
585
|
-
function variablesOf(send: MailingSend):
|
|
586
|
-
|
|
580
|
+
function variablesOf(send: MailingSend): JsonObject {
|
|
581
|
+
// SAFETY: json_variables is written only as JSON.stringify(params.variables ?? {}) (services/send.ts) or
|
|
582
|
+
// JSON.stringify(send.variables) (services/send-import.ts), both a JsonObject.
|
|
583
|
+
return JSON.parse(send.json_variables || "{}") as JsonObject;
|
|
587
584
|
}
|
|
588
585
|
|
|
589
586
|
function summarize(template: MailingTemplate): SendTemplateSummary {
|
package/src/routes/settings.ts
CHANGED
|
@@ -48,6 +48,8 @@ import {
|
|
|
48
48
|
settingsPatchSchema,
|
|
49
49
|
} from "../validation/settings.schema";
|
|
50
50
|
import { retentionLimit } from "../crons/retention";
|
|
51
|
+
import { isJsonObject } from "../validation/json";
|
|
52
|
+
import { zodIssuesJson } from "./zod-json";
|
|
51
53
|
|
|
52
54
|
const INVALID_SETTINGS = "$dms_mailing.errors.invalid_settings";
|
|
53
55
|
const CATEGORIES_REASSIGNED = "$dms_mailing.settings.categories.reassigned";
|
|
@@ -61,9 +63,9 @@ const NEXT_RUN_MINUTE = 15;
|
|
|
61
63
|
const RETENTION_DAYS_PARAM = "days";
|
|
62
64
|
|
|
63
65
|
/** A settings field the form shows under another name. */
|
|
64
|
-
const FORM_FIELD_OF
|
|
65
|
-
blockOnMissingVariables
|
|
66
|
-
|
|
66
|
+
const FORM_FIELD_OF = new Map([
|
|
67
|
+
["blockOnMissingVariables", "missingVariables"],
|
|
68
|
+
]);
|
|
67
69
|
|
|
68
70
|
const retentionDaysSchema = z.coerce
|
|
69
71
|
.number()
|
|
@@ -89,7 +91,7 @@ export interface MailingSettingsResponse extends MailingSettingsValues {
|
|
|
89
91
|
export interface SettingsNotice {
|
|
90
92
|
title: string;
|
|
91
93
|
color?: string;
|
|
92
|
-
params?: Record<string,
|
|
94
|
+
params?: Record<string, number | string>;
|
|
93
95
|
}
|
|
94
96
|
|
|
95
97
|
export interface SettingsWriteResponse extends MailingSettingsValues {
|
|
@@ -141,14 +143,16 @@ function webhookUrl(
|
|
|
141
143
|
/** The issue of a refused write, under the form field it belongs to. */
|
|
142
144
|
function formIssue(issue: z.ZodIssue): z.ZodIssue {
|
|
143
145
|
const [field, ...rest] = issue.path;
|
|
144
|
-
const formField = FORM_FIELD_OF
|
|
146
|
+
const formField = FORM_FIELD_OF.get(String(field));
|
|
145
147
|
return formField ? { ...issue, path: [formField, ...rest] } : issue;
|
|
146
148
|
}
|
|
147
149
|
|
|
148
150
|
/** A refused write: the generic message, and each issue for its field. */
|
|
149
|
-
function invalidSettings(error:
|
|
150
|
-
|
|
151
|
-
|
|
151
|
+
function invalidSettings(error: z.ZodError) {
|
|
152
|
+
return {
|
|
153
|
+
message: INVALID_SETTINGS,
|
|
154
|
+
issues: zodIssuesJson(error.issues.map(formIssue)),
|
|
155
|
+
};
|
|
152
156
|
}
|
|
153
157
|
|
|
154
158
|
function senderNotice(values: MailingSettingsValues): SettingsNotice[] {
|
|
@@ -236,13 +240,12 @@ export class MailingSettingsController extends Controller(
|
|
|
236
240
|
/** The body as a checked patch; 400 for anything else. */
|
|
237
241
|
private readPatch(raw: Buffer): SettingsPatch {
|
|
238
242
|
const body: unknown = parseBody(raw);
|
|
239
|
-
|
|
240
|
-
typeof body === "object" && body !== null && !Array.isArray(body);
|
|
241
|
-
assert(isPatch, HTTP_BAD_REQUEST, INVALID_SETTINGS);
|
|
243
|
+
assert(isJsonObject(body), HTTP_BAD_REQUEST, INVALID_SETTINGS);
|
|
242
244
|
return assertValidation(
|
|
243
|
-
clearedToEmpty(body
|
|
245
|
+
clearedToEmpty(body),
|
|
244
246
|
(value) => settingsPatchSchema.parse(value),
|
|
245
|
-
|
|
247
|
+
(error) =>
|
|
248
|
+
error instanceof z.ZodError ? invalidSettings(error) : INVALID_SETTINGS,
|
|
246
249
|
);
|
|
247
250
|
}
|
|
248
251
|
|
|
@@ -296,7 +299,8 @@ export class MailingSettingsController extends Controller(
|
|
|
296
299
|
return assertValidation(
|
|
297
300
|
raw,
|
|
298
301
|
(value) => retentionDaysSchema.parse(value),
|
|
299
|
-
|
|
302
|
+
(error) =>
|
|
303
|
+
error instanceof z.ZodError ? invalidSettings(error) : INVALID_SETTINGS,
|
|
300
304
|
);
|
|
301
305
|
}
|
|
302
306
|
|
|
@@ -307,12 +311,12 @@ export class MailingSettingsController extends Controller(
|
|
|
307
311
|
@Get("/category-usage")
|
|
308
312
|
async categoryUsage(): Promise<{ counts: Record<string, number> }> {
|
|
309
313
|
const templates = await this.templates.getAll();
|
|
310
|
-
const counts
|
|
314
|
+
const counts = new Map<string, number>();
|
|
311
315
|
for (const template of templates) {
|
|
312
316
|
if (!template.category || template.status === ARCHIVED_STATUS) continue;
|
|
313
|
-
counts
|
|
317
|
+
counts.set(template.category, (counts.get(template.category) ?? 0) + 1);
|
|
314
318
|
}
|
|
315
|
-
return { counts };
|
|
319
|
+
return { counts: Object.fromEntries(counts) };
|
|
316
320
|
}
|
|
317
321
|
|
|
318
322
|
/**
|
|
@@ -18,6 +18,7 @@ import { API_BASE_PATH, HTTP_CONFLICT, HTTP_NOT_FOUND } from "../constants";
|
|
|
18
18
|
import { SuppressionModel } from "../db";
|
|
19
19
|
import { SuppressionsPageController } from "../pages/suppressions";
|
|
20
20
|
import { suppressAddress } from "../services/suppressions";
|
|
21
|
+
import { zodIssuesJson } from "./zod-json";
|
|
21
22
|
|
|
22
23
|
const INVALID_SUPPRESSION = "$dms_mailing.suppressions.errors.invalid";
|
|
23
24
|
const ALREADY_SUPPRESSED = "$dms_mailing.suppressions.errors.already";
|
|
@@ -32,12 +33,6 @@ const manualSuppressionSchema = z.object({
|
|
|
32
33
|
note: z.string().trim().max(MAX_NOTE_LENGTH).optional(),
|
|
33
34
|
});
|
|
34
35
|
|
|
35
|
-
/** A refused entry: the generic message, and each issue under its field. */
|
|
36
|
-
function invalidSuppression(error: unknown): unknown {
|
|
37
|
-
if (!(error instanceof z.ZodError)) return INVALID_SUPPRESSION;
|
|
38
|
-
return { message: INVALID_SUPPRESSION, issues: error.issues };
|
|
39
|
-
}
|
|
40
|
-
|
|
41
36
|
/** An address already on the list, told under the address field. */
|
|
42
37
|
const alreadySuppressed = () => ({
|
|
43
38
|
message: ALREADY_SUPPRESSED,
|
|
@@ -56,13 +51,22 @@ export class SuppressionsController extends Controller(
|
|
|
56
51
|
@TenantScopedModel(SuppressionModel)
|
|
57
52
|
declare suppressions: SuppressionModel;
|
|
58
53
|
|
|
59
|
-
/**
|
|
54
|
+
/**
|
|
55
|
+
* Puts an address on the list by hand: nothing is sent to it any more. A
|
|
56
|
+
* refused entry answers the generic message, and each issue under its field.
|
|
57
|
+
*/
|
|
60
58
|
@Post("")
|
|
61
59
|
async add(@JSONBody() body: unknown): Promise<{ added: true }> {
|
|
62
60
|
const input = assertValidation(
|
|
63
61
|
body ?? {},
|
|
64
62
|
(value) => manualSuppressionSchema.parse(value),
|
|
65
|
-
|
|
63
|
+
(error) => {
|
|
64
|
+
if (!(error instanceof z.ZodError)) return INVALID_SUPPRESSION;
|
|
65
|
+
return {
|
|
66
|
+
message: INVALID_SUPPRESSION,
|
|
67
|
+
issues: zodIssuesJson(error.issues),
|
|
68
|
+
};
|
|
69
|
+
},
|
|
66
70
|
);
|
|
67
71
|
const isAdded = await suppressAddress(getRequestTenantId(this.ctx), {
|
|
68
72
|
email: input.email,
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { MailingTemplate } from "../db";
|
|
2
|
+
import type { TemplateChange } from "../engine";
|
|
3
|
+
import type {
|
|
4
|
+
CategoryInLocale,
|
|
5
|
+
TemplateContent,
|
|
6
|
+
VariableDefinition,
|
|
7
|
+
} from "../types";
|
|
8
|
+
import type { JsonObject } from "../types/json";
|
|
9
|
+
|
|
10
|
+
export interface TemplateSender {
|
|
11
|
+
name: string;
|
|
12
|
+
email: string;
|
|
13
|
+
replyTo: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface TemplateContentResponse {
|
|
17
|
+
/** The template, its name in the reader's language. */
|
|
18
|
+
template: MailingTemplate;
|
|
19
|
+
/** The content the editor works on: the draft, else the published one. */
|
|
20
|
+
content: TemplateContent;
|
|
21
|
+
/** What customers receive; `null` when the template was never published. */
|
|
22
|
+
publishedContent: TemplateContent | null;
|
|
23
|
+
hasDraft: boolean;
|
|
24
|
+
/** Version customers receive; 0 when never published. */
|
|
25
|
+
version: number;
|
|
26
|
+
/** The draft revision a save or a publish must carry back. */
|
|
27
|
+
revision: number;
|
|
28
|
+
changes: TemplateChange[];
|
|
29
|
+
/** The declarations the editor works on: the draft's, else the published. */
|
|
30
|
+
variables: VariableDefinition[];
|
|
31
|
+
/** Paths the working content references, across every locale. */
|
|
32
|
+
detectedVariables: string[];
|
|
33
|
+
/**
|
|
34
|
+
* The contract callers send against: the published declarations and the
|
|
35
|
+
* paths the published content references; `null` when never published.
|
|
36
|
+
*/
|
|
37
|
+
publishedVariables: VariableDefinition[] | null;
|
|
38
|
+
publishedDetectedVariables: string[] | null;
|
|
39
|
+
testData: JsonObject;
|
|
40
|
+
fallbackLocale: string;
|
|
41
|
+
/** Whether a send missing a variable fails rather than goes out blank. */
|
|
42
|
+
blockOnMissingVariables: boolean;
|
|
43
|
+
categories: CategoryInLocale[];
|
|
44
|
+
sender: TemplateSender;
|
|
45
|
+
}
|
package/src/routes/templates.ts
CHANGED
|
@@ -22,7 +22,6 @@ import {
|
|
|
22
22
|
import {
|
|
23
23
|
collectContentVariablePaths,
|
|
24
24
|
resolveEmail,
|
|
25
|
-
type TemplateChange,
|
|
26
25
|
valueFormatOf,
|
|
27
26
|
} from "../engine";
|
|
28
27
|
import {
|
|
@@ -31,7 +30,7 @@ import {
|
|
|
31
30
|
type TemplateFields,
|
|
32
31
|
TemplateModel,
|
|
33
32
|
} from "../db";
|
|
34
|
-
import { saveDetails } from "../data/templates-table";
|
|
33
|
+
import { INVALID_TEMPLATE, saveDetails } from "../data/templates-table";
|
|
35
34
|
import { TemplatesPageController } from "../pages/templates";
|
|
36
35
|
import { forAudience, statsWindowStart } from "../services/metrics";
|
|
37
36
|
import { refreshTemplateAttention } from "../services/template-attention";
|
|
@@ -75,18 +74,18 @@ import {
|
|
|
75
74
|
import {
|
|
76
75
|
BUSINESS_AUDIENCE,
|
|
77
76
|
type CategoryInLocale,
|
|
78
|
-
type TemplateContent,
|
|
79
77
|
type TemplateStatus,
|
|
80
|
-
type VariableDefinition,
|
|
81
78
|
} from "../types";
|
|
82
79
|
import {
|
|
83
80
|
duplicateSchema,
|
|
84
81
|
previewSchema,
|
|
85
82
|
publishSchema,
|
|
86
83
|
saveContentSchema,
|
|
84
|
+
templateDetailsSchema,
|
|
87
85
|
testDataSchema,
|
|
88
86
|
variablesSchema,
|
|
89
87
|
} from "../validation/templates.schema";
|
|
88
|
+
import type { TemplateContentResponse } from "./template-content";
|
|
90
89
|
|
|
91
90
|
const STARTER_NOT_FOUND = "$dms_mailing.errors.starter_not_found";
|
|
92
91
|
const INVALID_CONTENT = "$dms_mailing.errors.invalid_content";
|
|
@@ -125,43 +124,6 @@ export interface CategoriesResponse {
|
|
|
125
124
|
categories: CategoryInLocale[];
|
|
126
125
|
}
|
|
127
126
|
|
|
128
|
-
export interface TemplateSender {
|
|
129
|
-
name: string;
|
|
130
|
-
email: string;
|
|
131
|
-
replyTo: string;
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
export interface TemplateContentResponse {
|
|
135
|
-
/** The template, its name in the reader's language. */
|
|
136
|
-
template: MailingTemplate;
|
|
137
|
-
/** The content the editor works on: the draft, else the published one. */
|
|
138
|
-
content: TemplateContent;
|
|
139
|
-
/** What customers receive; `null` when the template was never published. */
|
|
140
|
-
publishedContent: TemplateContent | null;
|
|
141
|
-
hasDraft: boolean;
|
|
142
|
-
/** Version customers receive; 0 when never published. */
|
|
143
|
-
version: number;
|
|
144
|
-
/** The draft revision a save or a publish must carry back. */
|
|
145
|
-
revision: number;
|
|
146
|
-
changes: TemplateChange[];
|
|
147
|
-
/** The declarations the editor works on: the draft's, else the published. */
|
|
148
|
-
variables: VariableDefinition[];
|
|
149
|
-
/** Paths the working content references, across every locale. */
|
|
150
|
-
detectedVariables: string[];
|
|
151
|
-
/**
|
|
152
|
-
* The contract callers send against: the published declarations and the
|
|
153
|
-
* paths the published content references; `null` when never published.
|
|
154
|
-
*/
|
|
155
|
-
publishedVariables: VariableDefinition[] | null;
|
|
156
|
-
publishedDetectedVariables: string[] | null;
|
|
157
|
-
testData: Record<string, unknown>;
|
|
158
|
-
fallbackLocale: string;
|
|
159
|
-
/** Whether a send missing a variable fails rather than goes out blank. */
|
|
160
|
-
blockOnMissingVariables: boolean;
|
|
161
|
-
categories: CategoryInLocale[];
|
|
162
|
-
sender: TemplateSender;
|
|
163
|
-
}
|
|
164
|
-
|
|
165
127
|
export interface StarterSummary {
|
|
166
128
|
id: string;
|
|
167
129
|
name: string;
|
|
@@ -221,9 +183,9 @@ export class TemplatesController extends Controller(
|
|
|
221
183
|
}
|
|
222
184
|
|
|
223
185
|
@Get("/starters")
|
|
224
|
-
starters()
|
|
186
|
+
starters() {
|
|
225
187
|
return {
|
|
226
|
-
starters: STARTER_TEMPLATES.map((starter) => ({
|
|
188
|
+
starters: STARTER_TEMPLATES.map((starter): StarterSummary => ({
|
|
227
189
|
id: starter.id,
|
|
228
190
|
name: starter.name,
|
|
229
191
|
slug: starter.slug,
|
|
@@ -352,7 +314,7 @@ export class TemplatesController extends Controller(
|
|
|
352
314
|
(value) => saveContentSchema.parse(value),
|
|
353
315
|
() => INVALID_CONTENT,
|
|
354
316
|
);
|
|
355
|
-
const content = input
|
|
317
|
+
const { content } = input;
|
|
356
318
|
const template = await requireTemplate(this.templates, id);
|
|
357
319
|
assertCurrentDraft(template, input.revision);
|
|
358
320
|
const variables = input.variables ?? workingVariables(template);
|
|
@@ -514,7 +476,7 @@ export class TemplatesController extends Controller(
|
|
|
514
476
|
const { content, variables } = input.content
|
|
515
477
|
? {
|
|
516
478
|
...workingSnapshot(template),
|
|
517
|
-
content: input.content
|
|
479
|
+
content: input.content,
|
|
518
480
|
}
|
|
519
481
|
: await this.previewSnapshot(template, input.version);
|
|
520
482
|
const settings = await getSettings(this.tenantId);
|
|
@@ -542,9 +504,9 @@ export class TemplatesController extends Controller(
|
|
|
542
504
|
version: "draft" | "published" | number | undefined,
|
|
543
505
|
): Promise<TemplateDraft> | TemplateDraft {
|
|
544
506
|
if (version === "published") return publishedSnapshot(template);
|
|
545
|
-
if (
|
|
546
|
-
return
|
|
547
|
-
return
|
|
507
|
+
if (version === undefined || version === "draft")
|
|
508
|
+
return workingSnapshot(template);
|
|
509
|
+
return snapshotOfVersion(this.tenantId, template, version);
|
|
548
510
|
}
|
|
549
511
|
|
|
550
512
|
/**
|
|
@@ -559,7 +521,12 @@ export class TemplatesController extends Controller(
|
|
|
559
521
|
@JSONBody() body: unknown,
|
|
560
522
|
) {
|
|
561
523
|
const template = await requireTemplate(this.templates, id);
|
|
562
|
-
|
|
524
|
+
const details = assertValidation(
|
|
525
|
+
body,
|
|
526
|
+
(value) => templateDetailsSchema.parse(value),
|
|
527
|
+
() => INVALID_TEMPLATE,
|
|
528
|
+
);
|
|
529
|
+
await saveDetails(this.ctx, template, details, this.author);
|
|
563
530
|
return { id };
|
|
564
531
|
}
|
|
565
532
|
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { HTTPBody } from "@antelopejs/interface-api";
|
|
2
|
+
import type { ZodIssue } from "zod";
|
|
3
|
+
|
|
4
|
+
function isJson(value: unknown): value is HTTPBody {
|
|
5
|
+
if (
|
|
6
|
+
value === null ||
|
|
7
|
+
typeof value === "string" ||
|
|
8
|
+
typeof value === "number" ||
|
|
9
|
+
typeof value === "boolean"
|
|
10
|
+
) {
|
|
11
|
+
return true;
|
|
12
|
+
}
|
|
13
|
+
if (Array.isArray(value)) return value.every(isJson);
|
|
14
|
+
return typeof value === "object" && Object.values(value).every(isJson);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* zod's issues as the JSON value `JSON.stringify` writes for them, so a body
|
|
19
|
+
* holding it serializes byte for byte as one holding the issues. A `ZodError`
|
|
20
|
+
* nested in an issue (`unionErrors`, `argumentsError`, `returnTypeError`)
|
|
21
|
+
* becomes its enumerable fields, `issues` and `name`.
|
|
22
|
+
*/
|
|
23
|
+
export function zodIssuesJson(issues: readonly ZodIssue[]): HTTPBody {
|
|
24
|
+
const json: unknown = JSON.parse(JSON.stringify(issues));
|
|
25
|
+
if (!isJson(json))
|
|
26
|
+
throw new TypeError("zod issues did not serialize to JSON");
|
|
27
|
+
return json;
|
|
28
|
+
}
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
type TemplateAttentionReason,
|
|
12
12
|
} from "../types";
|
|
13
13
|
import { isDeliveryUntracked, type ProviderTracking } from "./metrics";
|
|
14
|
+
import { isNumber } from "../validation/guards";
|
|
14
15
|
|
|
15
16
|
export type AttentionTone = "error" | "warning" | "neutral";
|
|
16
17
|
|
|
@@ -238,7 +239,7 @@ function namesOf(templates: MailingTemplate[]): ComposedTextParam {
|
|
|
238
239
|
};
|
|
239
240
|
}
|
|
240
241
|
|
|
241
|
-
function problemBreakdown(problems: SendSummary[])
|
|
242
|
+
function problemBreakdown(problems: SendSummary[]) {
|
|
242
243
|
const countOf = (status: string) =>
|
|
243
244
|
problems.filter((send) => send.status === status).length;
|
|
244
245
|
return {
|
|
@@ -334,9 +335,9 @@ const COUNT_PARAM = "count";
|
|
|
334
335
|
function feedParams(
|
|
335
336
|
item: AttentionItem,
|
|
336
337
|
): Record<string, ComposedTextParam> | undefined {
|
|
337
|
-
const params
|
|
338
|
+
const params = { ...item.params };
|
|
338
339
|
const count = item.count ?? item.params?.[COUNT_PARAM];
|
|
339
|
-
if (
|
|
340
|
+
if (isNumber(count)) {
|
|
340
341
|
params[COUNT_PARAM] = { type: "count", value: count };
|
|
341
342
|
}
|
|
342
343
|
return Object.keys(params).length ? params : undefined;
|
|
@@ -374,20 +375,21 @@ type TemplateAttentionRule = (
|
|
|
374
375
|
context: TemplateAttentionContext,
|
|
375
376
|
) => boolean;
|
|
376
377
|
|
|
377
|
-
const TEMPLATE_ATTENTION_RULES
|
|
378
|
+
const TEMPLATE_ATTENTION_RULES = new Map<
|
|
378
379
|
TemplateAttentionReason,
|
|
379
380
|
TemplateAttentionRule
|
|
380
|
-
>
|
|
381
|
-
stale_draft
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
381
|
+
>([
|
|
382
|
+
["stale_draft", (template, context) => isStaleDraft(template, context.now)],
|
|
383
|
+
[
|
|
384
|
+
"never_sent",
|
|
385
|
+
(template, context) =>
|
|
386
|
+
template.status === "live" && !context.sentSlugs.has(template.slug),
|
|
387
|
+
],
|
|
388
|
+
[
|
|
389
|
+
"missing_locale",
|
|
390
|
+
(template, context) => isMissingLocale(template, context.knownLocales),
|
|
391
|
+
],
|
|
392
|
+
]);
|
|
391
393
|
|
|
392
394
|
/**
|
|
393
395
|
* Why a template needs a look, in a stable order: a draft never published and
|
|
@@ -398,7 +400,7 @@ export function templateAttentionReasons(
|
|
|
398
400
|
template: MailingTemplate,
|
|
399
401
|
context: TemplateAttentionContext,
|
|
400
402
|
): TemplateAttentionReason[] {
|
|
401
|
-
return
|
|
402
|
-
|
|
403
|
-
|
|
403
|
+
return [...TEMPLATE_ATTENTION_RULES]
|
|
404
|
+
.filter(([, applies]) => applies(template, context))
|
|
405
|
+
.map(([reason]) => reason);
|
|
404
406
|
}
|