@antelopejs/dms-mailing 0.9.2 → 0.10.1

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 (107) 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/build/components/FormFieldRow.vue +1 -1
  36. package/frontend-vue/app/build/components/TemplateDrawerPerformance.vue +3 -3
  37. package/frontend-vue/app/build/components/TemplateDrawerVariables.vue +4 -4
  38. package/frontend-vue/app/components/CategoriesInput.vue +2 -2
  39. package/frontend-vue/app/components/CategoryInput.vue +1 -1
  40. package/frontend-vue/app/components/FunnelCard.vue +4 -4
  41. package/frontend-vue/app/components/LocaleSelect.vue +1 -1
  42. package/frontend-vue/app/components/NewTemplateModal.vue +1 -1
  43. package/frontend-vue/app/components/OverviewGate.vue +6 -6
  44. package/frontend-vue/app/components/RealSendModal.vue +6 -6
  45. package/frontend-vue/app/components/RenderingModal.vue +2 -2
  46. package/frontend-vue/app/components/RetentionInput.vue +1 -1
  47. package/frontend-vue/app/components/SendDrawer.vue +9 -9
  48. package/frontend-vue/app/components/StarterGallery.vue +2 -2
  49. package/frontend-vue/app/components/SystemVariables.vue +1 -1
  50. package/frontend-vue/app/components/TemplateCard.vue +2 -2
  51. package/frontend-vue/app/components/TemplateDrawer.vue +4 -4
  52. package/frontend-vue/app/components/TemplateGallery.vue +2 -2
  53. package/frontend-vue/app/components/TestSendModal.vue +4 -4
  54. package/frontend-vue/app/components/VersionPreview.vue +3 -3
  55. package/frontend-vue/app/components/editor/EditorBlockFields.vue +1 -1
  56. package/frontend-vue/app/components/editor/EditorCompare.vue +1 -1
  57. package/frontend-vue/app/components/editor/EditorEnvelope.vue +2 -2
  58. package/frontend-vue/app/components/editor/EditorLifecycle.vue +1 -1
  59. package/frontend-vue/app/components/editor/EditorPaletteGroup.vue +1 -1
  60. package/frontend-vue/app/components/editor/EditorPublishReview.vue +4 -4
  61. package/frontend-vue/app/components/editor/EditorTemplatePane.vue +2 -2
  62. package/frontend-vue/app/components/editor/EditorTestDataPane.vue +1 -1
  63. package/frontend-vue/app/components/editor/EditorVariablesPane.vue +1 -1
  64. package/frontend-vue/app/components/editor/EditorViewSwitches.vue +2 -2
  65. package/frontend-vue/app/components/editor/EditorVisibility.vue +1 -1
  66. package/frontend-vue/app/composables/useEditorContext.ts +1 -1
  67. package/frontend-vue/build/backend-boundary.ts +7 -2
  68. package/frontend-vue/dms.frontend.ts +1 -4
  69. package/frontend-vue/eslint.config.mjs +9 -1
  70. package/frontend-vue/package.json +2 -2
  71. package/frontend-vue/pnpm-lock.yaml +24 -7
  72. package/frontend-vue/tests/backend-boundary.test.ts +4 -4
  73. package/frontend-vue/tests/categories-input.test.ts +1 -1
  74. package/frontend-vue/tests/column-displays.test.ts +13 -12
  75. package/frontend-vue/tests/dms-component-names.test.ts +12 -0
  76. package/frontend-vue/tests/footer-block.test.ts +1 -1
  77. package/frontend-vue/tests/funnel-card.test.ts +3 -3
  78. package/frontend-vue/tests/helpers/component-tags.ts +58 -0
  79. package/frontend-vue/tests/helpers/nuxt-globals.ts +1 -1
  80. package/frontend-vue/tests/overview-gate.test.ts +1 -1
  81. package/frontend-vue/tests/retention-input.test.ts +2 -2
  82. package/frontend-vue/tests/send-drawer.test.ts +2 -2
  83. package/frontend-vue/tests/send-modals.test.ts +1 -1
  84. package/frontend-vue/tests/template-drawer.test.ts +6 -6
  85. package/frontend-vue/tests/template-gallery-display.test.ts +6 -5
  86. package/frontend-vue/tests/template-gallery.test.ts +3 -3
  87. package/frontend-vue/tests/template-overlays.test.ts +4 -12
  88. package/package.json +16 -16
  89. package/src/db/models/template-versions.model.ts +8 -6
  90. package/src/db/models/templates.model.ts +25 -9
  91. package/src/hooks/tenant-export.ts +5 -2
  92. package/src/pages/editor.ts +1 -1
  93. package/src/routes/sends.ts +2 -3
  94. package/src/routes/templates.ts +3 -2
  95. package/src/services/settings.ts +9 -3
  96. package/src/services/templates.ts +35 -16
  97. package/src/validation/stored.schema.ts +257 -0
  98. /package/frontend-vue/app/components/{Editor.vue → TemplateEditor.vue} +0 -0
  99. /package/frontend-vue/app/components/blocks/{Button.vue → BlockButton.vue} +0 -0
  100. /package/frontend-vue/app/components/blocks/{Code.vue → BlockCode.vue} +0 -0
  101. /package/frontend-vue/app/components/blocks/{Divider.vue → BlockDivider.vue} +0 -0
  102. /package/frontend-vue/app/components/blocks/{Footer.vue → BlockFooter.vue} +0 -0
  103. /package/frontend-vue/app/components/blocks/{Heading.vue → BlockHeading.vue} +0 -0
  104. /package/frontend-vue/app/components/blocks/{Hero.vue → BlockHero.vue} +0 -0
  105. /package/frontend-vue/app/components/blocks/{List.vue → BlockList.vue} +0 -0
  106. /package/frontend-vue/app/components/blocks/{Paragraph.vue → BlockParagraph.vue} +0 -0
  107. /package/frontend-vue/app/components/blocks/{Total.vue → BlockTotal.vue} +0 -0
@@ -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
  /**
@@ -0,0 +1,257 @@
1
+ import { z } from "zod";
2
+ import type { MailingTemplateVersion } from "../db/tables";
3
+ import type { TemplateChange, TemplateChangeKind } from "../engine/changes";
4
+ import type {
5
+ Block,
6
+ BlockType,
7
+ Condition,
8
+ ConditionOperator,
9
+ TemplateCategory,
10
+ TemplateContent,
11
+ TextAlign,
12
+ VariableDefinition,
13
+ VariableType,
14
+ } from "../types";
15
+
16
+ /** What a stored JSON column holds, checked when it is read back. */
17
+ type StoredSchema<T> = z.ZodType<T, z.ZodTypeDef, unknown>;
18
+
19
+ /**
20
+ * `text`, a stored JSON column, parsed and read with `schema`: a
21
+ * `SyntaxError` when it is not JSON, a `TypeError` naming `what` when it does
22
+ * not hold one, as `parseJsonObject` refuses.
23
+ */
24
+ export function parseStored<T>(
25
+ text: string,
26
+ schema: StoredSchema<T>,
27
+ what: string,
28
+ ): T {
29
+ const value: unknown = JSON.parse(text);
30
+ const read = schema.safeParse(value);
31
+ if (!read.success) throw new TypeError(`expected ${what}`);
32
+ return read.data;
33
+ }
34
+
35
+ /** A string that is one of the keys of `members`, which lists every member. */
36
+ function oneOf<T extends string>(members: Record<T, true>): StoredSchema<T> {
37
+ return z
38
+ .string()
39
+ .refine((value): value is T => Object.hasOwn(members, value));
40
+ }
41
+
42
+ const conditionOperators = {
43
+ truthy: true,
44
+ falsy: true,
45
+ eq: true,
46
+ ne: true,
47
+ gt: true,
48
+ lt: true,
49
+ } satisfies Record<ConditionOperator, true>;
50
+
51
+ const textAligns = {
52
+ left: true,
53
+ center: true,
54
+ right: true,
55
+ } satisfies Record<TextAlign, true>;
56
+
57
+ const variableTypes = {
58
+ string: true,
59
+ text: true,
60
+ number: true,
61
+ money: true,
62
+ date: true,
63
+ url: true,
64
+ boolean: true,
65
+ array: true,
66
+ } satisfies Record<VariableType, true>;
67
+
68
+ const blockTypes = {
69
+ hero: true,
70
+ heading: true,
71
+ paragraph: true,
72
+ code: true,
73
+ list: true,
74
+ total: true,
75
+ button: true,
76
+ if: true,
77
+ divider: true,
78
+ footer: true,
79
+ } satisfies Record<BlockType, true>;
80
+
81
+ const changeKinds = {
82
+ locale_added: true,
83
+ locale_removed: true,
84
+ subject: true,
85
+ preheader: true,
86
+ block_added: true,
87
+ block_removed: true,
88
+ block_changed: true,
89
+ blocks_reordered: true,
90
+ variable_added: true,
91
+ variable_removed: true,
92
+ variable_changed: true,
93
+ } satisfies Record<TemplateChangeKind, true>;
94
+
95
+ const storedConditionSchema: StoredSchema<Condition> = z
96
+ .object({
97
+ path: z.string(),
98
+ operator: oneOf(conditionOperators),
99
+ value: z.string(),
100
+ })
101
+ .passthrough();
102
+
103
+ const align = oneOf(textAligns);
104
+ const base = { id: z.string(), visibleIf: storedConditionSchema.nullable() };
105
+
106
+ /**
107
+ * A block as the module stores it: the shape of {@link Block} and nothing
108
+ * more, without the bounds an edit is held to, so every block the module
109
+ * wrote reads back unchanged.
110
+ */
111
+ const storedBlockSchema: StoredSchema<Block> = z.lazy(() => storedBlockUnion);
112
+
113
+ /** Built once: the lazy schema above returns it for every nested block. */
114
+ const storedBlockUnion = z.discriminatedUnion("type", [
115
+ z
116
+ .object({
117
+ ...base,
118
+ type: z.literal("hero"),
119
+ imageUrl: z.string(),
120
+ alt: z.string(),
121
+ })
122
+ .passthrough(),
123
+ z
124
+ .object({
125
+ ...base,
126
+ type: z.literal("heading"),
127
+ text: z.string(),
128
+ align,
129
+ size: z.number(),
130
+ })
131
+ .passthrough(),
132
+ z
133
+ .object({
134
+ ...base,
135
+ type: z.literal("paragraph"),
136
+ text: z.string(),
137
+ align,
138
+ })
139
+ .passthrough(),
140
+ z
141
+ .object({ ...base, type: z.literal("code"), text: z.string() })
142
+ .passthrough(),
143
+ z
144
+ .object({
145
+ ...base,
146
+ type: z.literal("list"),
147
+ source: z.string(),
148
+ labelPath: z.string(),
149
+ valuePath: z.string(),
150
+ })
151
+ .passthrough(),
152
+ z
153
+ .object({
154
+ ...base,
155
+ type: z.literal("total"),
156
+ label: z.string(),
157
+ value: z.string(),
158
+ })
159
+ .passthrough(),
160
+ z
161
+ .object({
162
+ ...base,
163
+ type: z.literal("button"),
164
+ text: z.string(),
165
+ href: z.string(),
166
+ align,
167
+ })
168
+ .passthrough(),
169
+ z
170
+ .object({
171
+ ...base,
172
+ type: z.literal("if"),
173
+ condition: storedConditionSchema,
174
+ children: z.array(storedBlockSchema),
175
+ elseChildren: z.array(storedBlockSchema).nullable(),
176
+ })
177
+ .passthrough(),
178
+ z.object({ ...base, type: z.literal("divider") }).passthrough(),
179
+ z
180
+ .object({
181
+ ...base,
182
+ type: z.literal("footer"),
183
+ text: z.string(),
184
+ unsubscribeLabel: z.string(),
185
+ preferencesLabel: z.string(),
186
+ })
187
+ .passthrough(),
188
+ ]);
189
+
190
+ /** Stored {@link TemplateContent}: `json_content`, `json_draft`, a version's. */
191
+ export const storedContentSchema: StoredSchema<TemplateContent> = z
192
+ .object({
193
+ locales: z.record(
194
+ z
195
+ .object({
196
+ subject: z.string(),
197
+ preheader: z.string(),
198
+ blocks: z.array(storedBlockSchema),
199
+ })
200
+ .passthrough(),
201
+ ),
202
+ })
203
+ .passthrough();
204
+
205
+ /** Stored declarations: `json_variables`, `json_variables_draft`, a version's. */
206
+ export const storedVariablesSchema: StoredSchema<VariableDefinition[]> =
207
+ z.array(
208
+ z
209
+ .object({
210
+ path: z.string(),
211
+ type: oneOf(variableTypes),
212
+ required: z.boolean(),
213
+ })
214
+ .passthrough(),
215
+ );
216
+
217
+ /** A version's `json_changes`, as {@link TemplateChange} lists them. */
218
+ export const storedChangesSchema: StoredSchema<TemplateChange[]> = z.array(
219
+ z
220
+ .object({
221
+ locale: z.string(),
222
+ kind: oneOf(changeKinds),
223
+ blockId: z.string().optional(),
224
+ blockType: oneOf(blockTypes).optional(),
225
+ variable: z.string().optional(),
226
+ })
227
+ .passthrough(),
228
+ );
229
+
230
+ /**
231
+ * A template's `json_published_version`: the version row it inserts, whose
232
+ * `publishedAt` JSON wrote as a string.
233
+ */
234
+ export const storedVersionRowSchema: StoredSchema<MailingTemplateVersion> = z
235
+ .object({
236
+ _id: z.string(),
237
+ templateId: z.string(),
238
+ version: z.number(),
239
+ json_content: z.string(),
240
+ json_variables: z.string(),
241
+ locales: z.string(),
242
+ publishedAt: z.string().transform((at) => new Date(at)),
243
+ publishedBy: z.string(),
244
+ json_changes: z.string(),
245
+ })
246
+ .passthrough();
247
+
248
+ /** The settings' `json_categories`, each label one text per language. */
249
+ export const storedCategoriesSchema: StoredSchema<TemplateCategory[]> = z.array(
250
+ z
251
+ .object({
252
+ id: z.string(),
253
+ label: z.record(z.string()),
254
+ icon: z.string(),
255
+ })
256
+ .passthrough(),
257
+ );