@antelopejs/dms-mailing 0.9.2 → 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 (65) 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 +3 -3
  8. package/dist/db/models/settings.model.d.ts +3 -3
  9. package/dist/db/models/suppressions.model.d.ts +3 -3
  10. package/dist/db/models/template-versions.model.d.ts +3 -3
  11. package/dist/db/models/template-versions.model.js +2 -3
  12. package/dist/db/models/template-versions.model.js.map +1 -1
  13. package/dist/db/models/templates.model.d.ts +4 -3
  14. package/dist/db/models/templates.model.js +14 -6
  15. package/dist/db/models/templates.model.js.map +1 -1
  16. package/dist/db/models/webhook-status.model.d.ts +3 -3
  17. package/dist/db/tables/templates.table.d.ts +4 -1
  18. package/dist/hooks/tenant-export.js +5 -1
  19. package/dist/hooks/tenant-export.js.map +1 -1
  20. package/dist/pages/editor.js +1 -1
  21. package/dist/pages/editor.js.map +1 -1
  22. package/dist/routes/sends.js +2 -1
  23. package/dist/routes/sends.js.map +1 -1
  24. package/dist/routes/templates.d.ts +2 -2
  25. package/dist/routes/templates.js +2 -2
  26. package/dist/routes/templates.js.map +1 -1
  27. package/dist/services/settings.js +2 -1
  28. package/dist/services/settings.js.map +1 -1
  29. package/dist/services/templates.js +10 -8
  30. package/dist/services/templates.js.map +1 -1
  31. package/dist/validation/declarations.schema.d.ts +1 -1
  32. package/dist/validation/stored.schema.d.ts +12 -0
  33. package/dist/validation/stored.schema.js +198 -0
  34. package/dist/validation/stored.schema.js.map +1 -0
  35. package/frontend-vue/app/composables/useEditorContext.ts +1 -1
  36. package/frontend-vue/build/backend-boundary.ts +7 -2
  37. package/frontend-vue/dms.frontend.ts +1 -4
  38. package/frontend-vue/eslint.config.mjs +9 -1
  39. package/frontend-vue/package.json +1 -1
  40. package/frontend-vue/pnpm-lock.yaml +24 -7
  41. package/frontend-vue/tests/backend-boundary.test.ts +4 -4
  42. package/frontend-vue/tests/column-displays.test.ts +13 -12
  43. package/frontend-vue/tests/footer-block.test.ts +1 -1
  44. package/frontend-vue/tests/template-gallery-display.test.ts +6 -5
  45. package/frontend-vue/tests/template-overlays.test.ts +4 -12
  46. package/package.json +15 -15
  47. package/src/db/models/template-versions.model.ts +8 -6
  48. package/src/db/models/templates.model.ts +25 -9
  49. package/src/hooks/tenant-export.ts +5 -2
  50. package/src/pages/editor.ts +1 -1
  51. package/src/routes/sends.ts +2 -3
  52. package/src/routes/templates.ts +3 -2
  53. package/src/services/settings.ts +9 -3
  54. package/src/services/templates.ts +35 -16
  55. package/src/validation/stored.schema.ts +257 -0
  56. /package/frontend-vue/app/components/{Editor.vue → TemplateEditor.vue} +0 -0
  57. /package/frontend-vue/app/components/blocks/{Button.vue → BlockButton.vue} +0 -0
  58. /package/frontend-vue/app/components/blocks/{Code.vue → BlockCode.vue} +0 -0
  59. /package/frontend-vue/app/components/blocks/{Divider.vue → BlockDivider.vue} +0 -0
  60. /package/frontend-vue/app/components/blocks/{Footer.vue → BlockFooter.vue} +0 -0
  61. /package/frontend-vue/app/components/blocks/{Heading.vue → BlockHeading.vue} +0 -0
  62. /package/frontend-vue/app/components/blocks/{Hero.vue → BlockHero.vue} +0 -0
  63. /package/frontend-vue/app/components/blocks/{List.vue → BlockList.vue} +0 -0
  64. /package/frontend-vue/app/components/blocks/{Paragraph.vue → BlockParagraph.vue} +0 -0
  65. /package/frontend-vue/app/components/blocks/{Total.vue → BlockTotal.vue} +0 -0
@@ -7,8 +7,8 @@ import {
7
7
  writeFileSync,
8
8
  } from 'node:fs'
9
9
  import { tmpdir } from 'node:os'
10
- import { dirname, join } from 'node:path'
11
- import { build as viteBuild, type Plugin } from 'vite'
10
+ import { dirname, join } from 'node:path/posix'
11
+ import { normalizePath, build as viteBuild, type Plugin } from 'vite'
12
12
  import { afterAll, beforeAll, describe, expect, it } from 'vitest'
13
13
  import { backendBoundary } from '../build/backend-boundary'
14
14
 
@@ -22,8 +22,8 @@ function write(file: string, text: string): void {
22
22
  }
23
23
 
24
24
  beforeAll(() => {
25
- repository = realpathSync(
26
- mkdtempSync(join(tmpdir(), 'dms-mailing-boundary-')),
25
+ repository = normalizePath(
26
+ realpathSync(mkdtempSync(`${tmpdir()}/dms-mailing-boundary-`)),
27
27
  )
28
28
  const backend = join(repository, 'packages/dms-mailing')
29
29
  frontend = join(backend, 'frontend-vue')
@@ -1,6 +1,11 @@
1
1
  // @vitest-environment node
2
2
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
3
3
  import type { VNode } from 'vue'
4
+ import CategoryLabel from '../app/components/CategoryLabel.vue'
5
+ import setup, {
6
+ renderCategory,
7
+ renderLocaleChips,
8
+ } from '../app/plugins/column-displays'
4
9
 
5
10
  interface RegisteredType {
6
11
  id: string
@@ -11,9 +16,12 @@ type PluginSetup = () => void
11
16
 
12
17
  const registered: RegisteredType[] = []
13
18
 
19
+ vi.hoisted(() => {
20
+ vi.stubGlobal('defineDmsPlugin', (pluginSetup: PluginSetup) => pluginSetup)
21
+ })
22
+
14
23
  beforeEach(() => {
15
24
  registered.length = 0
16
- vi.stubGlobal('defineDmsPlugin', (setup: PluginSetup) => setup)
17
25
  vi.stubGlobal('useDataTypes', () => ({
18
26
  registerDataType: (type: RegisteredType) => registered.push(type),
19
27
  }))
@@ -24,7 +32,6 @@ beforeEach(() => {
24
32
 
25
33
  afterEach(() => {
26
34
  vi.unstubAllGlobals()
27
- vi.resetModules()
28
35
  })
29
36
 
30
37
  function texts(node: VNode): string[] {
@@ -36,8 +43,7 @@ function texts(node: VNode): string[] {
36
43
  }
37
44
 
38
45
  describe('column displays plugin', () => {
39
- it('registers the locale chips and category displays without a DOM', async () => {
40
- const { default: setup } = await import('../app/plugins/column-displays')
46
+ it('registers the locale chips and category displays without a DOM', () => {
41
47
  ;(setup as unknown as PluginSetup)()
42
48
  expect(registered.map((type) => type.id)).toEqual([
43
49
  'mailing:locale-chips',
@@ -45,18 +51,14 @@ describe('column displays plugin', () => {
45
51
  ])
46
52
  })
47
53
 
48
- it('draws a category through its label component', async () => {
49
- const { renderCategory } = await import('../app/plugins/column-displays')
50
- const { default: CategoryLabel } =
51
- await import('../app/components/CategoryLabel.vue')
54
+ it('draws a category through its label component', () => {
52
55
  const cell = renderCategory('orders')
53
56
  expect(cell.type).toBe(CategoryLabel)
54
57
  expect(cell.props).toEqual({ value: 'orders' })
55
58
  expect(renderCategory(undefined).props).toEqual({ value: null })
56
59
  })
57
60
 
58
- it('draws the locale chips with the missing workspace ones dashed', async () => {
59
- const { default: setup } = await import('../app/plugins/column-displays')
61
+ it('draws the locale chips with the missing workspace ones dashed', () => {
60
62
  ;(setup as unknown as PluginSetup)()
61
63
  const cell = registered[0]!.formatter.default('en,fr', 'en')
62
64
  const chips = cell.children as VNode[]
@@ -65,8 +67,7 @@ describe('column displays plugin', () => {
65
67
  expect(String(chips[0]!.props?.class)).not.toContain('border-dashed')
66
68
  })
67
69
 
68
- it('draws a dash for a row without locale information', async () => {
69
- const { renderLocaleChips } = await import('../app/plugins/column-displays')
70
+ it('draws a dash for a row without locale information', () => {
70
71
  expect(texts(renderLocaleChips('', ['en']))).toEqual(['—'])
71
72
  expect(texts(renderLocaleChips(undefined, ['en']))).toEqual(['—'])
72
73
  })
@@ -1,7 +1,7 @@
1
1
  // @vitest-environment jsdom
2
2
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
3
3
  import { defineComponent, h, provide, ref } from 'vue'
4
- import FooterBlockView from '../app/components/blocks/Footer.vue'
4
+ import FooterBlockView from '../app/components/blocks/BlockFooter.vue'
5
5
  import type { FooterBlock } from '../app/types/mailing'
6
6
  import { TOKEN_DATA_KEY } from '../app/utils/tokens'
7
7
  import type { TokenData } from '../app/utils/tokens'
@@ -1,6 +1,7 @@
1
1
  // @vitest-environment node
2
2
  import { afterEach, describe, expect, it, vi } from 'vitest'
3
3
  import type { Component } from 'vue'
4
+ import setup from '../app/plugins/template-gallery-display'
4
5
 
5
6
  interface DisplayRegistration {
6
7
  id: string
@@ -9,21 +10,21 @@ interface DisplayRegistration {
9
10
 
10
11
  type PluginSetup = () => void
11
12
 
13
+ vi.hoisted(() => {
14
+ vi.stubGlobal('defineDmsPlugin', (pluginSetup: PluginSetup) => pluginSetup)
15
+ })
16
+
12
17
  afterEach(() => {
13
18
  vi.unstubAllGlobals()
14
- vi.resetModules()
15
19
  })
16
20
 
17
21
  describe('template gallery display plugin', () => {
18
- it('registers the gallery under the mailing namespace without a DOM', async () => {
22
+ it('registers the gallery under the mailing namespace without a DOM', () => {
19
23
  const registrations: DisplayRegistration[] = []
20
- vi.stubGlobal('defineDmsPlugin', (setup: PluginSetup) => setup)
21
24
  vi.stubGlobal(
22
25
  'registerTableViewDisplay',
23
26
  (registration: DisplayRegistration) => registrations.push(registration),
24
27
  )
25
- const { default: setup } =
26
- await import('../app/plugins/template-gallery-display')
27
28
  setup()
28
29
  expect(registrations.map((registration) => registration.id)).toEqual([
29
30
  'mailing:gallery',
@@ -1,5 +1,6 @@
1
1
  // @vitest-environment node
2
2
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
3
+ import { useTemplateDrawer } from '../app/composables/useTemplateDrawer'
3
4
  import type { TemplateRow } from '../app/types/mailing'
4
5
 
5
6
  interface OpenCall {
@@ -53,7 +54,6 @@ beforeEach(() => {
53
54
 
54
55
  afterEach(() => {
55
56
  vi.unstubAllGlobals()
56
- vi.resetModules()
57
57
  })
58
58
 
59
59
  function row(id: string): TemplateRow {
@@ -68,9 +68,7 @@ function row(id: string): TemplateRow {
68
68
  }
69
69
 
70
70
  describe('useTemplateDrawer', () => {
71
- it('opens the test send with the editor draft and locale', async () => {
72
- const { useTemplateDrawer } =
73
- await import('../app/composables/useTemplateDrawer')
71
+ it('opens the test send with the editor draft and locale', () => {
74
72
  const content = { locales: {} }
75
73
  useTemplateDrawer().openTestSend('t1', { locale: 'fr', content })
76
74
  expect(modalCalls[0]?.component).toEqual({ name: 'MailingTestSendModal' })
@@ -82,8 +80,6 @@ describe('useTemplateDrawer', () => {
82
80
  })
83
81
 
84
82
  it('calls back only when the dialog closes after a change', async () => {
85
- const { useTemplateDrawer } =
86
- await import('../app/composables/useTemplateDrawer')
87
83
  const onDone = vi.fn()
88
84
  useTemplateDrawer().openRealSend('t1', { onDone })
89
85
  await Promise.resolve()
@@ -97,9 +93,7 @@ describe('useTemplateDrawer', () => {
97
93
  expect(modalCalls[1]?.componentOptions).toEqual({ initialName: 'Refund' })
98
94
  })
99
95
 
100
- it('opens the details from the right and steps through the rows shown', async () => {
101
- const { useTemplateDrawer } =
102
- await import('../app/composables/useTemplateDrawer')
96
+ it('opens the details from the right and steps through the rows shown', () => {
103
97
  const rows = [row('a'), row('b'), row('c')]
104
98
  useTemplateDrawer().openDetails(rows[1]!, { rows })
105
99
  const call = drawerCalls[0]!
@@ -115,9 +109,7 @@ describe('useTemplateDrawer', () => {
115
109
  expect((options.navigation as Navigation).hasNext).toBe(false)
116
110
  })
117
111
 
118
- it('opens the details of a bare id without navigation', async () => {
119
- const { useTemplateDrawer } =
120
- await import('../app/composables/useTemplateDrawer')
112
+ it('opens the details of a bare id without navigation', () => {
121
113
  useTemplateDrawer().openDetails('x')
122
114
  expect(drawerCalls[0]?.componentOptions.templateId).toBe('x')
123
115
  expect(drawerCalls[0]?.componentOptions.navigation).toBeUndefined()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@antelopejs/dms-mailing",
3
- "version": "0.9.2",
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.5.1",
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",
@@ -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",
@@ -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";
@@ -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);
@@ -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
 
@@ -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
  /**