@antelopejs/dms-mailing 0.9.1 → 0.10.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.
Files changed (83) hide show
  1. package/README.md +11 -11
  2. package/dist/data/read-only-routes.d.ts +14 -1
  3. package/dist/data/sends-table.d.ts +14 -1
  4. package/dist/data/suppressions-table.d.ts +14 -1
  5. package/dist/data/templates-table.d.ts +15 -2
  6. package/dist/db/models/send-events.model.d.ts +3 -3
  7. package/dist/db/models/sends.model.d.ts +6 -4
  8. package/dist/db/models/sends.model.js +26 -2
  9. package/dist/db/models/sends.model.js.map +1 -1
  10. package/dist/db/models/settings.model.d.ts +3 -3
  11. package/dist/db/models/suppressions.model.d.ts +3 -3
  12. package/dist/db/models/template-versions.model.d.ts +3 -3
  13. package/dist/db/models/template-versions.model.js +2 -3
  14. package/dist/db/models/template-versions.model.js.map +1 -1
  15. package/dist/db/models/templates.model.d.ts +4 -3
  16. package/dist/db/models/templates.model.js +14 -6
  17. package/dist/db/models/templates.model.js.map +1 -1
  18. package/dist/db/models/webhook-status.model.d.ts +3 -3
  19. package/dist/db/tables/templates.table.d.ts +4 -1
  20. package/dist/hooks/tenant-export.js +5 -1
  21. package/dist/hooks/tenant-export.js.map +1 -1
  22. package/dist/pages/editor.js +1 -1
  23. package/dist/pages/editor.js.map +1 -1
  24. package/dist/routes/metrics.js +1 -2
  25. package/dist/routes/metrics.js.map +1 -1
  26. package/dist/routes/sends.js +2 -1
  27. package/dist/routes/sends.js.map +1 -1
  28. package/dist/routes/templates.d.ts +2 -2
  29. package/dist/routes/templates.js +4 -4
  30. package/dist/routes/templates.js.map +1 -1
  31. package/dist/services/attention.d.ts +1 -1
  32. package/dist/services/attention.js +3 -3
  33. package/dist/services/attention.js.map +1 -1
  34. package/dist/services/metrics.js +5 -1
  35. package/dist/services/metrics.js.map +1 -1
  36. package/dist/services/settings.js +2 -1
  37. package/dist/services/settings.js.map +1 -1
  38. package/dist/services/template-attention.d.ts +1 -1
  39. package/dist/services/template-attention.js +2 -4
  40. package/dist/services/template-attention.js.map +1 -1
  41. package/dist/services/templates.js +10 -8
  42. package/dist/services/templates.js.map +1 -1
  43. package/dist/validation/declarations.schema.d.ts +1 -1
  44. package/dist/validation/stored.schema.d.ts +12 -0
  45. package/dist/validation/stored.schema.js +198 -0
  46. package/dist/validation/stored.schema.js.map +1 -0
  47. package/frontend-vue/app/composables/useEditorContext.ts +1 -1
  48. package/frontend-vue/build/backend-boundary.ts +7 -2
  49. package/frontend-vue/dms.frontend.ts +1 -4
  50. package/frontend-vue/eslint.config.mjs +9 -1
  51. package/frontend-vue/package.json +1 -1
  52. package/frontend-vue/pnpm-lock.yaml +24 -7
  53. package/frontend-vue/tests/backend-boundary.test.ts +4 -4
  54. package/frontend-vue/tests/column-displays.test.ts +13 -12
  55. package/frontend-vue/tests/footer-block.test.ts +1 -1
  56. package/frontend-vue/tests/template-gallery-display.test.ts +6 -5
  57. package/frontend-vue/tests/template-overlays.test.ts +4 -12
  58. package/frontend-vue/tsconfig.typecheck.json +1 -2
  59. package/package.json +16 -16
  60. package/src/db/models/sends.model.ts +34 -4
  61. package/src/db/models/template-versions.model.ts +8 -6
  62. package/src/db/models/templates.model.ts +25 -9
  63. package/src/hooks/tenant-export.ts +5 -2
  64. package/src/pages/editor.ts +1 -1
  65. package/src/routes/metrics.ts +1 -2
  66. package/src/routes/sends.ts +2 -3
  67. package/src/routes/templates.ts +9 -9
  68. package/src/services/attention.ts +6 -6
  69. package/src/services/metrics.ts +3 -1
  70. package/src/services/settings.ts +9 -3
  71. package/src/services/template-attention.ts +4 -5
  72. package/src/services/templates.ts +35 -16
  73. package/src/validation/stored.schema.ts +257 -0
  74. /package/frontend-vue/app/components/{Editor.vue → TemplateEditor.vue} +0 -0
  75. /package/frontend-vue/app/components/blocks/{Button.vue → BlockButton.vue} +0 -0
  76. /package/frontend-vue/app/components/blocks/{Code.vue → BlockCode.vue} +0 -0
  77. /package/frontend-vue/app/components/blocks/{Divider.vue → BlockDivider.vue} +0 -0
  78. /package/frontend-vue/app/components/blocks/{Footer.vue → BlockFooter.vue} +0 -0
  79. /package/frontend-vue/app/components/blocks/{Heading.vue → BlockHeading.vue} +0 -0
  80. /package/frontend-vue/app/components/blocks/{Hero.vue → BlockHero.vue} +0 -0
  81. /package/frontend-vue/app/components/blocks/{List.vue → BlockList.vue} +0 -0
  82. /package/frontend-vue/app/components/blocks/{Paragraph.vue → BlockParagraph.vue} +0 -0
  83. /package/frontend-vue/app/components/blocks/{Total.vue → BlockTotal.vue} +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@antelopejs/dms-mailing",
3
- "version": "0.9.1",
3
+ "version": "0.10.0",
4
4
  "description": "E-mail templates for the AntelopeJS DMS: a block editor with drafts and versions, a send log, delivery metrics and a suppression list.",
5
5
  "keywords": [
6
6
  "antelopejs",
@@ -46,23 +46,23 @@
46
46
  "registry": "https://registry.npmjs.org/"
47
47
  },
48
48
  "dependencies": {
49
- "@antelopejs/interface-api": ">=0.3.0 <1.0.0",
50
- "@antelopejs/interface-api-util": ">=0.3.0 <1.0.0",
51
- "@antelopejs/interface-core": ">=0.4.0 <1.0.0",
52
- "@antelopejs/interface-data-api": ">=0.6.1 <1.0.0",
53
- "@antelopejs/interface-database": ">=0.3.0 <1.0.0",
54
- "@antelopejs/interface-database-decorators": ">=0.2.0 <1.0.0",
55
- "@antelopejs/interface-dms": ">=0.12.0 <1.0.0",
56
- "@antelopejs/interface-dms-mailing": ">=0.10.0 <0.11.0",
57
- "@antelopejs/interface-email": ">=0.0.6 <1.0.0",
49
+ "@antelopejs/interface-api": ">=0.3.3 <0.4.0",
50
+ "@antelopejs/interface-api-util": ">=0.3.2 <0.4.0",
51
+ "@antelopejs/interface-core": ">=0.5.0 <0.6.0",
52
+ "@antelopejs/interface-data-api": ">=0.8.0 <0.9.0",
53
+ "@antelopejs/interface-database": ">=0.4.0 <0.5.0",
54
+ "@antelopejs/interface-database-decorators": ">=0.5.0 <0.6.0",
55
+ "@antelopejs/interface-dms": ">=0.14.0 <0.15.0",
56
+ "@antelopejs/interface-dms-mailing": ">=0.11.0 <0.12.0",
57
+ "@antelopejs/interface-email": ">=0.2.0 <0.3.0",
58
58
  "node-cron": "^4.2.1",
59
59
  "zod": "~3.24.2"
60
60
  },
61
61
  "devDependencies": {
62
- "@antelopejs/core": "^1.21.0",
63
- "@antelopejs/dms": ">=0.19.0 <1.0.0",
64
- "@antelopejs/mongodb": "^1.6.2",
65
- "@antelopejs/tooling-configs": "^0.3.3",
62
+ "@antelopejs/core": "~1.23.2",
63
+ "@antelopejs/dms": ">=0.20.0 <0.21.0",
64
+ "@antelopejs/mongodb": "~1.8.1",
65
+ "@antelopejs/tooling-configs": ">=0.7.1 <0.8.0",
66
66
  "@types/chai": "^4.3.20",
67
67
  "@types/mocha": "^10",
68
68
  "@types/node": "^24.0.0",
@@ -77,7 +77,7 @@
77
77
  "typescript": "^6.0.3"
78
78
  },
79
79
  "optionalDependencies": {
80
- "@antelopejs/interface-dms-automation": ">=0.6.0 <1.0.0"
80
+ "@antelopejs/interface-dms-automation": ">=0.6.0 <0.7.0"
81
81
  },
82
82
  "antelopeJs": {
83
83
  "baseUrl": "dist",
@@ -98,7 +98,7 @@
98
98
  "typecheck": "tsc --noEmit",
99
99
  "knip": "knip --dependencies",
100
100
  "knip:all": "knip",
101
- "release": "pnpm --filter @antelopejs/interface-dms-mailing build && pnpm --dir ../.. install --frozen-lockfile && release-it",
101
+ "release": "pnpm --dir ../.. run typecheck && pnpm --filter @antelopejs/interface-dms-mailing build && pnpm --dir ../.. install --frozen-lockfile && release-it",
102
102
  "test": "pnpm run test:backend && pnpm run test:layer",
103
103
  "test:backend": "([ -n \"$DMS_PREBUILT\" ] || pnpm run build) && ajs module test .",
104
104
  "test:layer": "cd frontend-vue && pnpm test",
@@ -24,8 +24,28 @@ export type ImportedSendRow = Omit<
24
24
 
25
25
  const MAX_MUTATION_ATTEMPTS = 5;
26
26
 
27
- /** A send as the metrics read it: everything but the rendered variables. */
28
- export type SendSummary = Omit<MailingSend, "json_variables">;
27
+ const SEND_SUMMARY_FIELDS = [
28
+ "_id",
29
+ "createdAt",
30
+ "templateSlug",
31
+ "recipientEmail",
32
+ "status",
33
+ "stage",
34
+ "latencyMs",
35
+ "opens",
36
+ "clicks",
37
+ "isTest",
38
+ "error",
39
+ "source",
40
+ "locale",
41
+ "requestedLocale",
42
+ ] as const;
43
+
44
+ /** A send as the figures read it: only the fields they count. */
45
+ export type SendSummary = Pick<
46
+ MailingSend,
47
+ (typeof SEND_SUMMARY_FIELDS)[number]
48
+ >;
29
49
 
30
50
  type SendActivity = Pick<
31
51
  MailingSend,
@@ -122,7 +142,7 @@ export class SendModel extends BasicDataModel(
122
142
  return this.table
123
143
  .filter((row) => row.key("createdAt").ge(from))
124
144
  .filter((row) => row.key("createdAt").lt(to))
125
- .without("json_variables")
145
+ .pluck(...SEND_SUMMARY_FIELDS)
126
146
  .run();
127
147
  }
128
148
 
@@ -259,7 +279,7 @@ export class SendModel extends BasicDataModel(
259
279
  return this.table
260
280
  .getAll(templateSlug, "templateSlug")
261
281
  .filter((row) => row.key("createdAt").ge(from))
262
- .without("json_variables")
282
+ .pluck(...SEND_SUMMARY_FIELDS)
263
283
  .run();
264
284
  }
265
285
 
@@ -270,4 +290,14 @@ export class SendModel extends BasicDataModel(
270
290
  .count()
271
291
  .run();
272
292
  }
293
+
294
+ /** The slugs of the real sends made from `from` until `to`. */
295
+ listSentSlugs(from: Date, to: Date): Promise<string[]> {
296
+ return this.table
297
+ .filter((row) => row.key("createdAt").ge(from))
298
+ .filter((row) => row.key("createdAt").lt(to))
299
+ .filter((row) => row.key("isTest").ne(true))
300
+ .distinct("templateSlug")
301
+ .run();
302
+ }
273
303
  }
@@ -4,6 +4,10 @@ import {
4
4
  type MailingTemplate,
5
5
  MailingTemplateVersion,
6
6
  } from "../tables";
7
+ import {
8
+ parseStored,
9
+ storedVersionRowSchema,
10
+ } from "../../validation/stored.schema";
7
11
  import { TemplateModel } from "./templates.model";
8
12
 
9
13
  /** The latest committed history row, even before its separate insert completes. */
@@ -11,13 +15,11 @@ export function committedVersionOf(
11
15
  template: Pick<MailingTemplate, "json_published_version">,
12
16
  ): MailingTemplateVersion | undefined {
13
17
  if (!template.json_published_version) return undefined;
14
- // SAFETY: json_published_version is written only by publishSnapshot and publishedAtCreation as the
15
- // JSON.stringify of the version row they insert; its publishedAt reads back as a string, and the next line restores the Date.
16
- const row = JSON.parse(
18
+ return parseStored(
17
19
  template.json_published_version,
18
- ) as MailingTemplateVersion;
19
- row.publishedAt = new Date(row.publishedAt);
20
- return row;
20
+ storedVersionRowSchema,
21
+ "a version row",
22
+ );
21
23
  }
22
24
 
23
25
  export class TemplateVersionModel extends BasicDataModel(
@@ -35,7 +35,9 @@ export class TemplateModel extends BasicDataModel(
35
35
  ) {
36
36
  /** The row of `fields`, its name written in every language it holds. */
37
37
  static fromFields(fields: TemplateFields): MailingTemplate {
38
- return TemplateModel.fromPlainData(fields).localize("*", ["name"]);
38
+ const row = TemplateModel.fromPlainData(fields);
39
+ row.localize("*", ["name"]);
40
+ return row;
39
41
  }
40
42
 
41
43
  /**
@@ -57,19 +59,30 @@ export class TemplateModel extends BasicDataModel(
57
59
  return this.insert(TemplateModel.fromFields(fields));
58
60
  }
59
61
 
62
+ /**
63
+ * The stored form of a patch. The row's modifier container (`_internal`,
64
+ * where the localized name lives) comes out empty when the patch has no
65
+ * name, and the database merges a row shallowly, so that empty container
66
+ * would wipe the stored name: it is left out then.
67
+ */
68
+ private static storedPatch(row: MailingTemplate, fields: TemplateFields) {
69
+ const stored = TemplateModel.toDatabase(row);
70
+ if (fields.name === undefined) delete stored[MODIFIER_CONTAINER];
71
+ return stored;
72
+ }
73
+
60
74
  /**
61
75
  * Updates a template, a name given holding every language. Every write of
62
- * a template goes through here rather than `update`: an update event leaves
63
- * the row's modifier container (`_internal`, where the localized name
64
- * lives) empty when the patch has no name, and the database merges a row
65
- * shallowly, so that empty container would wipe the stored name.
76
+ * a template goes through here rather than `update`, which would store the
77
+ * patch's empty modifier container over the name.
66
78
  */
67
79
  updateFields(id: string, fields: TemplateFields): Promise<number> {
68
80
  const row = TemplateModel.fromFields(fields);
69
81
  triggerEvent(row, "update");
70
- const stored = TemplateModel.toDatabase(row);
71
- if (fields.name === undefined) delete stored[MODIFIER_CONTAINER];
72
- return this.table.get(id).update(stored).run();
82
+ return this.table
83
+ .get(id)
84
+ .update(TemplateModel.storedPatch(row, fields))
85
+ .run();
73
86
  }
74
87
 
75
88
  /** Publishes only over the exact semantic revision that was inspected. */
@@ -77,7 +90,10 @@ export class TemplateModel extends BasicDataModel(
77
90
  template: MailingTemplate,
78
91
  patch: TemplateFields,
79
92
  ): Promise<boolean> {
80
- const stored = TemplateModel.toDatabase(TemplateModel.fromFields(patch));
93
+ const stored = TemplateModel.storedPatch(
94
+ TemplateModel.fromFields(patch),
95
+ patch,
96
+ );
81
97
  const outcome = await this.table
82
98
  .atomicMutation(template._id, {
83
99
  type: "update",
@@ -102,8 +102,11 @@ async function loadPage<T extends object>(
102
102
 
103
103
  /** A template as exported: its name in every language. */
104
104
  const templateCodec: RowCodec<MailingTemplate> = {
105
- fromDatabase: (row) =>
106
- TemplateModel.fromDatabase(row)?.localize("*", ["name"]),
105
+ fromDatabase: (stored) => {
106
+ const row = TemplateModel.fromDatabase(stored);
107
+ row?.localize("*", ["name"]);
108
+ return row;
109
+ },
107
110
  };
108
111
 
109
112
  export function exportedCollections(tenantId: string): ExportedCollection[] {
@@ -25,7 +25,7 @@ export class MailingEditorController extends PageController(
25
25
  },
26
26
  DefaultLayout({ fullWidth: true, hideHeader: true, fillHeight: true }),
27
27
  ) {
28
- static editor = CustomComponent("MailingEditor").meta({
28
+ static editor = CustomComponent("MailingTemplateEditor").meta({
29
29
  name: "$dms_mailing.permissions.editor.name",
30
30
  description: "$dms_mailing.permissions.editor.description",
31
31
  icon: "i-ph-pencil-simple",
@@ -324,11 +324,10 @@ export class MetricsController extends Controller(`${API_BASE_PATH}/metrics`) {
324
324
  findSettings(this.tenantId),
325
325
  ]);
326
326
  const sends = forAudience(rows, this.audience);
327
- const excluded = rows.filter((row) => !sends.includes(row));
328
327
  const items = buildAttentionItems({
329
328
  templates,
330
329
  sends,
331
- excluded,
330
+ excludedCount: rows.length - sends.length,
332
331
  windowEnd: range.to,
333
332
  tracking,
334
333
  isSenderSet: Boolean(settings?.senderEmail),
@@ -70,6 +70,7 @@ import {
70
70
  } from "../services/templates";
71
71
  import type { SendStatus, SuppressionSummary, TemplateStatus } from "../types";
72
72
  import type { JsonObject } from "../types/json";
73
+ import { parseJsonObject } from "../validation/json";
73
74
  import {
74
75
  type previewVersionSchema,
75
76
  realSendSchema,
@@ -578,9 +579,7 @@ function requestedVersionOf(
578
579
  }
579
580
 
580
581
  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;
582
+ return parseJsonObject(send.json_variables || "{}");
584
583
  }
585
584
 
586
585
  function summarize(template: MailingTemplate): SendTemplateSummary {
@@ -6,6 +6,7 @@ import {
6
6
  Parameter,
7
7
  Post,
8
8
  Put,
9
+ type QueryValue,
9
10
  type RequestContext,
10
11
  } from "@antelopejs/interface-api";
11
12
  import { assert, assertValidation } from "@antelopejs/interface-api-util";
@@ -32,7 +33,7 @@ import {
32
33
  } from "../db";
33
34
  import { INVALID_TEMPLATE, saveDetails } from "../data/templates-table";
34
35
  import { TemplatesPageController } from "../pages/templates";
35
- import { forAudience, statsWindowStart } from "../services/metrics";
36
+ import { forAudience, groupBy, statsWindowStart } from "../services/metrics";
36
37
  import { refreshTemplateAttention } from "../services/template-attention";
37
38
  import { renderEmailHtml } from "../services/render";
38
39
  import { categoriesIn } from "../services/categories";
@@ -203,14 +204,14 @@ export class TemplatesController extends Controller(
203
204
  @Get("/starters/:id/preview")
204
205
  async starterPreview(
205
206
  @Parameter("id", "param") id: string,
206
- @Parameter("locale", "query") requested?: string,
207
+ @Parameter("locale", "query") requested: QueryValue,
207
208
  ) {
208
209
  const starter = findStarter(id);
209
210
  assert(starter, HTTP_NOT_FOUND, STARTER_NOT_FOUND);
210
211
  const settings = await getSettings(this.tenantId);
211
212
  const locale = pickLocale(
212
213
  starter.content,
213
- requested || undefined,
214
+ requested === true || requested === "" ? undefined : requested,
214
215
  settings.fallbackLocale,
215
216
  );
216
217
  const localeContent = localeContentOf(starter.content, locale);
@@ -234,14 +235,13 @@ export class TemplatesController extends Controller(
234
235
  this.templates.getAll(),
235
236
  this.sends.listBetween(statsWindowStart(), new Date()),
236
237
  ]);
237
- const sends = forAudience(rows, BUSINESS_AUDIENCE);
238
+ const bySlug = groupBy(
239
+ forAudience(rows, BUSINESS_AUDIENCE),
240
+ (send) => send.templateSlug,
241
+ );
238
242
  return {
239
243
  items: templates.map((template) =>
240
- statsOf(
241
- template._id,
242
- template.slug,
243
- sends.filter((send) => send.templateSlug === template.slug),
244
- ),
244
+ statsOf(template._id, template.slug, bySlug.get(template.slug) ?? []),
245
245
  ),
246
246
  };
247
247
  }
@@ -124,13 +124,13 @@ function unusedLiveItem(window: AttentionWindow): AttentionItem[] {
124
124
 
125
125
  /**
126
126
  * Every send the Overview window holds, split the way the business view counts
127
- * them: `sends` are the ones behind the figures, `excluded` the test sends the
128
- * business audience drops.
127
+ * them: `sends` are the ones behind the figures, `excludedCount` the test sends
128
+ * the business audience drops.
129
129
  */
130
130
  export interface AttentionWindow {
131
131
  templates: MailingTemplate[];
132
132
  sends: SendSummary[];
133
- excluded: SendSummary[];
133
+ excludedCount: number;
134
134
  /** When the window closes. */
135
135
  windowEnd: Date;
136
136
  /** What the configured provider can report; `null` when it is unreachable. */
@@ -140,7 +140,7 @@ export interface AttentionWindow {
140
140
  }
141
141
 
142
142
  function testOnlyWindowItem(window: AttentionWindow): AttentionItem[] {
143
- if (window.sends.length || !window.excluded.length) return [];
143
+ if (window.sends.length || !window.excludedCount) return [];
144
144
  return [
145
145
  {
146
146
  id: "test-only-window",
@@ -150,8 +150,8 @@ function testOnlyWindowItem(window: AttentionWindow): AttentionItem[] {
150
150
  description: "$dms_mailing.attention.test_only_window.description",
151
151
  action: "$dms_mailing.attention.test_only_window.action",
152
152
  to: SENDS_PAGE,
153
- count: window.excluded.length,
154
- params: { count: window.excluded.length },
153
+ count: window.excludedCount,
154
+ params: { count: window.excludedCount },
155
155
  },
156
156
  ];
157
157
  }
@@ -276,7 +276,9 @@ export function groupBy<T>(
276
276
  const groups = new Map<string, T[]>();
277
277
  for (const item of items) {
278
278
  const id = key(item);
279
- groups.set(id, [...(groups.get(id) ?? []), item]);
279
+ const group = groups.get(id);
280
+ if (group) group.push(item);
281
+ else groups.set(id, [item]);
280
282
  }
281
283
  return groups;
282
284
  }
@@ -10,6 +10,10 @@ import {
10
10
  } from "../types";
11
11
  import type { JsonObject } from "../types/json";
12
12
  import type { SettingsPatch } from "../validation/settings.schema";
13
+ import {
14
+ parseStored,
15
+ storedCategoriesSchema,
16
+ } from "../validation/stored.schema";
13
17
 
14
18
  export const WEBHOOK_SECRET_BYTES = 24;
15
19
 
@@ -48,9 +52,11 @@ function toValues(row: MailingSettings): MailingSettingsValues {
48
52
  replyTo: row.replyTo,
49
53
  webhookSecret: row.webhookSecret,
50
54
  webhookSecretSource: storedSecretSource(row.webhookSecretSource),
51
- // SAFETY: json_categories is written only by toRow as the JSON.stringify of categories that categoriesSchema
52
- // validated (settingsPatchSchema, applyCategory) or that the module defaults supplied.
53
- categories: JSON.parse(row.json_categories) as TemplateCategory[],
55
+ categories: parseStored(
56
+ row.json_categories,
57
+ storedCategoriesSchema,
58
+ "template categories",
59
+ ),
54
60
  };
55
61
  }
56
62
 
@@ -5,13 +5,13 @@ import {
5
5
  type TemplateAttentionFields,
6
6
  TemplateModel,
7
7
  } from "../db";
8
- import { BUSINESS_AUDIENCE, type TemplateAttentionReason } from "../types";
8
+ import type { TemplateAttentionReason } from "../types";
9
9
  import {
10
10
  knownLocalesOf,
11
11
  type TemplateAttentionContext,
12
12
  templateAttentionReasons,
13
13
  } from "./attention";
14
- import { forAudience, statsWindowStart } from "./metrics";
14
+ import { statsWindowStart } from "./metrics";
15
15
 
16
16
  /** How the stored `attention` field separates its reasons. */
17
17
  const REASON_SEPARATOR = ",";
@@ -31,14 +31,13 @@ async function attentionContext(
31
31
  templates: MailingTemplate[],
32
32
  ): Promise<TemplateAttentionContext> {
33
33
  const now = Date.now();
34
- const sends = await GetModel(SendModel, tenantId).listBetween(
34
+ const slugs = await GetModel(SendModel, tenantId).listSentSlugs(
35
35
  statsWindowStart(now),
36
36
  new Date(now),
37
37
  );
38
- const real = forAudience(sends, BUSINESS_AUDIENCE);
39
38
  return {
40
39
  now,
41
- sentSlugs: new Set(real.map((send) => send.templateSlug)),
40
+ sentSlugs: new Set(slugs),
42
41
  knownLocales: knownLocalesOf(templates),
43
42
  };
44
43
  }
@@ -28,6 +28,13 @@ import type {
28
28
  } from "../types";
29
29
  import { DEFAULT_FALLBACK_LOCALE } from "../types";
30
30
  import type { JsonObject } from "../types/json";
31
+ import { parseJsonObject } from "../validation/json";
32
+ import {
33
+ parseStored,
34
+ storedChangesSchema,
35
+ storedContentSchema,
36
+ storedVariablesSchema,
37
+ } from "../validation/stored.schema";
31
38
 
32
39
  export interface TemplateAuthor {
33
40
  name?: string;
@@ -74,8 +81,11 @@ export async function refuseTakenSlug<T>(write: Promise<T>): Promise<T> {
74
81
 
75
82
  /** The content customers receive: the last published version. */
76
83
  export function parseContent(template: MailingTemplate): TemplateContent {
77
- // SAFETY: json_content is written only as JSON.stringify of a TemplateContent that saveContentSchema validated or the module built.
78
- return JSON.parse(template.json_content || EMPTY_CONTENT) as TemplateContent;
84
+ return parseStored(
85
+ template.json_content || EMPTY_CONTENT,
86
+ storedContentSchema,
87
+ "template content",
88
+ );
79
89
  }
80
90
 
81
91
  export function hasDraft(template: MailingTemplate): boolean {
@@ -85,8 +95,11 @@ export function hasDraft(template: MailingTemplate): boolean {
85
95
  /** The content the editor works on: the draft, or the published content. */
86
96
  export function workingContent(template: MailingTemplate): TemplateContent {
87
97
  if (!template.json_draft) return parseContent(template);
88
- // SAFETY: json_draft is written only as JSON.stringify of a TemplateContent that saveContentSchema validated or the module built.
89
- return JSON.parse(template.json_draft) as TemplateContent;
98
+ return parseStored(
99
+ template.json_draft,
100
+ storedContentSchema,
101
+ "template content",
102
+ );
90
103
  }
91
104
 
92
105
  /** The version customers receive; 0 for a template never published. */
@@ -105,8 +118,11 @@ export function isPublished(template: MailingTemplate): boolean {
105
118
  export function parseVariables(
106
119
  template: MailingTemplate,
107
120
  ): VariableDefinition[] {
108
- // SAFETY: json_variables is written only as JSON.stringify of a VariableDefinition[] that variableListSchema validated or the module built.
109
- return JSON.parse(template.json_variables) as VariableDefinition[];
121
+ return parseStored(
122
+ template.json_variables,
123
+ storedVariablesSchema,
124
+ "variable declarations",
125
+ );
110
126
  }
111
127
 
112
128
  /**
@@ -118,8 +134,11 @@ export function workingVariables(
118
134
  ): VariableDefinition[] {
119
135
  if (!hasDraft(template) || !template.json_variables_draft)
120
136
  return parseVariables(template);
121
- // SAFETY: json_variables_draft is written only as JSON.stringify of a VariableDefinition[] that variableListSchema validated or the module built.
122
- return JSON.parse(template.json_variables_draft) as VariableDefinition[];
137
+ return parseStored(
138
+ template.json_variables_draft,
139
+ storedVariablesSchema,
140
+ "variable declarations",
141
+ );
123
142
  }
124
143
 
125
144
  /** A template's working state: what a publish would ship. */
@@ -175,8 +194,7 @@ export function pendingChanges(template: MailingTemplate): TemplateChange[] {
175
194
  }
176
195
 
177
196
  export function parseTestData(template: MailingTemplate): JsonObject {
178
- // SAFETY: json_test_data is written only as JSON.stringify of an object that its route or declaration schema validated, or the module built.
179
- return JSON.parse(template.json_test_data) as JsonObject;
197
+ return parseJsonObject(template.json_test_data);
180
198
  }
181
199
 
182
200
  interface FooterLabels {
@@ -297,16 +315,18 @@ export async function discardDraft(
297
315
  export function parseVersionChanges(
298
316
  row: MailingTemplateVersion,
299
317
  ): TemplateChange[] {
300
- // SAFETY: json_changes is written only as JSON.stringify of the TemplateChange[] from diffContents and diffVariables.
301
- return JSON.parse(row.json_changes) as TemplateChange[];
318
+ return parseStored(row.json_changes, storedChangesSchema, "version changes");
302
319
  }
303
320
 
304
321
  /** The declarations a published version shipped with. */
305
322
  export function parseVersionVariables(
306
323
  row: MailingTemplateVersion,
307
324
  ): VariableDefinition[] {
308
- // SAFETY: a version row's json_variables is written only as JSON.stringify of a VariableDefinition[] that variableListSchema validated or the module built.
309
- return JSON.parse(row.json_variables) as VariableDefinition[];
325
+ return parseStored(
326
+ row.json_variables,
327
+ storedVariablesSchema,
328
+ "variable declarations",
329
+ );
310
330
  }
311
331
 
312
332
  /**
@@ -461,8 +481,7 @@ export async function contentOfVersion(
461
481
  export function parseVersionContent(
462
482
  row: MailingTemplateVersion,
463
483
  ): TemplateContent {
464
- // SAFETY: a version row's json_content is written only as JSON.stringify of a TemplateContent that saveContentSchema validated.
465
- return JSON.parse(row.json_content) as TemplateContent;
484
+ return parseStored(row.json_content, storedContentSchema, "template content");
466
485
  }
467
486
 
468
487
  /**