@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.
Files changed (255) hide show
  1. package/README.md +46 -31
  2. package/dist/automation/email-event-trigger.d.ts +2 -2
  3. package/dist/automation/send-template-action.d.ts +11 -3
  4. package/dist/automation/send-template-action.js +1 -1
  5. package/dist/automation/send-template-action.js.map +1 -1
  6. package/dist/crons/jobs.d.ts +1 -1
  7. package/dist/data/body.d.ts +2 -1
  8. package/dist/data/body.js +4 -2
  9. package/dist/data/body.js.map +1 -1
  10. package/dist/data/read-only-routes.d.ts +4 -4
  11. package/dist/data/sends-table.d.ts +4 -4
  12. package/dist/data/suppressions-table.d.ts +4 -4
  13. package/dist/data/templates-table.d.ts +35 -12
  14. package/dist/data/templates-table.js +30 -24
  15. package/dist/data/templates-table.js.map +1 -1
  16. package/dist/data-types/categories-type.d.ts +1 -2
  17. package/dist/data-types/categories-type.js +3 -5
  18. package/dist/data-types/categories-type.js.map +1 -1
  19. package/dist/data-types/category-type.d.ts +1 -2
  20. package/dist/data-types/category-type.js +3 -5
  21. package/dist/data-types/category-type.js.map +1 -1
  22. package/dist/db/models/send-events.model.d.ts +7 -5
  23. package/dist/db/models/sends.model.d.ts +7 -5
  24. package/dist/db/models/sends.model.js.map +1 -1
  25. package/dist/db/models/settings.model.d.ts +7 -5
  26. package/dist/db/models/suppressions.model.d.ts +7 -5
  27. package/dist/db/models/template-versions.model.d.ts +7 -5
  28. package/dist/db/models/template-versions.model.js +4 -3
  29. package/dist/db/models/template-versions.model.js.map +1 -1
  30. package/dist/db/models/templates.model.d.ts +7 -5
  31. package/dist/db/models/templates.model.js +2 -1
  32. package/dist/db/models/templates.model.js.map +1 -1
  33. package/dist/db/models/webhook-status.model.d.ts +7 -5
  34. package/dist/db/tables/templates.table.js.map +1 -1
  35. package/dist/engine/changes.js +8 -18
  36. package/dist/engine/changes.js.map +1 -1
  37. package/dist/engine/conditions.d.ts +2 -1
  38. package/dist/engine/conditions.js +5 -5
  39. package/dist/engine/conditions.js.map +1 -1
  40. package/dist/engine/coverage.d.ts +2 -1
  41. package/dist/engine/coverage.js.map +1 -1
  42. package/dist/engine/fallback-html.js +8 -8
  43. package/dist/engine/fallback-html.js.map +1 -1
  44. package/dist/engine/formats.d.ts +4 -6
  45. package/dist/engine/formats.js +22 -8
  46. package/dist/engine/formats.js.map +1 -1
  47. package/dist/engine/paths.d.ts +4 -2
  48. package/dist/engine/paths.js +16 -5
  49. package/dist/engine/paths.js.map +1 -1
  50. package/dist/engine/plain-text.js +2 -4
  51. package/dist/engine/plain-text.js.map +1 -1
  52. package/dist/engine/resolve.d.ts +2 -1
  53. package/dist/engine/resolve.js +1 -1
  54. package/dist/engine/resolve.js.map +1 -1
  55. package/dist/engine/system-variables.d.ts +2 -1
  56. package/dist/engine/system-variables.js +1 -1
  57. package/dist/engine/system-variables.js.map +1 -1
  58. package/dist/engine/tokens.d.ts +2 -1
  59. package/dist/engine/tokens.js.map +1 -1
  60. package/dist/hooks/tenant-export.js +10 -9
  61. package/dist/hooks/tenant-export.js.map +1 -1
  62. package/dist/pages/sends.js.map +1 -1
  63. package/dist/pages/templates.js +3 -1
  64. package/dist/pages/templates.js.map +1 -1
  65. package/dist/pages/toolbar.js.map +1 -1
  66. package/dist/routes/events.js +6 -4
  67. package/dist/routes/events.js.map +1 -1
  68. package/dist/routes/index.d.ts +1 -0
  69. package/dist/routes/index.js +1 -0
  70. package/dist/routes/index.js.map +1 -1
  71. package/dist/routes/metrics.js +31 -31
  72. package/dist/routes/metrics.js.map +1 -1
  73. package/dist/routes/period.js +5 -5
  74. package/dist/routes/period.js.map +1 -1
  75. package/dist/routes/sends.js +4 -5
  76. package/dist/routes/sends.js.map +1 -1
  77. package/dist/routes/settings.d.ts +1 -1
  78. package/dist/routes/settings.js +16 -14
  79. package/dist/routes/settings.js.map +1 -1
  80. package/dist/routes/suppressions.js +9 -6
  81. package/dist/routes/suppressions.js.map +1 -1
  82. package/dist/routes/template-content.d.ts +27 -0
  83. package/dist/routes/template-content.js +3 -0
  84. package/dist/routes/template-content.js.map +1 -0
  85. package/dist/routes/templates.d.ts +7 -30
  86. package/dist/routes/templates.js +7 -6
  87. package/dist/routes/templates.js.map +1 -1
  88. package/dist/routes/zod-json.d.ts +3 -0
  89. package/dist/routes/zod-json.js +21 -0
  90. package/dist/routes/zod-json.js.map +1 -0
  91. package/dist/services/attention.js +16 -8
  92. package/dist/services/attention.js.map +1 -1
  93. package/dist/services/catalog.js +4 -2
  94. package/dist/services/catalog.js.map +1 -1
  95. package/dist/services/categories.js +6 -6
  96. package/dist/services/categories.js.map +1 -1
  97. package/dist/services/checked-send.js +16 -9
  98. package/dist/services/checked-send.js.map +1 -1
  99. package/dist/services/declaration-error.d.ts +1 -1
  100. package/dist/services/declaration-error.js.map +1 -1
  101. package/dist/services/declarations.js +4 -1
  102. package/dist/services/declarations.js.map +1 -1
  103. package/dist/services/events.js +13 -7
  104. package/dist/services/events.js.map +1 -1
  105. package/dist/services/localized-text.d.ts +2 -1
  106. package/dist/services/localized-text.js +2 -1
  107. package/dist/services/localized-text.js.map +1 -1
  108. package/dist/services/metrics.js +2 -5
  109. package/dist/services/metrics.js.map +1 -1
  110. package/dist/services/render.js.map +1 -1
  111. package/dist/services/send-import.js +7 -3
  112. package/dist/services/send-import.js.map +1 -1
  113. package/dist/services/send.js +7 -7
  114. package/dist/services/send.js.map +1 -1
  115. package/dist/services/settings.d.ts +4 -2
  116. package/dist/services/settings.js +5 -4
  117. package/dist/services/settings.js.map +1 -1
  118. package/dist/services/starters.d.ts +3 -2
  119. package/dist/services/starters.js +5 -5
  120. package/dist/services/starters.js.map +1 -1
  121. package/dist/services/template-stats.js +5 -2
  122. package/dist/services/template-stats.js.map +1 -1
  123. package/dist/services/templates.d.ts +2 -1
  124. package/dist/services/templates.js +8 -4
  125. package/dist/services/templates.js.map +1 -1
  126. package/dist/services/webhook-status.d.ts +2 -2
  127. package/dist/services/webhook-status.js +6 -4
  128. package/dist/services/webhook-status.js.map +1 -1
  129. package/dist/types/json.d.ts +1 -0
  130. package/dist/types/json.js +3 -0
  131. package/dist/types/json.js.map +1 -0
  132. package/dist/types/settings.d.ts +2 -2
  133. package/dist/validation/declarations.schema.d.ts +44 -44
  134. package/dist/validation/declarations.schema.js +6 -5
  135. package/dist/validation/declarations.schema.js.map +1 -1
  136. package/dist/validation/guards.d.ts +3 -0
  137. package/dist/validation/guards.js +15 -0
  138. package/dist/validation/guards.js.map +1 -0
  139. package/dist/validation/json.d.ts +5 -0
  140. package/dist/validation/json.js +54 -0
  141. package/dist/validation/json.js.map +1 -0
  142. package/dist/validation/json.schema.d.ts +2 -0
  143. package/dist/validation/json.schema.js +9 -0
  144. package/dist/validation/json.schema.js.map +1 -0
  145. package/dist/validation/send-event-type.schema.d.ts +2 -0
  146. package/dist/validation/send-event-type.schema.js +11 -0
  147. package/dist/validation/send-event-type.schema.js.map +1 -0
  148. package/dist/validation/settings.schema.js +1 -1
  149. package/dist/validation/settings.schema.js.map +1 -1
  150. package/dist/validation/templates.schema.d.ts +44 -42
  151. package/dist/validation/templates.schema.js +4 -3
  152. package/dist/validation/templates.schema.js.map +1 -1
  153. package/frontend-vue/app/build/components/TemplateDrawerFooter.vue +45 -0
  154. package/frontend-vue/app/build/components/TemplateDrawerPerformance.vue +102 -0
  155. package/frontend-vue/app/build/components/TemplateDrawerVariables.vue +110 -0
  156. package/frontend-vue/app/build/composables/useDrawerStats.ts +78 -0
  157. package/frontend-vue/app/components/DuplicateModal.vue +3 -11
  158. package/frontend-vue/app/components/FixAddressModal.vue +2 -1
  159. package/frontend-vue/app/components/NewTemplateModal.vue +3 -11
  160. package/frontend-vue/app/components/RealSendModal.vue +6 -17
  161. package/frontend-vue/app/components/RenderingModal.vue +2 -1
  162. package/frontend-vue/app/components/SendDrawer.vue +5 -4
  163. package/frontend-vue/app/components/StarterGallery.vue +3 -11
  164. package/frontend-vue/app/components/TemplateDrawer.vue +40 -269
  165. package/frontend-vue/app/components/TestSendModal.vue +7 -18
  166. package/frontend-vue/app/components/VersionHistory.vue +2 -7
  167. package/frontend-vue/app/components/WebhookStatus.vue +2 -1
  168. package/frontend-vue/app/components/editor/EditorWorkspace.vue +2 -7
  169. package/frontend-vue/build/backend-boundary.ts +122 -0
  170. package/frontend-vue/dms.frontend.build.ts +7 -0
  171. package/frontend-vue/eslint.config.mjs +13 -0
  172. package/frontend-vue/package.json +4 -2
  173. package/frontend-vue/pnpm-lock.yaml +250 -61
  174. package/frontend-vue/tests/backend-boundary.test.ts +203 -0
  175. package/frontend-vue/tests/dnd.test.ts +1 -2
  176. package/frontend-vue/tests/editor-block-list.test.ts +136 -53
  177. package/frontend-vue/tests/helpers/auth-fetch.ts +123 -0
  178. package/frontend-vue/tests/helpers/nuxt-globals.ts +17 -1
  179. package/frontend-vue/tests/new-template-modal.test.ts +41 -6
  180. package/frontend-vue/tests/page-reads.test.ts +26 -6
  181. package/frontend-vue/tests/send-actions.test.ts +0 -1
  182. package/frontend-vue/tests/send-drawer.test.ts +2 -6
  183. package/frontend-vue/tests/send-modals.test.ts +8 -5
  184. package/frontend-vue/tests/sortable-list.test.ts +54 -62
  185. package/frontend-vue/tests/suppression.test.ts +1 -2
  186. package/frontend-vue/tests/template-drawer.test.ts +27 -18
  187. package/frontend-vue/tests/template-gallery.test.ts +7 -5
  188. package/frontend-vue/tests/template-preview-frame.test.ts +2 -5
  189. package/package.json +16 -20
  190. package/skills/dms-mailing/SKILL.md +1 -1
  191. package/skills/dms-mailing/cases.json +49 -0
  192. package/src/automation/email-event-trigger.ts +2 -2
  193. package/src/automation/send-template-action.ts +33 -14
  194. package/src/crons/jobs.ts +1 -1
  195. package/src/data/body.ts +7 -3
  196. package/src/data/templates-table.ts +55 -56
  197. package/src/data-types/categories-type.ts +2 -2
  198. package/src/data-types/category-type.ts +2 -2
  199. package/src/db/models/sends.model.ts +2 -2
  200. package/src/db/models/template-versions.model.ts +10 -4
  201. package/src/db/models/templates.model.ts +6 -3
  202. package/src/db/tables/templates.table.ts +1 -1
  203. package/src/engine/changes.ts +11 -28
  204. package/src/engine/conditions.ts +14 -8
  205. package/src/engine/coverage.ts +2 -1
  206. package/src/engine/fallback-html.ts +13 -13
  207. package/src/engine/formats.ts +35 -21
  208. package/src/engine/paths.ts +41 -7
  209. package/src/engine/plain-text.ts +2 -4
  210. package/src/engine/resolve.ts +12 -4
  211. package/src/engine/system-variables.ts +6 -4
  212. package/src/engine/tokens.ts +3 -3
  213. package/src/hooks/tenant-export.ts +11 -13
  214. package/src/pages/sends.ts +5 -3
  215. package/src/pages/templates.ts +4 -2
  216. package/src/pages/toolbar.ts +4 -2
  217. package/src/routes/events.ts +10 -10
  218. package/src/routes/index.ts +1 -0
  219. package/src/routes/metrics.ts +35 -33
  220. package/src/routes/period.ts +5 -5
  221. package/src/routes/sends.ts +23 -26
  222. package/src/routes/settings.ts +21 -17
  223. package/src/routes/suppressions.ts +12 -8
  224. package/src/routes/template-content.ts +45 -0
  225. package/src/routes/templates.ts +16 -49
  226. package/src/routes/zod-json.ts +28 -0
  227. package/src/services/attention.ts +20 -18
  228. package/src/services/catalog.ts +4 -8
  229. package/src/services/categories.ts +6 -6
  230. package/src/services/checked-send.ts +17 -19
  231. package/src/services/declaration-error.ts +1 -1
  232. package/src/services/declarations.ts +7 -6
  233. package/src/services/events.ts +18 -13
  234. package/src/services/localized-text.ts +4 -2
  235. package/src/services/metrics.ts +5 -7
  236. package/src/services/notify.ts +1 -1
  237. package/src/services/render.ts +5 -1
  238. package/src/services/send-import.ts +16 -11
  239. package/src/services/send.ts +10 -13
  240. package/src/services/settings.ts +17 -16
  241. package/src/services/starters.ts +12 -8
  242. package/src/services/template-stats.ts +11 -4
  243. package/src/services/templates.ts +24 -15
  244. package/src/services/webhook-status.ts +13 -8
  245. package/src/types/json.ts +1 -0
  246. package/src/types/settings.ts +2 -2
  247. package/src/validation/declarations.schema.ts +6 -5
  248. package/src/validation/guards.ts +17 -0
  249. package/src/validation/json.schema.ts +7 -0
  250. package/src/validation/json.ts +66 -0
  251. package/src/validation/send-event-type.schema.ts +10 -0
  252. package/src/validation/settings.schema.ts +7 -2
  253. package/src/validation/templates.schema.ts +9 -4
  254. package/frontend-vue/tests/i18n-editor.test.ts +0 -50
  255. package/frontend-vue/tests/i18n.test.ts +0 -138
@@ -27,6 +27,7 @@ import type {
27
27
  VariableDefinition,
28
28
  } from "../types";
29
29
  import { DEFAULT_FALLBACK_LOCALE } from "../types";
30
+ import type { JsonObject } from "../types/json";
30
31
 
31
32
  export interface TemplateAuthor {
32
33
  name?: string;
@@ -73,6 +74,7 @@ export async function refuseTakenSlug<T>(write: Promise<T>): Promise<T> {
73
74
 
74
75
  /** The content customers receive: the last published version. */
75
76
  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.
76
78
  return JSON.parse(template.json_content || EMPTY_CONTENT) as TemplateContent;
77
79
  }
78
80
 
@@ -83,6 +85,7 @@ export function hasDraft(template: MailingTemplate): boolean {
83
85
  /** The content the editor works on: the draft, or the published content. */
84
86
  export function workingContent(template: MailingTemplate): TemplateContent {
85
87
  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.
86
89
  return JSON.parse(template.json_draft) as TemplateContent;
87
90
  }
88
91
 
@@ -102,6 +105,7 @@ export function isPublished(template: MailingTemplate): boolean {
102
105
  export function parseVariables(
103
106
  template: MailingTemplate,
104
107
  ): VariableDefinition[] {
108
+ // SAFETY: json_variables is written only as JSON.stringify of a VariableDefinition[] that variableListSchema validated or the module built.
105
109
  return JSON.parse(template.json_variables) as VariableDefinition[];
106
110
  }
107
111
 
@@ -114,6 +118,7 @@ export function workingVariables(
114
118
  ): VariableDefinition[] {
115
119
  if (!hasDraft(template) || !template.json_variables_draft)
116
120
  return parseVariables(template);
121
+ // SAFETY: json_variables_draft is written only as JSON.stringify of a VariableDefinition[] that variableListSchema validated or the module built.
117
122
  return JSON.parse(template.json_variables_draft) as VariableDefinition[];
118
123
  }
119
124
 
@@ -169,10 +174,9 @@ export function pendingChanges(template: MailingTemplate): TemplateChange[] {
169
174
  });
170
175
  }
171
176
 
172
- export function parseTestData(
173
- template: MailingTemplate,
174
- ): Record<string, unknown> {
175
- return JSON.parse(template.json_test_data) as Record<string, unknown>;
177
+ 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;
176
180
  }
177
181
 
178
182
  interface FooterLabels {
@@ -180,21 +184,26 @@ interface FooterLabels {
180
184
  preferences: string;
181
185
  }
182
186
 
187
+ const ENGLISH_FOOTER_LABELS: FooterLabels = {
188
+ unsubscribe: "Unsubscribe",
189
+ preferences: "Preferences",
190
+ };
191
+
183
192
  /**
184
193
  * The one piece of content the module still writes for a new template. An
185
194
  * e-mail without an unsubscribe link is a legal problem, so a blank template
186
195
  * ships a footer — and nothing else: no invented subject, prose or address.
187
196
  */
188
- const FOOTER_LABELS: Record<string, FooterLabels> = {
189
- en: { unsubscribe: "Unsubscribe", preferences: "Preferences" },
190
- fr: { unsubscribe: "Se désinscrire", preferences: "Préférences" },
191
- };
197
+ const FOOTER_LABELS = new Map<string, FooterLabels>([
198
+ ["en", ENGLISH_FOOTER_LABELS],
199
+ ["fr", { unsubscribe: "Se désinscrire", preferences: "Préférences" }],
200
+ ]);
192
201
 
193
202
  const FOOTER_BLOCK_ID = "footer";
194
203
 
195
204
  /** A footer in `locale`: its legal line and the labels of its two links. */
196
205
  export function footerBlock(locale: string, text = ""): Block {
197
- const labels = FOOTER_LABELS[locale] ?? (FOOTER_LABELS.en as FooterLabels);
206
+ const labels = FOOTER_LABELS.get(locale) ?? ENGLISH_FOOTER_LABELS;
198
207
  return {
199
208
  id: FOOTER_BLOCK_ID,
200
209
  type: "footer",
@@ -226,16 +235,13 @@ export function isStaleRevision(
226
235
  return revision !== undefined && revision !== template.draftRevision;
227
236
  }
228
237
 
229
- function draftWriteFields(
230
- template: MailingTemplate,
231
- author: string,
232
- ): TemplateFields {
238
+ function draftWriteFields(template: MailingTemplate, author: string) {
233
239
  return {
234
240
  draftRevision: template.draftRevision + 1,
235
241
  draftUpdatedAt: new Date(),
236
242
  draftUpdatedBy: author,
237
243
  updatedBy: author,
238
- };
244
+ } satisfies TemplateFields;
239
245
  }
240
246
 
241
247
  /** Whether `draft` is exactly what customers and callers already have. */
@@ -271,7 +277,7 @@ export async function saveDraft(
271
277
  ...locales,
272
278
  });
273
279
  await refreshAttentionIfFlagged(tenantId, template, STALE_DRAFT);
274
- return fields.draftRevision as number;
280
+ return fields.draftRevision;
275
281
  }
276
282
 
277
283
  export async function discardDraft(
@@ -291,6 +297,7 @@ export async function discardDraft(
291
297
  export function parseVersionChanges(
292
298
  row: MailingTemplateVersion,
293
299
  ): TemplateChange[] {
300
+ // SAFETY: json_changes is written only as JSON.stringify of the TemplateChange[] from diffContents and diffVariables.
294
301
  return JSON.parse(row.json_changes) as TemplateChange[];
295
302
  }
296
303
 
@@ -298,6 +305,7 @@ export function parseVersionChanges(
298
305
  export function parseVersionVariables(
299
306
  row: MailingTemplateVersion,
300
307
  ): 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.
301
309
  return JSON.parse(row.json_variables) as VariableDefinition[];
302
310
  }
303
311
 
@@ -453,6 +461,7 @@ export async function contentOfVersion(
453
461
  export function parseVersionContent(
454
462
  row: MailingTemplateVersion,
455
463
  ): TemplateContent {
464
+ // SAFETY: a version row's json_content is written only as JSON.stringify of a TemplateContent that saveContentSchema validated.
456
465
  return JSON.parse(row.json_content) as TemplateContent;
457
466
  }
458
467
 
@@ -7,6 +7,7 @@ import type {
7
7
  WebhookRefusal,
8
8
  WebhookStatus,
9
9
  } from "../types";
10
+ import { isObject, isText } from "../validation/guards";
10
11
  import { webhookSecretMatches } from "./events";
11
12
  import { WEBHOOK_SECRET_HEADER } from "../constants";
12
13
 
@@ -28,11 +29,11 @@ export const WEBHOOK_REFUSAL_MESSAGES: Record<WebhookRefusal, string> = {
28
29
  };
29
30
 
30
31
  /** The body of a refused webhook call. */
31
- export interface WebhookRefusalBody {
32
+ export type WebhookRefusalBody = {
32
33
  error: "webhook_refused";
33
34
  reason: WebhookRefusal;
34
35
  message: string;
35
- }
36
+ };
36
37
 
37
38
  /** What "Send a test event" found when it called the webhook URL. */
38
39
  export interface WebhookTestOutcome {
@@ -143,15 +144,13 @@ export async function readWebhookStatus(
143
144
  );
144
145
  }
145
146
 
146
- const describeError = (error: unknown): string =>
147
- error instanceof Error
148
- ? String((error.cause as Error | undefined)?.message ?? error.message)
149
- : String(error);
150
-
151
147
  async function refusalOf(
152
148
  response: Response,
153
149
  ): Promise<WebhookRefusal | undefined> {
154
150
  try {
151
+ // SAFETY: Response.json() answers any, and the URL under test is this module's own webhook route, which refuses
152
+ // with refusalBody(reason); another endpoint can answer any JSON, which is read the same way, and a non-object
153
+ // reads as no refusal (only null throws, into the catch below).
155
154
  const body = (await response.json()) as Partial<WebhookRefusalBody>;
156
155
  return body.reason;
157
156
  } catch {
@@ -192,11 +191,17 @@ export async function sendWebhookTest(
192
191
  refusal: response.ok ? undefined : await refusalOf(response),
193
192
  };
194
193
  } catch (error) {
194
+ const cause = error instanceof Error ? error.cause : undefined;
195
+ const causeMessage =
196
+ isObject(cause) && isText(cause.message) ? cause.message : undefined;
195
197
  return {
196
198
  isReceived: false,
197
199
  url,
198
200
  durationMs: elapsed(),
199
- networkError: describeError(error),
201
+ networkError:
202
+ error instanceof Error
203
+ ? (causeMessage ?? error.message)
204
+ : String(error),
200
205
  };
201
206
  }
202
207
  }
@@ -0,0 +1 @@
1
+ export type { JsonObject, JsonValue } from "@antelopejs/interface-dms-mailing";
@@ -1,11 +1,11 @@
1
1
  import type { LocalizedText } from "@antelopejs/interface-dms-mailing";
2
2
 
3
3
  /** A template category as stored: its label written once per language. */
4
- export interface TemplateCategory {
4
+ export type TemplateCategory = {
5
5
  id: string;
6
6
  label: LocalizedText;
7
7
  icon: string;
8
- }
8
+ };
9
9
 
10
10
  /** A template category as one reader sees it: the label in their language. */
11
11
  export interface CategoryInLocale {
@@ -1,13 +1,14 @@
1
1
  import { LOCALIZED_TEXT_FALLBACK_LOCALE } from "@antelopejs/interface-dms-mailing";
2
2
  import { z } from "zod";
3
3
  import { MAX_NAME_LENGTH } from "../constants";
4
- import { SEND_EVENT_TYPES } from "../types";
4
+ import { sendEventTypeSchema } from "./send-event-type.schema";
5
5
  import { categorySchema, settingsSchema } from "./settings.schema";
6
6
  import {
7
7
  slugSchema,
8
8
  templateContentSchema,
9
9
  variableListSchema,
10
10
  } from "./templates.schema";
11
+ import { jsonObjectSchema } from "./json.schema";
11
12
 
12
13
  const MAX_AUTHOR_LENGTH = MAX_NAME_LENGTH;
13
14
  const MAX_IMPORT_ID_LENGTH = 200;
@@ -36,7 +37,7 @@ export const templateDeclarationSchema = z.object({
36
37
  category: z.string().trim().default(""),
37
38
  content: templateContentSchema,
38
39
  variables: variableListSchema.default([]),
39
- testData: z.record(z.unknown()).default({}),
40
+ testData: jsonObjectSchema.default({}),
40
41
  author: z.string().trim().min(1).max(MAX_AUTHOR_LENGTH).optional(),
41
42
  });
42
43
 
@@ -76,9 +77,9 @@ const recipientSchema = z.union([
76
77
  ]);
77
78
 
78
79
  const importedEventSchema = z.object({
79
- type: z.enum(SEND_EVENT_TYPES as [string, ...string[]]),
80
+ type: sendEventTypeSchema,
80
81
  at: z.coerce.date(),
81
- details: z.record(z.unknown()).optional(),
82
+ details: jsonObjectSchema.optional(),
82
83
  });
83
84
 
84
85
  const importedSendSchema = z.object({
@@ -87,7 +88,7 @@ const importedSendSchema = z.object({
87
88
  to: recipientSchema,
88
89
  sentAt: z.coerce.date(),
89
90
  locale: z.string().trim().min(1).optional(),
90
- variables: z.record(z.unknown()).default({}),
91
+ variables: jsonObjectSchema.default({}),
91
92
  provider: z.string().optional(),
92
93
  providerMessageId: z.string().optional(),
93
94
  source: z.string().optional(),
@@ -0,0 +1,17 @@
1
+ /** Whether `value` is a string. */
2
+ export function isText(value: unknown): value is string {
3
+ return typeof value === "string";
4
+ }
5
+
6
+ /**
7
+ * Whether `value` is an object, arrays included, and not `null`: reading any
8
+ * key of it yields a value of unknown type.
9
+ */
10
+ export function isObject(value: unknown): value is Record<string, unknown> {
11
+ return typeof value === "object" && value !== null;
12
+ }
13
+
14
+ /** Whether `value` is a number. */
15
+ export function isNumber(value: unknown): value is number {
16
+ return typeof value === "number";
17
+ }
@@ -0,0 +1,7 @@
1
+ import { z } from "zod";
2
+ import { isJsonObject } from "./json";
3
+
4
+ /** A JSON object whose every value is JSON data, such as a request body carries. */
5
+ export const jsonObjectSchema = z
6
+ .unknown()
7
+ .refine(isJsonObject, "expected a JSON object");
@@ -0,0 +1,66 @@
1
+ import type { JsonObject, JsonValue } from "../types/json";
2
+ import { isNumber, isObject, isText } from "./guards";
3
+
4
+ /** A step of the walk in {@link isJsonValue}: visit a node, or leave one. */
5
+ type JsonWalkStep = { visit: unknown } | { leave: object };
6
+
7
+ /**
8
+ * Whether `value` is wholly JSON data: strings, numbers, booleans, null, and
9
+ * arrays and plain objects of the same, none of them reached through itself.
10
+ * Walked with a stack, so a deeply nested value cannot overflow it.
11
+ */
12
+ export function isJsonValue(value: unknown): value is JsonValue {
13
+ const stack: JsonWalkStep[] = [{ visit: value }];
14
+ const ancestors = new Set<object>();
15
+ for (let step = stack.pop(); step; step = stack.pop()) {
16
+ if ("leave" in step) {
17
+ ancestors.delete(step.leave);
18
+ continue;
19
+ }
20
+ const item = step.visit;
21
+ if (
22
+ item === null ||
23
+ isText(item) ||
24
+ isNumber(item) ||
25
+ typeof item === "boolean"
26
+ )
27
+ continue;
28
+ if (typeof item !== "object" || ancestors.has(item)) return false;
29
+ const prototype: unknown = Object.getPrototypeOf(item);
30
+ if (
31
+ !Array.isArray(item) &&
32
+ prototype !== Object.prototype &&
33
+ prototype !== null
34
+ )
35
+ return false;
36
+ ancestors.add(item);
37
+ stack.push({ leave: item });
38
+ for (const child of Object.values(item)) stack.push({ visit: child });
39
+ }
40
+ return true;
41
+ }
42
+
43
+ /** Whether `value` is wholly JSON data and an object: neither `null` nor an array. */
44
+ export function isJsonObject(value: unknown): value is JsonObject {
45
+ return (
46
+ typeof value === "object" &&
47
+ value !== null &&
48
+ !Array.isArray(value) &&
49
+ isJsonValue(value)
50
+ );
51
+ }
52
+
53
+ /** `text` parsed as JSON; a `SyntaxError` when it is not. */
54
+ export function parseJson(text: string): JsonValue {
55
+ const parsed: unknown = JSON.parse(text);
56
+ if (!isJsonValue(parsed)) throw new TypeError("expected JSON data");
57
+ return parsed;
58
+ }
59
+
60
+ /** `text` parsed as a JSON object. */
61
+ export function parseJsonObject(text: string): JsonObject {
62
+ const parsed = parseJson(text);
63
+ if (!isObject(parsed) || Array.isArray(parsed))
64
+ throw new TypeError("expected a JSON object");
65
+ return parsed;
66
+ }
@@ -0,0 +1,10 @@
1
+ import { z } from "zod";
2
+ import { SEND_EVENT_TYPES } from "../types";
3
+
4
+ const [FIRST_EVENT_TYPE, ...OTHER_EVENT_TYPES] = SEND_EVENT_TYPES;
5
+
6
+ /** A send event's type: one of {@link SEND_EVENT_TYPES}. */
7
+ export const sendEventTypeSchema = z.enum([
8
+ FIRST_EVENT_TYPE,
9
+ ...OTHER_EVENT_TYPES,
10
+ ]);
@@ -123,12 +123,18 @@ function moveIssues(
123
123
  kept: Set<string>,
124
124
  ): CategoryIssue[] {
125
125
  return entries.flatMap((entry, index) =>
126
- isKept(entry) || kept.has(entry.moveTo as string)
126
+ entry.moveTo === undefined || kept.has(entry.moveTo)
127
127
  ? []
128
128
  : [{ message: UNKNOWN_MOVE_TARGET, path: [index, "moveTo"] }],
129
129
  );
130
130
  }
131
131
 
132
+ /**
133
+ * Adds the issues of the category rows: blank labels and duplicates in each
134
+ * language, a removed marketing category, a move to a category that is not
135
+ * kept. Each issue is reported once per row and part, whichever language
136
+ * shows it first.
137
+ */
132
138
  function refineCategoryEntries(
133
139
  entries: CategoryEntry[],
134
140
  context: z.RefinementCtx,
@@ -147,7 +153,6 @@ function refineCategoryEntries(
147
153
  ...moveIssues(entriesIn(entries, LOCALIZED_TEXT_FALLBACK_LOCALE), kept),
148
154
  ]
149
155
  .filter((issue) => {
150
- // One issue per row and part, whichever language shows it first.
151
156
  const id = JSON.stringify(issue);
152
157
  if (seen.has(id)) return false;
153
158
  seen.add(id);
@@ -1,5 +1,7 @@
1
1
  import { z } from "zod";
2
2
  import { MAX_NAME_LENGTH, MAX_SLUG_LENGTH, SLUG_PATTERN } from "../constants";
3
+ import type { Block } from "../types";
4
+ import { jsonObjectSchema } from "./json.schema";
3
5
 
4
6
  const MIN_HEADING_SIZE = 12;
5
7
  const MAX_HEADING_SIZE = 48;
@@ -31,7 +33,7 @@ export const conditionSchema = z.object({
31
33
  const alignSchema = z.enum(["left", "center", "right"]);
32
34
  const base = { id: z.string().min(1), visibleIf: conditionSchema.nullable() };
33
35
 
34
- export const blockSchema: z.ZodType<unknown> = z.lazy(() =>
36
+ export const blockSchema: z.ZodType<Block, z.ZodTypeDef, unknown> = z.lazy(() =>
35
37
  z.discriminatedUnion("type", [
36
38
  z.object({
37
39
  ...base,
@@ -148,7 +150,7 @@ export const saveContentSchema = z.object({
148
150
  revision: draftRevisionSchema,
149
151
  });
150
152
 
151
- export const testDataSchema = z.object({ data: z.record(z.unknown()) });
153
+ export const testDataSchema = z.object({ data: jsonObjectSchema });
152
154
 
153
155
  /**
154
156
  * Which content a preview renders when it carries none: the working draft
@@ -165,7 +167,7 @@ export const previewSchema = z.object({
165
167
  locale: z.string().min(1).optional(),
166
168
  version: previewVersionSchema.optional(),
167
169
  content: templateContentSchema.optional(),
168
- data: z.record(z.unknown()).optional(),
170
+ data: jsonObjectSchema.optional(),
169
171
  });
170
172
 
171
173
  /**
@@ -175,7 +177,7 @@ export const previewSchema = z.object({
175
177
  export const realSendSchema = z.object({
176
178
  to: z.array(z.string().email()).min(1).max(MAX_TEST_RECIPIENT_COUNT),
177
179
  locale: z.string().min(1).optional(),
178
- data: z.record(z.unknown()).optional(),
180
+ data: jsonObjectSchema.optional(),
179
181
  });
180
182
 
181
183
  export const testSendSchema = previewSchema.extend({
@@ -192,6 +194,9 @@ export const templateDetailsSchema = z.object({
192
194
  category: z.string().nullable().optional(),
193
195
  });
194
196
 
197
+ /** The details a template's drawer or the editor saves, validated. */
198
+ export type TemplateDetails = z.infer<typeof templateDetailsSchema>;
199
+
195
200
  export const duplicateSchema = z.object({
196
201
  slug: slugSchema,
197
202
  name: z.string().trim().min(1).max(MAX_NAME_LENGTH),
@@ -1,50 +0,0 @@
1
- import { readFileSync, readdirSync } from 'node:fs'
2
- import { join } from 'node:path'
3
- import { describe, expect, it } from 'vitest'
4
- import en from '../i18n/locales/mailing-en-GB.json'
5
-
6
- const APP = join(__dirname, '..', 'app')
7
- const EDITOR_SOURCES = [
8
- join(APP, 'components', 'Editor.vue'),
9
- join(APP, 'composables', 'useEditorActions.ts'),
10
- join(APP, 'composables', 'useEditorShortcuts.ts'),
11
- ...readdirSync(join(APP, 'components', 'editor')).map((file) =>
12
- join(APP, 'components', 'editor', file),
13
- ),
14
- ]
15
- const LITERAL_KEY =
16
- /'(dms_mailing\.(?:editor|blocks|templates\.status)\.[a-z_.]+)'/g
17
- const PREFIX_CONSTANT = /const (\w*KEY) = '(dms_mailing[a-z_.]*)'/g
18
- const PREFIXED_KEY = /\$\{(\w*KEY)\}\.([a-z_.]+)`/g
19
-
20
- function keysUsedIn(source: string): string[] {
21
- const prefixes = new Map(
22
- [...source.matchAll(PREFIX_CONSTANT)].map((match) => [match[1], match[2]]),
23
- )
24
- const prefixValues = new Set(prefixes.values())
25
- const literal = [...source.matchAll(LITERAL_KEY)]
26
- .map((match) => match[1] as string)
27
- .filter((key) => !prefixValues.has(key))
28
- const prefixed = [...source.matchAll(PREFIXED_KEY)]
29
- .filter((match) => prefixes.has(match[1]))
30
- .map((match) => `${prefixes.get(match[1])}.${match[2]}`)
31
- return [...literal, ...prefixed]
32
- }
33
-
34
- function keysOf(value: unknown, prefix = ''): string[] {
35
- if (typeof value !== 'object' || value === null) return [prefix]
36
- return Object.entries(value).flatMap(([key, child]) =>
37
- keysOf(child, prefix ? `${prefix}.${key}` : key),
38
- )
39
- }
40
-
41
- describe('editor locales', () => {
42
- it('define every literal key the editor components use', () => {
43
- const known = new Set(keysOf(en))
44
- const used = EDITOR_SOURCES.flatMap((file) =>
45
- keysUsedIn(readFileSync(file, 'utf8')),
46
- )
47
- expect(used.length).toBeGreaterThan(0)
48
- expect(used.filter((key) => !known.has(key))).toEqual([])
49
- })
50
- })
@@ -1,138 +0,0 @@
1
- import { readFileSync, readdirSync } from 'node:fs'
2
- import { join } from 'node:path'
3
- import { describe, expect, it } from 'vitest'
4
- import en from '../i18n/locales/mailing-en-GB.json'
5
- import fr from '../i18n/locales/mailing-fr-FR.json'
6
-
7
- // vitest runs with the layer as its root; the backend sits one level above it.
8
- const BACKEND_SOURCE_DIR = join(process.cwd(), '..', 'src')
9
-
10
- // `$dms_mailing.a.b` as the backend writes it. A literal that stops on a dot is
11
- // the prefix of a key completed by a template expression (`${status}`); those
12
- // are resolved at runtime and cannot be checked here.
13
- const KEY_LITERAL = /\$dms_mailing\.[\w.]+/g
14
- const DYNAMIC_KEY_SUFFIX = '.'
15
-
16
- function keysOf(value: unknown, prefix = ''): string[] {
17
- if (typeof value !== 'object' || value === null) return [prefix]
18
- return Object.entries(value).flatMap(([key, child]) =>
19
- keysOf(child, prefix ? `${prefix}.${key}` : key),
20
- )
21
- }
22
-
23
- function typescriptFiles(directory: string): string[] {
24
- return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
25
- const path = join(directory, entry.name)
26
- if (entry.isDirectory()) return typescriptFiles(path)
27
- return entry.isFile() && path.endsWith('.ts') ? [path] : []
28
- })
29
- }
30
-
31
- function backendKeys(): string[] {
32
- const found = new Set<string>()
33
- for (const file of typescriptFiles(BACKEND_SOURCE_DIR)) {
34
- for (const literal of readFileSync(file, 'utf8').match(KEY_LITERAL) ?? []) {
35
- const key = literal.slice(1)
36
- if (!key.endsWith(DYNAMIC_KEY_SUFFIX)) found.add(key)
37
- }
38
- }
39
- return [...found].sort()
40
- }
41
-
42
- function messagesOf(value: unknown, prefix = ''): [string, string][] {
43
- if (typeof value === 'string') return [[prefix, value]]
44
- if (typeof value !== 'object' || value === null) return []
45
- return Object.entries(value).flatMap(([key, child]) =>
46
- messagesOf(child, prefix ? `${prefix}.${key}` : key),
47
- )
48
- }
49
-
50
- // `{name}` placeholders and `{'…'}` literals, the only braces a message holds.
51
- const BRACED = /\{'[^']*'\}|\{\w+\}/g
52
- const PLACEHOLDER = /\{(\w+)\}/g
53
- const PLURAL_SEPARATOR = '|'
54
- // vue-i18n's default rule picks, for three forms, the first at 0 and the
55
- // second at 1: French says « 0 envoi », so every French plural needs all three.
56
- const FRENCH_PLURAL_FORMS = 3
57
-
58
- function placeholdersOf(message: string): string[] {
59
- return [
60
- ...new Set([...message.matchAll(PLACEHOLDER)].map((match) => match[1])),
61
- ].sort()
62
- }
63
-
64
- function resolve(root: unknown, key: string): unknown {
65
- return key
66
- .split('.')
67
- .reduce<unknown>(
68
- (node, part) =>
69
- typeof node === 'object' && node !== null
70
- ? (node as Record<string, unknown>)[part]
71
- : undefined,
72
- root,
73
- )
74
- }
75
-
76
- describe('locales', () => {
77
- it('carry the same key set', () => {
78
- expect(keysOf(fr).sort()).toEqual(keysOf(en).sort())
79
- })
80
- it('cover every namespace the pages use', () => {
81
- for (const namespace of [
82
- 'overview',
83
- 'templates',
84
- 'editor',
85
- 'sends',
86
- 'settings',
87
- 'metrics',
88
- 'funnel',
89
- 'errors',
90
- 'blocks',
91
- ]) {
92
- expect(en.dms_mailing).toHaveProperty(namespace)
93
- }
94
- })
95
- it('resolve every static key the backend emits', () => {
96
- const keys = backendKeys()
97
- expect(keys.length).toBeGreaterThan(0)
98
- expect(keys.filter((key) => typeof resolve(en, key) !== 'string')).toEqual(
99
- [],
100
- )
101
- expect(keys.filter((key) => typeof resolve(fr, key) !== 'string')).toEqual(
102
- [],
103
- )
104
- })
105
- it('compile: no stray brace or unescaped @ outside placeholders', () => {
106
- const broken = [...messagesOf(en), ...messagesOf(fr)].filter(
107
- ([, message]) => /[{}@]/.test(message.replace(BRACED, '')),
108
- )
109
- expect(broken).toEqual([])
110
- })
111
- it('give each message the same placeholders in both languages', () => {
112
- const french = new Map(messagesOf(fr))
113
- const mismatched = messagesOf(en)
114
- .filter(
115
- ([key, message]) =>
116
- placeholdersOf(message).join() !==
117
- placeholdersOf(french.get(key) ?? '').join(),
118
- )
119
- .map(([key]) => key)
120
- expect(mismatched).toEqual([])
121
- })
122
- it('say « modèle », never « template », in French', () => {
123
- const anglicisms = messagesOf(fr)
124
- .filter(([, message]) => /\btemplates?\b/i.test(message))
125
- .map(([key]) => key)
126
- expect(anglicisms).toEqual([])
127
- })
128
- it('write every French plural with three forms, 0 in the singular', () => {
129
- const incomplete = messagesOf(fr)
130
- .filter(
131
- ([, message]) =>
132
- message.includes(PLURAL_SEPARATOR) &&
133
- message.split(PLURAL_SEPARATOR).length !== FRENCH_PLURAL_FORMS,
134
- )
135
- .map(([key]) => key)
136
- expect(incomplete).toEqual([])
137
- })
138
- })